2026-10-15 17:21:05 +08:00
|
|
|
"""应用用例:一次业务动作对应一次完整存储事务。"""
|
|
|
|
|
|
|
|
|
|
from dataclasses import asdict, replace
|
|
|
|
|
import time
|
2026-10-21 17:57:55 +08:00
|
|
|
import secrets
|
2026-10-15 17:21:05 +08:00
|
|
|
|
|
|
|
|
from .catalogue import Catalogue
|
|
|
|
|
from .domain import (
|
|
|
|
|
Asset, FarmError, Identity, Plot, boosted, integer, quantity, text,
|
|
|
|
|
)
|
|
|
|
|
from .ports import Store
|
2026-10-19 17:25:25 +08:00
|
|
|
from .pests import advance_plot, start_growth
|
2026-10-21 17:57:55 +08:00
|
|
|
from .yield_rules import season_yield, remaining_yield
|
2026-10-15 17:21:05 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class FarmService:
|
|
|
|
|
def __init__(self, store: Store, catalogue: Catalogue, clock=None):
|
|
|
|
|
self.store = store
|
|
|
|
|
self.catalogue = catalogue
|
|
|
|
|
self.clock = clock if clock is not None else lambda: int(time.time())
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _identity(identity):
|
|
|
|
|
if not isinstance(identity, Identity):
|
|
|
|
|
raise FarmError("invalid_identity")
|
|
|
|
|
|
|
|
|
|
async def _execute(
|
|
|
|
|
self, identity, operation_id, action, parameters, operation,
|
|
|
|
|
):
|
|
|
|
|
self._identity(identity)
|
|
|
|
|
text(operation_id)
|
|
|
|
|
now = integer(self.clock())
|
|
|
|
|
return await self.store.transact(
|
|
|
|
|
identity, operation_id, action, parameters, now,
|
|
|
|
|
lambda repository: operation(repository, now),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _result(repository, **details):
|
|
|
|
|
account = repository.account()
|
|
|
|
|
return {
|
|
|
|
|
**details, "account": asdict(account), "level": account.level,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def snapshot(self, identity: Identity):
|
|
|
|
|
self._identity(identity)
|
2026-10-19 17:25:25 +08:00
|
|
|
now = integer(self.clock())
|
|
|
|
|
snapshot = await self.store.snapshot(identity)
|
|
|
|
|
return replace(snapshot, plots=tuple(
|
|
|
|
|
advance_plot(plot, now) for plot in snapshot.plots
|
|
|
|
|
))
|
2026-10-15 17:21:05 +08:00
|
|
|
|
|
|
|
|
async def register(
|
|
|
|
|
self, identity: Identity, name: str, operation_id: str,
|
|
|
|
|
):
|
|
|
|
|
text(name, 64)
|
|
|
|
|
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
created = repository.register(name)
|
2026-10-21 14:07:18 +08:00
|
|
|
return self._result(
|
|
|
|
|
repository, created=created,
|
|
|
|
|
plot_count=len(repository.plots()),
|
|
|
|
|
)
|
2026-10-15 17:21:05 +08:00
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "register", {"name": name}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def buy(
|
|
|
|
|
self, identity: Identity, name: str, count: int, operation_id: str,
|
|
|
|
|
):
|
|
|
|
|
text(name)
|
|
|
|
|
quantity(count)
|
|
|
|
|
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
plant = self.catalogue.get(name)
|
2026-10-19 17:25:25 +08:00
|
|
|
if not plant.enabled:
|
|
|
|
|
raise FarmError("plant_disabled")
|
|
|
|
|
if plant.crop_type != 1:
|
|
|
|
|
raise FarmError("unsupported_crop_type")
|
|
|
|
|
if not plant.purchasable:
|
|
|
|
|
raise FarmError("plant_not_purchasable")
|
2026-10-15 17:21:05 +08:00
|
|
|
if repository.account().level < plant.level:
|
|
|
|
|
raise FarmError("level_required")
|
|
|
|
|
cost = integer(plant.cost * count)
|
|
|
|
|
repository.asset(plant.asset, -cost)
|
2026-10-19 17:25:25 +08:00
|
|
|
repository.stock(plant, seeds=count)
|
2026-10-15 17:21:05 +08:00
|
|
|
return self._result(
|
2026-10-19 17:25:25 +08:00
|
|
|
repository, name=plant.name, plant_id=plant.plant_id,
|
|
|
|
|
quantity=count, cost=cost,
|
2026-10-15 17:21:05 +08:00
|
|
|
asset=plant.asset.value,
|
2026-10-19 17:25:25 +08:00
|
|
|
seeds=repository.item(plant).seeds,
|
2026-10-15 17:21:05 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "buy",
|
|
|
|
|
{"name": name, "count": count}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def sow(
|
|
|
|
|
self, identity: Identity, name: str, count: int | None,
|
|
|
|
|
operation_id: str,
|
|
|
|
|
):
|
|
|
|
|
text(name)
|
|
|
|
|
if count is not None:
|
|
|
|
|
quantity(count)
|
|
|
|
|
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
|
|
|
|
plant = self.catalogue.get(name)
|
2026-10-19 17:25:25 +08:00
|
|
|
if not plant.enabled:
|
|
|
|
|
raise FarmError("plant_disabled")
|
|
|
|
|
if plant.crop_type != 1:
|
|
|
|
|
raise FarmError("unsupported_crop_type")
|
|
|
|
|
available = repository.item(plant).seeds
|
2026-10-15 17:21:05 +08:00
|
|
|
empty = [p for p in repository.plots() if p.plant is None]
|
|
|
|
|
amount = min(available, len(empty)) if count is None else count
|
|
|
|
|
if amount == 0 or amount > available:
|
|
|
|
|
raise FarmError("insufficient_stock")
|
|
|
|
|
if amount > len(empty):
|
|
|
|
|
raise FarmError("insufficient_empty_plots")
|
|
|
|
|
mature_at = integer(now + plant.growth_seconds)
|
2026-10-19 17:25:25 +08:00
|
|
|
repository.stock(plant, seeds=-amount)
|
2026-10-15 17:21:05 +08:00
|
|
|
for plot in empty[:amount]:
|
2026-10-19 17:25:25 +08:00
|
|
|
repository.save_plot(start_growth(replace(
|
2026-10-15 17:21:05 +08:00
|
|
|
plot, plant=plant, mature_at=mature_at, harvest_count=0,
|
2026-10-19 17:25:25 +08:00
|
|
|
), now))
|
2026-10-20 14:13:31 +08:00
|
|
|
experience = integer(amount * 2)
|
|
|
|
|
repository.asset(Asset.EXPERIENCE, experience)
|
2026-10-15 17:21:05 +08:00
|
|
|
return self._result(
|
2026-10-19 17:25:25 +08:00
|
|
|
repository, name=plant.name, plant_id=plant.plant_id,
|
|
|
|
|
quantity=amount,
|
2026-10-15 17:21:05 +08:00
|
|
|
slots=[p.slot for p in empty[:amount]],
|
2026-10-19 17:25:25 +08:00
|
|
|
seeds=repository.item(plant).seeds,
|
2026-10-20 14:13:31 +08:00
|
|
|
experience=experience,
|
2026-10-15 17:21:05 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "sow",
|
|
|
|
|
{"name": name, "count": count}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def harvest(self, identity: Identity, operation_id: str):
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
|
|
|
|
harvested = []
|
|
|
|
|
total_experience = 0
|
|
|
|
|
for plot in repository.plots():
|
2026-10-19 17:25:25 +08:00
|
|
|
plot = advance_plot(plot, now)
|
2026-10-15 17:21:05 +08:00
|
|
|
plant = plot.plant
|
|
|
|
|
if (
|
|
|
|
|
plant is None or plot.withered or plot.mature_at is None
|
|
|
|
|
or plot.mature_at > now
|
|
|
|
|
):
|
|
|
|
|
continue
|
2026-10-21 17:57:55 +08:00
|
|
|
amount = remaining_yield(plot)
|
2026-10-15 17:21:05 +08:00
|
|
|
experience = boosted(
|
|
|
|
|
plant.experience, plot.experience_bonus,
|
|
|
|
|
)
|
2026-10-19 17:25:25 +08:00
|
|
|
repository.stock(plant, crops=amount)
|
2026-10-15 17:21:05 +08:00
|
|
|
total_experience = integer(total_experience + experience)
|
|
|
|
|
count = plot.harvest_count + 1
|
|
|
|
|
mature_at = (
|
|
|
|
|
integer(now + plant.regrowth_seconds)
|
|
|
|
|
if count < plant.crop else None
|
|
|
|
|
)
|
2026-10-19 17:25:25 +08:00
|
|
|
updated = replace(
|
2026-10-15 17:21:05 +08:00
|
|
|
plot, mature_at=mature_at, harvest_count=count,
|
2026-10-19 17:25:25 +08:00
|
|
|
pest_count=0, pest_spawned=0, growth_remaining=None,
|
|
|
|
|
growth_updated_at=None, next_pest_at=None,
|
|
|
|
|
maturity_pests=0,
|
2026-10-21 17:57:55 +08:00
|
|
|
stolen_count=0,
|
2026-10-19 17:25:25 +08:00
|
|
|
)
|
2026-10-21 17:57:55 +08:00
|
|
|
repository.clear_thefts(plot.slot)
|
2026-10-19 17:25:25 +08:00
|
|
|
repository.save_plot(
|
|
|
|
|
start_growth(updated, now) if count < plant.crop
|
|
|
|
|
else updated
|
|
|
|
|
)
|
2026-10-15 17:21:05 +08:00
|
|
|
harvested.append({
|
|
|
|
|
"slot": plot.slot, "name": plant.name,
|
2026-10-19 17:25:25 +08:00
|
|
|
"plant_id": plant.plant_id,
|
2026-10-15 17:21:05 +08:00
|
|
|
"quantity": amount, "experience": experience,
|
2026-10-19 17:25:25 +08:00
|
|
|
"pests": plot.maturity_pests,
|
|
|
|
|
"yield_loss_percent": plot.maturity_pests * 5,
|
2026-10-15 17:21:05 +08:00
|
|
|
})
|
|
|
|
|
if not harvested:
|
|
|
|
|
raise FarmError("nothing_to_harvest")
|
|
|
|
|
repository.asset(Asset.EXPERIENCE, total_experience)
|
|
|
|
|
return self._result(
|
|
|
|
|
repository, harvested=harvested,
|
|
|
|
|
experience=total_experience,
|
|
|
|
|
quantity=sum(item["quantity"] for item in harvested),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "harvest", {}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def eradicate(self, identity: Identity, operation_id: str):
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
|
|
|
|
withered = [p for p in repository.plots() if p.withered]
|
|
|
|
|
if not withered:
|
|
|
|
|
raise FarmError("nothing_to_eradicate")
|
|
|
|
|
for plot in withered:
|
2026-10-21 17:57:55 +08:00
|
|
|
repository.clear_thefts(plot.slot)
|
2026-10-15 17:21:05 +08:00
|
|
|
repository.save_plot(replace(
|
|
|
|
|
plot, plant=None, mature_at=None, harvest_count=0,
|
2026-10-19 17:25:25 +08:00
|
|
|
pest_count=0, pest_spawned=0, growth_remaining=None,
|
|
|
|
|
growth_updated_at=None, next_pest_at=None,
|
|
|
|
|
maturity_pests=0,
|
2026-10-21 17:57:55 +08:00
|
|
|
stolen_count=0,
|
2026-10-15 17:21:05 +08:00
|
|
|
))
|
2026-10-20 14:13:31 +08:00
|
|
|
experience = integer(len(withered) * 2)
|
|
|
|
|
repository.asset(Asset.EXPERIENCE, experience)
|
2026-10-15 17:21:05 +08:00
|
|
|
return self._result(
|
|
|
|
|
repository, slots=[p.slot for p in withered],
|
2026-10-20 14:13:31 +08:00
|
|
|
experience=experience,
|
2026-10-15 17:21:05 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "eradicate", {}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-21 17:57:55 +08:00
|
|
|
async def steal(self, identity: Identity, target: Identity,
|
|
|
|
|
operation_id: str):
|
|
|
|
|
self._identity(identity)
|
|
|
|
|
self._identity(target)
|
|
|
|
|
if identity == target:
|
|
|
|
|
raise FarmError('cannot_steal_self')
|
|
|
|
|
if (identity.deployment, identity.realm, identity.platform) != (
|
|
|
|
|
target.deployment, target.realm, target.platform
|
|
|
|
|
):
|
|
|
|
|
raise FarmError('different_farm_scope')
|
|
|
|
|
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
|
|
|
|
owner = repository.for_identity(target)
|
|
|
|
|
try:
|
|
|
|
|
owner.account()
|
|
|
|
|
except FarmError as error:
|
|
|
|
|
if error.code == 'not_registered':
|
|
|
|
|
raise FarmError('target_not_registered') from error
|
|
|
|
|
raise
|
|
|
|
|
day_start = ((now+28800)//86400)*86400-28800
|
|
|
|
|
used = repository.successful_thefts(day_start)
|
|
|
|
|
if used >= 5:
|
|
|
|
|
raise FarmError('steal_daily_limit')
|
|
|
|
|
items = []
|
|
|
|
|
skipped = {'not_ripe': 0, 'already_stolen': 0, 'protected': 0}
|
|
|
|
|
for stored in owner.plots():
|
|
|
|
|
plot = advance_plot(stored, now)
|
|
|
|
|
if (plot.plant is None or plot.withered
|
|
|
|
|
or plot.mature_at is None or plot.mature_at > now):
|
|
|
|
|
skipped['not_ripe'] += 1
|
|
|
|
|
continue
|
|
|
|
|
if owner.theft_seen(plot.slot, identity):
|
|
|
|
|
skipped['already_stolen'] += 1
|
|
|
|
|
continue
|
|
|
|
|
total = season_yield(plot)
|
|
|
|
|
available = remaining_yield(plot)-(total+1)//2
|
|
|
|
|
if available <= 0:
|
|
|
|
|
skipped['protected'] += 1
|
|
|
|
|
continue
|
|
|
|
|
amount = min(secrets.randbelow(3)+1, available)
|
|
|
|
|
repository.stock(plot.plant, crops=amount)
|
|
|
|
|
owner.save_plot(replace(
|
|
|
|
|
plot, stolen_count=plot.stolen_count+amount,
|
|
|
|
|
))
|
|
|
|
|
owner.record_theft(plot.slot, identity)
|
|
|
|
|
items.append({'slot': plot.slot, 'name': plot.plant.name,
|
|
|
|
|
'plant_id': plot.plant.plant_id,
|
|
|
|
|
'quantity': amount})
|
|
|
|
|
return self._result(
|
|
|
|
|
repository, items=items, skipped=skipped,
|
|
|
|
|
quantity=sum(i['quantity'] for i in items),
|
|
|
|
|
remaining_attempts=5-used-bool(items),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(identity, operation_id, 'steal',
|
|
|
|
|
{'target': target.key}, operation)
|
|
|
|
|
|
2026-10-19 17:25:25 +08:00
|
|
|
async def clear_pests(self, identity: Identity, operation_id: str):
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
|
|
|
|
cleared = []
|
|
|
|
|
for stored in repository.plots():
|
|
|
|
|
plot = advance_plot(stored, now)
|
|
|
|
|
if plot.pest_count == 0:
|
|
|
|
|
continue
|
|
|
|
|
cleared.append({"slot": plot.slot, "pests": plot.pest_count})
|
|
|
|
|
updated = replace(plot, pest_count=0)
|
|
|
|
|
repository.save_plot(advance_plot(updated, now))
|
|
|
|
|
if not cleared:
|
|
|
|
|
raise FarmError("nothing_to_clear")
|
2026-10-20 14:13:31 +08:00
|
|
|
quantity = integer(sum(item['pests'] for item in cleared))
|
|
|
|
|
repository.asset(Asset.EXPERIENCE, quantity)
|
2026-10-19 17:25:25 +08:00
|
|
|
return self._result(
|
|
|
|
|
repository, cleared=cleared,
|
2026-10-20 14:13:31 +08:00
|
|
|
quantity=quantity, experience=quantity,
|
2026-10-19 17:25:25 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "clear_pests", {}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-15 17:21:05 +08:00
|
|
|
async def lock(
|
|
|
|
|
self, identity: Identity, name: str, locked: bool,
|
|
|
|
|
operation_id: str,
|
|
|
|
|
):
|
|
|
|
|
text(name)
|
|
|
|
|
if type(locked) is not bool:
|
|
|
|
|
raise FarmError("invalid_lock")
|
|
|
|
|
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
2026-10-19 17:25:25 +08:00
|
|
|
plant = self.catalogue.get(name)
|
|
|
|
|
repository.lock(plant, locked)
|
|
|
|
|
return self._result(
|
|
|
|
|
repository, name=plant.name, plant_id=plant.plant_id,
|
|
|
|
|
locked=locked,
|
|
|
|
|
)
|
2026-10-15 17:21:05 +08:00
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "lock",
|
|
|
|
|
{"name": name, "locked": locked}, operation,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def sell(
|
|
|
|
|
self, identity: Identity, name: str | None, count: int | None,
|
|
|
|
|
operation_id: str,
|
|
|
|
|
):
|
|
|
|
|
if name is not None:
|
|
|
|
|
text(name)
|
|
|
|
|
if count is not None:
|
|
|
|
|
quantity(count)
|
|
|
|
|
if name is None:
|
|
|
|
|
raise FarmError("name_required_for_quantity")
|
|
|
|
|
|
|
|
|
|
def operation(repository, now):
|
|
|
|
|
repository.account()
|
|
|
|
|
if name is not None:
|
2026-10-19 17:25:25 +08:00
|
|
|
plant = self.catalogue.get(name)
|
|
|
|
|
item = repository.item(plant)
|
2026-10-15 17:21:05 +08:00
|
|
|
if item.locked:
|
|
|
|
|
raise FarmError("plant_locked")
|
|
|
|
|
amount = item.crops if count is None else count
|
|
|
|
|
if amount <= 0 or amount > item.crops:
|
|
|
|
|
raise FarmError("insufficient_stock")
|
2026-10-19 17:25:25 +08:00
|
|
|
items = [(plant.plant_id, amount)]
|
2026-10-15 17:21:05 +08:00
|
|
|
else:
|
|
|
|
|
items = [
|
2026-10-19 17:25:25 +08:00
|
|
|
(item.plant_id, item.crops)
|
2026-10-15 17:21:05 +08:00
|
|
|
for item in repository.items()
|
|
|
|
|
if not item.locked and item.crops > 0
|
|
|
|
|
]
|
|
|
|
|
if not items:
|
|
|
|
|
raise FarmError("nothing_to_sell")
|
|
|
|
|
proceeds = 0
|
|
|
|
|
sold = []
|
2026-10-19 17:25:25 +08:00
|
|
|
for plant_id, amount in items:
|
|
|
|
|
plant = self.catalogue.get_by_id(plant_id)
|
2026-10-15 17:21:05 +08:00
|
|
|
proceeds = integer(proceeds + plant.price * amount)
|
2026-10-19 17:25:25 +08:00
|
|
|
repository.stock(plant, crops=-amount)
|
|
|
|
|
sold.append({
|
|
|
|
|
"name": plant.name, "plant_id": plant.plant_id,
|
|
|
|
|
"quantity": amount,
|
|
|
|
|
})
|
2026-10-15 17:21:05 +08:00
|
|
|
repository.asset(Asset.POINT, proceeds)
|
|
|
|
|
return self._result(
|
|
|
|
|
repository, sold=sold, proceeds=proceeds,
|
|
|
|
|
quantity=sum(item["quantity"] for item in sold),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await self._execute(
|
|
|
|
|
identity, operation_id, "sell",
|
|
|
|
|
{"name": name, "count": count}, operation,
|
|
|
|
|
)
|