mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-31 23:09:58 +08:00
add # 新增项目整体大纲
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 共享核心预留位置
|
||||
# 共享基础核心
|
||||
|
||||
当前只有职责说明,未实现新核心。完整目标见 docs/02-architecture.md。
|
||||
实施时创建 domain、application、ports、infrastructure、presentation。
|
||||
禁止导入 nonebot、astrbot、zhenxun;内部模块采用相对导入。
|
||||
先实现注册、库存、资产和一个完整交易,不直接复制旧全局管理器。
|
||||
本轮已实现框架无关的作物目录、注册、交易及种植闭环。
|
||||
模块责任与限制见 docs/09-foundation.md,运行方式见根 README。
|
||||
运行无第三方依赖,导入不创建数据库、文件或后台线程。
|
||||
本包不是机器人插件入口;integrations 下接入尚未实现。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""框架无关的农场基础核心,导入时不启动服务。"""
|
||||
|
||||
from .application import FarmService
|
||||
from .catalogue import Catalogue
|
||||
from .domain import Asset, FarmError, Identity
|
||||
from .storage import SQLiteStore
|
||||
|
||||
__all__ = [
|
||||
"Asset", "Catalogue", "FarmError", "FarmService", "Identity",
|
||||
"SQLiteStore",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""离线演示;只有显式调用才创建指定数据库。"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from . import Catalogue, FarmService, Identity, SQLiteStore
|
||||
from .presentation import receipt_text, snapshot_text
|
||||
|
||||
|
||||
async def demo(database):
|
||||
clock = [0]
|
||||
catalogue = Catalogue.bundled()
|
||||
plant = min(
|
||||
(p for p in catalogue if p.level == 0 and not p.is_vip),
|
||||
key=lambda p: p.cost,
|
||||
)
|
||||
store = SQLiteStore(database)
|
||||
await store.initialize()
|
||||
service = FarmService(store, catalogue, lambda: clock[0])
|
||||
identity = Identity("demo", "local", "local", "farmer")
|
||||
try:
|
||||
for receipt in (
|
||||
await service.register(identity, "演示农场", "demo-register"),
|
||||
await service.buy(identity, plant.name, 1, "demo-buy"),
|
||||
await service.sow(identity, plant.name, 1, "demo-sow"),
|
||||
):
|
||||
print(receipt_text(receipt))
|
||||
clock[0] = plant.growth_seconds
|
||||
print(receipt_text(await service.harvest(identity, "demo-harvest")))
|
||||
print(receipt_text(await service.sell(
|
||||
identity, None, None, "demo-sell",
|
||||
)))
|
||||
print(snapshot_text(await service.snapshot(identity)))
|
||||
finally:
|
||||
await store.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", required=True)
|
||||
args = parser.parse_args()
|
||||
database = Path(args.database).resolve()
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
logging.basicConfig(
|
||||
filename=database.with_suffix(".log"), encoding="utf-8",
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
asyncio.run(demo(database))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""应用用例:一次业务动作对应一次完整存储事务。"""
|
||||
|
||||
from dataclasses import asdict, replace
|
||||
import time
|
||||
|
||||
from .catalogue import Catalogue
|
||||
from .domain import (
|
||||
Asset, FarmError, Identity, Plot, boosted, integer, quantity, text,
|
||||
)
|
||||
from .ports import Store
|
||||
|
||||
|
||||
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)
|
||||
return await self.store.snapshot(identity)
|
||||
|
||||
async def register(
|
||||
self, identity: Identity, name: str, operation_id: str,
|
||||
):
|
||||
text(name, 64)
|
||||
|
||||
def operation(repository, now):
|
||||
created = repository.register(name)
|
||||
return self._result(repository, created=created)
|
||||
|
||||
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)
|
||||
if repository.account().level < plant.level:
|
||||
raise FarmError("level_required")
|
||||
cost = integer(plant.cost * count)
|
||||
repository.asset(plant.asset, -cost)
|
||||
repository.stock(name, seeds=count)
|
||||
return self._result(
|
||||
repository, name=name, quantity=count, cost=cost,
|
||||
asset=plant.asset.value,
|
||||
seeds=repository.item(name).seeds,
|
||||
)
|
||||
|
||||
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)
|
||||
available = repository.item(name).seeds
|
||||
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)
|
||||
repository.stock(name, seeds=-amount)
|
||||
for plot in empty[:amount]:
|
||||
repository.save_plot(replace(
|
||||
plot, plant=plant, mature_at=mature_at, harvest_count=0,
|
||||
))
|
||||
return self._result(
|
||||
repository, name=name, quantity=amount,
|
||||
slots=[p.slot for p in empty[:amount]],
|
||||
seeds=repository.item(name).seeds,
|
||||
)
|
||||
|
||||
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():
|
||||
plant = plot.plant
|
||||
if (
|
||||
plant is None or plot.withered or plot.mature_at is None
|
||||
or plot.mature_at > now
|
||||
):
|
||||
continue
|
||||
amount = boosted(plant.harvest, plot.yield_bonus)
|
||||
experience = boosted(
|
||||
plant.experience, plot.experience_bonus,
|
||||
)
|
||||
repository.stock(plant.name, crops=amount)
|
||||
total_experience = integer(total_experience + experience)
|
||||
count = plot.harvest_count + 1
|
||||
mature_at = (
|
||||
integer(now + plant.regrowth_seconds)
|
||||
if count < plant.crop else None
|
||||
)
|
||||
repository.save_plot(replace(
|
||||
plot, mature_at=mature_at, harvest_count=count,
|
||||
))
|
||||
harvested.append({
|
||||
"slot": plot.slot, "name": plant.name,
|
||||
"quantity": amount, "experience": experience,
|
||||
})
|
||||
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:
|
||||
repository.save_plot(replace(
|
||||
plot, plant=None, mature_at=None, harvest_count=0,
|
||||
))
|
||||
return self._result(
|
||||
repository, slots=[p.slot for p in withered],
|
||||
)
|
||||
|
||||
return await self._execute(
|
||||
identity, operation_id, "eradicate", {}, operation,
|
||||
)
|
||||
|
||||
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()
|
||||
self.catalogue.get(name)
|
||||
repository.lock(name, locked)
|
||||
return self._result(repository, name=name, locked=locked)
|
||||
|
||||
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:
|
||||
item = repository.item(name)
|
||||
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")
|
||||
items = [(name, amount)]
|
||||
else:
|
||||
items = [
|
||||
(item.name, item.crops)
|
||||
for item in repository.items()
|
||||
if not item.locked and item.crops > 0
|
||||
]
|
||||
if not items:
|
||||
raise FarmError("nothing_to_sell")
|
||||
proceeds = 0
|
||||
sold = []
|
||||
for plant_name, amount in items:
|
||||
plant = self.catalogue.get(plant_name)
|
||||
proceeds = integer(proceeds + plant.price * amount)
|
||||
repository.stock(plant_name, crops=-amount)
|
||||
sold.append({"name": plant_name, "quantity": amount})
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""只读、离线作物目录加载。"""
|
||||
|
||||
from importlib import resources
|
||||
import json
|
||||
from types import MappingProxyType
|
||||
|
||||
from .domain import FarmError, Plant, text
|
||||
|
||||
|
||||
class Catalogue:
|
||||
def __init__(self, plants):
|
||||
items = tuple(plants)
|
||||
if not items or any(not isinstance(p, Plant) for p in items):
|
||||
raise FarmError("invalid_catalogue")
|
||||
names = {plant.name: plant for plant in items}
|
||||
if len(names) != len(items):
|
||||
raise FarmError("duplicate_plant")
|
||||
if len({p.plant_id for p in items}) != len(items):
|
||||
raise FarmError("duplicate_plant")
|
||||
self._plants = MappingProxyType(names)
|
||||
|
||||
def get(self, name: str) -> Plant:
|
||||
text(name)
|
||||
try:
|
||||
return self._plants[name]
|
||||
except KeyError as exc:
|
||||
raise FarmError("unknown_plant") from exc
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._plants.values())
|
||||
|
||||
def __len__(self):
|
||||
return len(self._plants)
|
||||
|
||||
@classmethod
|
||||
def loads(cls, content: str | bytes) -> "Catalogue":
|
||||
try:
|
||||
data = json.loads(content)
|
||||
if type(data["schema"]) is not int or data["schema"] != 1:
|
||||
raise FarmError("invalid_catalogue")
|
||||
if not isinstance(data["plants"], list):
|
||||
raise FarmError("invalid_catalogue")
|
||||
return cls(Plant.from_legacy(row) for row in data["plants"])
|
||||
except (KeyError, TypeError, ValueError, UnicodeError) as exc:
|
||||
raise FarmError("invalid_catalogue") from exc
|
||||
|
||||
@classmethod
|
||||
def bundled(cls) -> "Catalogue":
|
||||
content = resources.files(__package__).joinpath(
|
||||
"data", "plants.json"
|
||||
).read_bytes()
|
||||
return cls.loads(content)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
"""不可变领域数据及源自旧版的纯计算规则。"""
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from enum import Enum
|
||||
from math import isqrt
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
MAX_VALUE = 2**63 - 1
|
||||
MAX_QUANTITY = 1_000_000
|
||||
|
||||
|
||||
class FarmError(ValueError):
|
||||
"""可由接入层区分的业务失败。"""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class StorageError(RuntimeError):
|
||||
"""数据库故障,不得作为无农场或业务成功处理。"""
|
||||
|
||||
|
||||
class SchemaError(StorageError):
|
||||
"""数据库不属于本核心或 schema 不兼容。"""
|
||||
|
||||
|
||||
class Asset(str, Enum):
|
||||
"""资产字段;经验与两种货币分别记账。"""
|
||||
|
||||
POINT = "point" # 农场币。
|
||||
VIP_POINT = "vip_point" # 点券,不与农场币自动兑换。
|
||||
EXPERIENCE = "experience" # 累计经验。
|
||||
|
||||
|
||||
def integer(value: int, minimum: int = 0, maximum: int = MAX_VALUE) -> int:
|
||||
"""拒绝布尔值、非整数及超出明确范围的数值。"""
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise FarmError("invalid_integer")
|
||||
return value
|
||||
|
||||
|
||||
def text(value: str, maximum: int = 128) -> str:
|
||||
"""身份和操作标识必须是有界的非空字符串。"""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise FarmError("invalid_text")
|
||||
if len(value) > maximum or any(ord(char) < 32 for char in value):
|
||||
raise FarmError("invalid_text")
|
||||
return value
|
||||
|
||||
|
||||
def quantity(value: int) -> int:
|
||||
return integer(value, 1, MAX_QUANTITY)
|
||||
|
||||
|
||||
def level_for(experience: int) -> int:
|
||||
"""沿用 200*k*(k+1)//2 门槛,避免浮点误差。"""
|
||||
return (isqrt(1 + integer(experience) // 25) - 1) // 2
|
||||
|
||||
|
||||
def boosted(base: int, percent: int = 0) -> int:
|
||||
"""单地块向下取整;调用方只对各地块结果求和。"""
|
||||
integer(base)
|
||||
integer(percent, -100, 10_000)
|
||||
return integer(base * (100 + percent) // 100)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
"""调用方明确提供的身份,核心不猜测账号等价性。"""
|
||||
|
||||
deployment: str # 独立部署标识。
|
||||
realm: str # 调用方决定的游戏共享范围。
|
||||
platform: str # 消息平台命名空间,不是机器人框架名称。
|
||||
user_id: str # 平台用户标识,始终保存为字符串。
|
||||
|
||||
def __post_init__(self):
|
||||
for value in asdict(self).values():
|
||||
text(value)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return json.dumps(
|
||||
list(asdict(self).values()), ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Plant:
|
||||
"""播种时保存的不可变结算定义。"""
|
||||
|
||||
plant_id: int # 旧作物目录 ID。
|
||||
name: str # 唯一作物名称。
|
||||
level: int # 购买等级门槛。
|
||||
buy: int # 普通种子农场币价格。
|
||||
is_vip: bool # 是否使用点券购买。
|
||||
vip_buy: int # 点券种子价格。
|
||||
experience: int # 每季基础经验。
|
||||
harvest: int # 每季基础产量。
|
||||
price: int # 每个收获物的农场币售价。
|
||||
growth_seconds: int # 首次成熟所需秒数。
|
||||
crop: int # 最多可收获季数。
|
||||
regrowth_seconds: int # 兼容旧版的多季再生长秒数。
|
||||
|
||||
def __post_init__(self):
|
||||
text(self.name)
|
||||
if type(self.is_vip) is not bool:
|
||||
raise FarmError("invalid_catalogue")
|
||||
for name in (
|
||||
"plant_id", "growth_seconds", "crop", "regrowth_seconds",
|
||||
"harvest",
|
||||
):
|
||||
integer(getattr(self, name), 1)
|
||||
for name in ("level", "buy", "vip_buy", "experience", "price"):
|
||||
integer(getattr(self, name))
|
||||
|
||||
@property
|
||||
def asset(self) -> Asset:
|
||||
return Asset.VIP_POINT if self.is_vip else Asset.POINT
|
||||
|
||||
@property
|
||||
def cost(self) -> int:
|
||||
return self.vip_buy if self.is_vip else self.buy
|
||||
|
||||
@classmethod
|
||||
def from_legacy(cls, row: dict[str, Any]) -> "Plant":
|
||||
try:
|
||||
for key in (
|
||||
"id", "level", "buy", "isVip", "vipBuy", "experience",
|
||||
"harvest", "price", "time", "crop",
|
||||
):
|
||||
integer(row[key])
|
||||
if row["isVip"] not in (0, 1):
|
||||
raise FarmError("invalid_catalogue")
|
||||
if not isinstance(row["phase"], str):
|
||||
raise FarmError("invalid_catalogue")
|
||||
parts = row["phase"].split(",")
|
||||
if not parts or any(not p.isascii() or not p.isdigit()
|
||||
for p in parts):
|
||||
raise FarmError("invalid_catalogue")
|
||||
phases = list(dict.fromkeys(int(part) for part in parts))
|
||||
for phase in phases:
|
||||
integer(phase, 1)
|
||||
if len(phases) < 2 or phases != sorted(phases):
|
||||
raise FarmError("invalid_catalogue")
|
||||
return cls(
|
||||
row["id"], row["name"], row["level"], row["buy"],
|
||||
bool(row["isVip"]), row["vipBuy"], row["experience"],
|
||||
row["harvest"], row["price"], row["time"] * 3600,
|
||||
row["crop"], sum(phases[1:]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError, OverflowError) as exc:
|
||||
raise FarmError("invalid_catalogue") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
"""已提交的账户查询快照。"""
|
||||
|
||||
name: str # 农场名称。
|
||||
point: int # 当前农场币。
|
||||
vip_point: int # 当前点券。
|
||||
experience: int # 当前累计经验。
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
return level_for(self.experience)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InventoryItem:
|
||||
"""同一作物的种子、产物和锁定状态。"""
|
||||
|
||||
name: str # 作物名称。
|
||||
seeds: int = 0 # 未播种种子数。
|
||||
crops: int = 0 # 已收获产物数。
|
||||
locked: bool = False # 是否禁止出售产物。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Plot:
|
||||
"""地块状态;空地无 plant,最后一季后保留枯萎实例。"""
|
||||
|
||||
slot: int # 从 1 开始的地块编号。
|
||||
plant: Plant | None = None # 播种时冻结的作物规则。
|
||||
mature_at: int | None = None # 下一季成熟 UTC 秒数。
|
||||
harvest_count: int = 0 # 当前实例已收获季数。
|
||||
yield_bonus: int = 0 # 此地块产量加成百分比。
|
||||
experience_bonus: int = 0 # 此地块经验加成百分比。
|
||||
|
||||
@property
|
||||
def withered(self) -> bool:
|
||||
return self.plant is not None and self.harvest_count >= self.plant.crop
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
"""同一数据库读事务内取得的一致快照。"""
|
||||
|
||||
account: Account # 账户。
|
||||
inventory: tuple[InventoryItem, ...] # 按名称排序的仓库。
|
||||
plots: tuple[Plot, ...] # 按编号排序的地块。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Receipt:
|
||||
"""可持久化、可重放的交易结果;data 只是脱离数据库的返回副本。"""
|
||||
|
||||
operation_id: str # 调用方稳定提供的幂等操作标识。
|
||||
action: str # 已提交的业务动作。
|
||||
data: dict[str, Any] # 实际成交数值与提交后的资产。
|
||||
@@ -0,0 +1,32 @@
|
||||
"""应用层需要的最小事务仓储契约。"""
|
||||
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from .domain import (
|
||||
Account, Asset, Identity, InventoryItem, Plot, Receipt, Snapshot,
|
||||
)
|
||||
|
||||
|
||||
class Repository(Protocol):
|
||||
def account(self) -> Account: ...
|
||||
def register(self, name: str) -> bool: ...
|
||||
def asset(self, asset: Asset, delta: int) -> None: ...
|
||||
def item(self, name: str) -> InventoryItem: ...
|
||||
def items(self) -> tuple[InventoryItem, ...]: ...
|
||||
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None: ...
|
||||
def lock(self, name: str, locked: bool) -> None: ...
|
||||
def plots(self) -> tuple[Plot, ...]: ...
|
||||
def save_plot(self, plot: Plot) -> None: ...
|
||||
|
||||
|
||||
class Store(Protocol):
|
||||
async def initialize(self) -> None: ...
|
||||
async def close(self) -> None: ...
|
||||
|
||||
async def transact(
|
||||
self, identity: Identity, operation_id: str, action: str,
|
||||
parameters: dict[str, Any], now: int,
|
||||
operation: Callable[[Repository], dict[str, Any]],
|
||||
) -> Receipt: ...
|
||||
|
||||
async def snapshot(self, identity: Identity) -> Snapshot: ...
|
||||
@@ -0,0 +1,44 @@
|
||||
"""仅渲染已提交结果,不发送框架消息或修改业务状态。"""
|
||||
|
||||
from .domain import Receipt, Snapshot
|
||||
|
||||
|
||||
def receipt_text(receipt: Receipt) -> str:
|
||||
labels = {
|
||||
"register": "注册", "buy": "购买", "sow": "播种",
|
||||
"harvest": "收获", "eradicate": "铲除",
|
||||
"lock": "锁定设置", "sell": "出售",
|
||||
}
|
||||
data = receipt.data
|
||||
account = data["account"]
|
||||
lines = [f"{labels[receipt.action]}已完成"]
|
||||
if "created" in data:
|
||||
lines.append("首次开通" if data["created"] else "农场已存在")
|
||||
if "quantity" in data:
|
||||
lines.append(f"实际数量:{data['quantity']}")
|
||||
if "proceeds" in data:
|
||||
lines.append(f"本次收入:{data['proceeds']}")
|
||||
lines.append(f"农场币:{account['point']}")
|
||||
lines.append(f"点券:{account['vip_point']}")
|
||||
lines.append(f"累计经验:{account['experience']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def snapshot_text(snapshot: Snapshot) -> str:
|
||||
account = snapshot.account
|
||||
lines = [
|
||||
account.name, f"等级:{account.level}",
|
||||
f"农场币:{account.point}", f"点券:{account.vip_point}",
|
||||
f"累计经验:{account.experience}",
|
||||
]
|
||||
for item in snapshot.inventory:
|
||||
lock = "锁定" if item.locked else "未锁定"
|
||||
lines.append(
|
||||
f"{item.name}:种子{item.seeds},作物{item.crops},{lock}"
|
||||
)
|
||||
for plot in snapshot.plots:
|
||||
state = "空地" if plot.plant is None else plot.plant.name
|
||||
if plot.withered:
|
||||
state += "(枯萎)"
|
||||
lines.append(f"地块{plot.slot}:{state}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""单个 SQLite 事务内的仓储;不自行提交或开启事务。"""
|
||||
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from .domain import (
|
||||
Account, Asset, FarmError, InventoryItem, Plot, Plant, StorageError,
|
||||
integer,
|
||||
)
|
||||
|
||||
|
||||
class SQLiteRepository:
|
||||
def __init__(
|
||||
self, connection: sqlite3.Connection, identity: str,
|
||||
operation_id: str, action: str, now: int,
|
||||
):
|
||||
self._db = connection
|
||||
self._identity = identity
|
||||
self._operation_id = operation_id
|
||||
self._action = action
|
||||
self._now = now
|
||||
|
||||
def account(self) -> Account:
|
||||
row = self._db.execute(
|
||||
"SELECT name, point, vip_point, experience "
|
||||
"FROM accounts WHERE identity=?", (self._identity,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise FarmError("not_registered")
|
||||
return Account(**dict(row))
|
||||
|
||||
def register(self, name: str) -> bool:
|
||||
exists = self._db.execute(
|
||||
"SELECT 1 FROM accounts WHERE identity=?", (self._identity,),
|
||||
).fetchone()
|
||||
if exists:
|
||||
return False
|
||||
self._db.execute(
|
||||
"INSERT INTO accounts(identity, name) VALUES (?, ?)",
|
||||
(self._identity, name),
|
||||
)
|
||||
self._db.executemany(
|
||||
"INSERT INTO plots(identity, slot) VALUES (?, ?)",
|
||||
[(self._identity, slot) for slot in range(1, 4)],
|
||||
)
|
||||
self.asset(Asset.POINT, 500)
|
||||
return True
|
||||
|
||||
def asset(self, asset: Asset, delta: int) -> None:
|
||||
if not isinstance(asset, Asset):
|
||||
raise FarmError("invalid_asset")
|
||||
integer(delta, -(2**63 - 1))
|
||||
previous = getattr(self.account(), asset.value)
|
||||
updated = previous + delta
|
||||
if updated < 0:
|
||||
raise FarmError("insufficient_funds")
|
||||
integer(updated)
|
||||
if delta == 0:
|
||||
return
|
||||
column = asset.value
|
||||
cursor = self._db.execute(
|
||||
f"UPDATE accounts SET {column}=? "
|
||||
f"WHERE identity=? AND {column}=?",
|
||||
(updated, self._identity, previous),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise FarmError("state_conflict")
|
||||
self._db.execute(
|
||||
"INSERT INTO ledger("
|
||||
"identity, operation_id, asset, delta, reason, created_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._identity, self._operation_id, asset.value, delta,
|
||||
self._action, self._now,
|
||||
),
|
||||
)
|
||||
|
||||
def item(self, name: str) -> InventoryItem:
|
||||
row = self._db.execute(
|
||||
"SELECT name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? AND name=?", (self._identity, name),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return InventoryItem(name)
|
||||
return InventoryItem(
|
||||
row["name"], row["seeds"], row["crops"], bool(row["locked"]),
|
||||
)
|
||||
|
||||
def items(self) -> tuple[InventoryItem, ...]:
|
||||
rows = self._db.execute(
|
||||
"SELECT name, seeds, crops, locked FROM inventory "
|
||||
"WHERE identity=? ORDER BY name", (self._identity,),
|
||||
).fetchall()
|
||||
return tuple(
|
||||
InventoryItem(r["name"], r["seeds"], r["crops"], bool(r["locked"]))
|
||||
for r in rows
|
||||
)
|
||||
|
||||
def stock(self, name: str, seeds: int = 0, crops: int = 0) -> None:
|
||||
previous = self.item(name)
|
||||
new_seeds, new_crops = previous.seeds + seeds, previous.crops + crops
|
||||
if min(new_seeds, new_crops) < 0:
|
||||
raise FarmError("insufficient_stock")
|
||||
integer(new_seeds)
|
||||
integer(new_crops)
|
||||
self._db.execute(
|
||||
"INSERT INTO inventory(identity, name, seeds, crops) "
|
||||
"VALUES (?, ?, ?, ?) ON CONFLICT(identity, name) "
|
||||
"DO UPDATE SET seeds=excluded.seeds, crops=excluded.crops",
|
||||
(self._identity, name, new_seeds, new_crops),
|
||||
)
|
||||
|
||||
def lock(self, name: str, locked: bool) -> None:
|
||||
self.stock(name)
|
||||
self._db.execute(
|
||||
"UPDATE inventory SET locked=? WHERE identity=? AND name=?",
|
||||
(int(locked), self._identity, name),
|
||||
)
|
||||
|
||||
def plots(self) -> tuple[Plot, ...]:
|
||||
rows = self._db.execute(
|
||||
"SELECT * FROM plots WHERE identity=? ORDER BY slot",
|
||||
(self._identity,),
|
||||
).fetchall()
|
||||
try:
|
||||
return tuple(
|
||||
Plot(
|
||||
r["slot"],
|
||||
Plant(**json.loads(r["plant"])) if r["plant"] else None,
|
||||
r["mature_at"], r["harvest_count"],
|
||||
r["yield_bonus"], r["experience_bonus"],
|
||||
)
|
||||
for r in rows
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise StorageError("invalid_plot_snapshot") from exc
|
||||
|
||||
def save_plot(self, plot: Plot) -> None:
|
||||
data = (
|
||||
json.dumps(asdict(plot.plant), ensure_ascii=False)
|
||||
if plot.plant else None
|
||||
)
|
||||
cursor = self._db.execute(
|
||||
"UPDATE plots SET plant=?, mature_at=?, harvest_count=?, "
|
||||
"yield_bonus=?, experience_bonus=? WHERE identity=? AND slot=?",
|
||||
(
|
||||
data, plot.mature_at, plot.harvest_count, plot.yield_bonus,
|
||||
plot.experience_bonus, self._identity, plot.slot,
|
||||
),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise FarmError("unknown_plot")
|
||||
@@ -0,0 +1,253 @@
|
||||
"""显式初始化的 SQLite 存储;每个用例独占一条事务连接。"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import closing, contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
from .domain import (
|
||||
FarmError, Identity, Receipt, SchemaError, Snapshot, StorageError,
|
||||
)
|
||||
from .repository import SQLiteRepository
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
APPLICATION_ID = 0x4641524D
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
SCHEMA = (
|
||||
"""CREATE TABLE accounts(
|
||||
identity TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
point INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(point)='integer' AND point>=0),
|
||||
vip_point INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(vip_point)='integer' AND vip_point>=0),
|
||||
experience INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(experience)='integer' AND experience>=0)
|
||||
)""",
|
||||
"""CREATE TABLE operations(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
operation_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(identity, operation_id)
|
||||
)""",
|
||||
"""CREATE TABLE inventory(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
name TEXT NOT NULL,
|
||||
seeds INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(seeds)='integer' AND seeds>=0),
|
||||
crops INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(typeof(crops)='integer' AND crops>=0),
|
||||
locked INTEGER NOT NULL DEFAULT 0 CHECK(locked IN (0,1)),
|
||||
PRIMARY KEY(identity, name)
|
||||
)""",
|
||||
"""CREATE TABLE plots(
|
||||
identity TEXT NOT NULL REFERENCES accounts(identity),
|
||||
slot INTEGER NOT NULL CHECK(slot>0),
|
||||
plant TEXT,
|
||||
mature_at INTEGER,
|
||||
harvest_count INTEGER NOT NULL DEFAULT 0 CHECK(harvest_count>=0),
|
||||
yield_bonus INTEGER NOT NULL DEFAULT 0,
|
||||
experience_bonus INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY(identity, slot)
|
||||
)""",
|
||||
"""CREATE TABLE ledger(
|
||||
id INTEGER PRIMARY KEY,
|
||||
identity TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
asset TEXT NOT NULL
|
||||
CHECK(asset IN ('point','vip_point','experience')),
|
||||
delta INTEGER NOT NULL CHECK(typeof(delta)='integer' AND delta!=0),
|
||||
reason TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(identity, operation_id)
|
||||
REFERENCES operations(identity, operation_id)
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
)""",
|
||||
)
|
||||
|
||||
|
||||
class SQLiteStore:
|
||||
def __init__(self, path: str | Path, timeout: float = 10):
|
||||
if str(path) == ":memory:":
|
||||
raise ValueError("Use a dedicated file for transaction connections")
|
||||
self.path = Path(path).resolve()
|
||||
self._timeout = timeout
|
||||
self._ready = False
|
||||
self._pending = set()
|
||||
self._lifecycle = asyncio.Lock()
|
||||
|
||||
async def _offload(self, function):
|
||||
task = asyncio.create_task(asyncio.to_thread(function))
|
||||
self._pending.add(task)
|
||||
|
||||
def finished(completed):
|
||||
self._pending.discard(completed)
|
||||
if not completed.cancelled():
|
||||
completed.exception()
|
||||
|
||||
task.add_done_callback(finished)
|
||||
return await asyncio.shield(task)
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
try:
|
||||
with closing(sqlite3.connect(
|
||||
self.path, timeout=self._timeout, isolation_level=None,
|
||||
)) as connection:
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
yield connection
|
||||
except sqlite3.Error as exc:
|
||||
raise StorageError("sqlite_failure") from exc
|
||||
|
||||
@staticmethod
|
||||
def _validate(connection):
|
||||
app_id = connection.execute("PRAGMA application_id").fetchone()[0]
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
tables = {
|
||||
r[0] for r in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
expected = {"accounts", "operations", "inventory", "plots", "ledger"}
|
||||
if (
|
||||
app_id != APPLICATION_ID or version != SCHEMA_VERSION
|
||||
or tables != expected
|
||||
):
|
||||
raise SchemaError("unsupported_database_schema")
|
||||
|
||||
async def initialize(self) -> None:
|
||||
async with self._lifecycle:
|
||||
await self._offload(self._initialize)
|
||||
self._ready = True
|
||||
|
||||
def _initialize(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
app = connection.execute("PRAGMA application_id").fetchone()[0]
|
||||
version = connection.execute(
|
||||
"PRAGMA user_version"
|
||||
).fetchone()[0]
|
||||
tables = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
if app == 0 and version == 0 and not tables:
|
||||
for statement in SCHEMA:
|
||||
connection.execute(statement)
|
||||
connection.execute(
|
||||
f"PRAGMA application_id={APPLICATION_ID}"
|
||||
)
|
||||
connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||
self._validate(connection)
|
||||
connection.commit()
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
async def close(self) -> None:
|
||||
async with self._lifecycle:
|
||||
self._ready = False
|
||||
pending = tuple(self._pending)
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
def _ensure_ready(self):
|
||||
if not self._ready:
|
||||
raise StorageError("store_not_initialized_or_closed")
|
||||
|
||||
async def transact(
|
||||
self, identity: Identity, operation_id: str, action: str,
|
||||
parameters: dict, now: int, operation,
|
||||
) -> Receipt:
|
||||
self._ensure_ready()
|
||||
request = json.dumps(
|
||||
{"action": action, "parameters": parameters},
|
||||
ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
)
|
||||
fingerprint = hashlib.sha256(request.encode("utf-8")).hexdigest()
|
||||
|
||||
def execute():
|
||||
with self._connection() as connection:
|
||||
self._validate(connection)
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
existing = connection.execute(
|
||||
"SELECT fingerprint, result FROM operations "
|
||||
"WHERE identity=? AND operation_id=?",
|
||||
(identity.key, operation_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["fingerprint"] != fingerprint:
|
||||
raise FarmError("operation_conflict")
|
||||
result = json.loads(existing["result"])
|
||||
else:
|
||||
repository = SQLiteRepository(
|
||||
connection, identity.key, operation_id, action, now,
|
||||
)
|
||||
result = operation(repository)
|
||||
connection.execute(
|
||||
"INSERT INTO operations VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
identity.key, operation_id, action, fingerprint,
|
||||
json.dumps(result, ensure_ascii=False), now,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
except BaseException as exc:
|
||||
connection.rollback()
|
||||
LOGGER.log(
|
||||
logging.INFO if isinstance(exc, FarmError)
|
||||
else logging.WARNING,
|
||||
"farm rollback action=%s operation=%s error=%s",
|
||||
action, operation_id, type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
LOGGER.info(
|
||||
"farm committed action=%s operation=%s", action, operation_id,
|
||||
)
|
||||
return Receipt(operation_id, action, result)
|
||||
|
||||
return await self._offload(execute)
|
||||
|
||||
async def snapshot(self, identity: Identity) -> Snapshot:
|
||||
self._ensure_ready()
|
||||
|
||||
def query():
|
||||
with self._connection() as connection:
|
||||
self._validate(connection)
|
||||
connection.execute("BEGIN")
|
||||
repository = SQLiteRepository(
|
||||
connection, identity.key, "", "query", 0,
|
||||
)
|
||||
result = Snapshot(
|
||||
repository.account(), repository.items(),
|
||||
repository.plots(),
|
||||
)
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
return await self._offload(query)
|
||||
|
||||
async def ledger(self, identity: Identity) -> tuple[dict, ...]:
|
||||
self._ensure_ready()
|
||||
|
||||
def query():
|
||||
with self._connection() as connection:
|
||||
self._validate(connection)
|
||||
rows = connection.execute(
|
||||
"SELECT operation_id, asset, delta, reason, created_at "
|
||||
"FROM ledger WHERE identity=? ORDER BY id",
|
||||
(identity.key,),
|
||||
).fetchall()
|
||||
return tuple(dict(row) for row in rows)
|
||||
|
||||
return await self._offload(query)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""可选共享图片层;核心模块无需导入本包。"""
|
||||
|
||||
from .models import RenderOptions, RenderResult
|
||||
from .renderer import FarmRenderer
|
||||
from .service import RenderService
|
||||
|
||||
__all__ = ["FarmRenderer", "RenderOptions", "RenderResult", "RenderService"]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""包内素材与有界 LRU;不下载、不解压到磁盘、不暴露共享图片。"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from importlib import resources
|
||||
from io import BytesIO
|
||||
import json
|
||||
from threading import RLock
|
||||
from zipfile import ZipFile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class ByteLRU:
|
||||
def __init__(self, limit):
|
||||
if type(limit) is not int or limit < 0:
|
||||
raise ValueError("Invalid cache budget")
|
||||
self.limit = limit
|
||||
self.used_bytes = 0
|
||||
self._items = OrderedDict()
|
||||
self._lock = RLock()
|
||||
|
||||
def get(self, key):
|
||||
with self._lock:
|
||||
item = self._items.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
self._items.move_to_end(key)
|
||||
return item[0]
|
||||
|
||||
def put(self, key, value, size):
|
||||
with self._lock:
|
||||
old = self._items.pop(key, None)
|
||||
if old is not None:
|
||||
self.used_bytes -= old[1]
|
||||
if size > self.limit or self.limit == 0:
|
||||
return
|
||||
while self._items and self.used_bytes + size > self.limit:
|
||||
_, (_, removed) = self._items.popitem(last=False)
|
||||
self.used_bytes -= removed
|
||||
self._items[key] = (value, size)
|
||||
self.used_bytes += size
|
||||
|
||||
|
||||
class Assets:
|
||||
def __init__(self, cache_bytes=32 * 1024 * 1024):
|
||||
data = resources.files(__package__).joinpath("data")
|
||||
self.metadata = json.loads(data.joinpath("metadata.json").read_bytes())
|
||||
self.cache = ByteLRU(cache_bytes)
|
||||
self._bundle = data.joinpath("assets.zip")
|
||||
|
||||
def image(self, name, size=None):
|
||||
if name not in self.metadata["images"]:
|
||||
raise KeyError("Unknown packaged image")
|
||||
size = tuple(size or self.metadata["images"][name]["size"])
|
||||
key = (name, size)
|
||||
cached = self.cache.get(key)
|
||||
if cached is None:
|
||||
with self._bundle.open("rb") as stream, ZipFile(stream) as bundle:
|
||||
content = bundle.read(name)
|
||||
with Image.open(BytesIO(content)) as original:
|
||||
original.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
cached = original.convert("RGBA")
|
||||
if cached.size != size:
|
||||
cached = cached.resize(size, Image.Resampling.LANCZOS)
|
||||
self.cache.put(key, cached, size[0] * size[1] * 4)
|
||||
return cached.copy()
|
||||
@@ -0,0 +1,77 @@
|
||||
"""同步 Pillow 图片原语;调用方负责整次绘制的异步调度。"""
|
||||
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def font(path, size):
|
||||
return ImageFont.truetype(str(path), size=size)
|
||||
|
||||
|
||||
class Canvas:
|
||||
def __init__(self, image):
|
||||
self.image = image.convert("RGBA")
|
||||
|
||||
@classmethod
|
||||
def new(cls, size, color=(0, 0, 0, 0)):
|
||||
return cls(Image.new("RGBA", size, color))
|
||||
|
||||
def copy(self):
|
||||
return Canvas(self.image.copy())
|
||||
|
||||
def resize(self, size):
|
||||
return Canvas(self.image.resize(size, Image.Resampling.LANCZOS))
|
||||
|
||||
def crop(self, box):
|
||||
return Canvas(self.image.crop(box))
|
||||
|
||||
def paste(self, image, position):
|
||||
source = image.image if isinstance(image, Canvas) else image
|
||||
self.image.alpha_composite(source.convert("RGBA"), position)
|
||||
|
||||
@staticmethod
|
||||
def measure_text(text, font_path, size):
|
||||
return font(font_path, size).getlength(text)
|
||||
|
||||
@classmethod
|
||||
def fit_text(cls, text, font_path, size, width):
|
||||
text = " ".join(str(text).split())[:256]
|
||||
if cls.measure_text(text, font_path, size) <= width:
|
||||
return text
|
||||
suffix = "…"
|
||||
while text and cls.measure_text(
|
||||
text + suffix, font_path, size,
|
||||
) > width:
|
||||
text = text[:-1]
|
||||
return text + suffix if cls.measure_text(
|
||||
suffix, font_path, size,
|
||||
) <= width else ""
|
||||
|
||||
def text(self, position, text, font_path, size, color):
|
||||
ImageDraw.Draw(self.image).text(
|
||||
position, text, font=font(font_path, size), fill=color,
|
||||
)
|
||||
|
||||
def rectangle(self, box, color, radius=0):
|
||||
ImageDraw.Draw(self.image).rounded_rectangle(
|
||||
box, radius=radius, fill=color,
|
||||
)
|
||||
|
||||
def encode(self, codec, quality=75, method=2):
|
||||
output = BytesIO()
|
||||
if codec == "JPEG":
|
||||
flattened = Image.new("RGB", self.image.size, "white")
|
||||
flattened.paste(self.image, mask=self.image.getchannel("A"))
|
||||
flattened.save(output, "JPEG", quality=quality)
|
||||
elif codec == "WEBP":
|
||||
self.image.save(
|
||||
output, "WEBP", quality=quality, method=method,
|
||||
)
|
||||
elif codec == "PNG":
|
||||
self.image.save(output, "PNG", compress_level=3)
|
||||
else:
|
||||
raise ValueError("Unsupported output codec")
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,8 @@
|
||||
# 绘图素材来源
|
||||
|
||||
assets.zip 原样复制自 Shu-Ying/zhenxun_plugin_farm 的本地保留基线,
|
||||
对应源提交与完整校验见主仓库 docs/legacy-manifest.json。
|
||||
仅选取农场季节背景、草地/普通地、作物及两个状态图标;metadata.json
|
||||
来自旧布局和作物配置。沿用仓库 GPLv3 许可与原始素材归属,迁移不
|
||||
增加第三方素材权利。后续对外发布前仍须核实历史素材来源及分发权限。
|
||||
不包含字体;部署方配置可合法使用的中文字体路径。
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
"""从一次快照与统一时间构造可缓存场景;不访问存储。"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from farm_core.domain import Identity, Snapshot, integer
|
||||
|
||||
from .models import PlotView, Scene
|
||||
|
||||
|
||||
def scene_from_snapshot(identity, snapshot, now, metadata):
|
||||
if not isinstance(identity, Identity) or not isinstance(snapshot, Snapshot):
|
||||
raise ValueError("Identity and snapshot are required")
|
||||
integer(now)
|
||||
slots = [p.slot for p in snapshot.plots]
|
||||
if len(set(slots)) != len(slots) or any(
|
||||
type(slot) is not int or not 1 <= slot <= 30 for slot in slots
|
||||
):
|
||||
raise ValueError("Invalid plot slots")
|
||||
views = []
|
||||
for plot in sorted(snapshot.plots, key=lambda p: p.slot):
|
||||
if plot.plant is None:
|
||||
views.append(PlotView(plot.slot, "empty"))
|
||||
continue
|
||||
if plot.withered:
|
||||
views.append(PlotView(
|
||||
plot.slot, "withered", "plant/basic/9.png", size=(150, 212),
|
||||
))
|
||||
continue
|
||||
integer(plot.mature_at)
|
||||
name = plot.plant.name
|
||||
info = metadata["plants"].get(name)
|
||||
if info is None:
|
||||
views.append(PlotView(plot.slot, "growing", "missing"))
|
||||
continue
|
||||
phases = tuple(dict.fromkeys(int(p) for p in info["phase"].split(",")))
|
||||
duration = (plot.plant.growth_seconds if plot.harvest_count == 0
|
||||
else plot.plant.regrowth_seconds)
|
||||
elapsed = max(0, now - (plot.mature_at - duration))
|
||||
ripe = now >= plot.mature_at
|
||||
stage = len(phases) if ripe else sum(
|
||||
elapsed * phases[-1] >= threshold * duration
|
||||
for threshold in phases
|
||||
)
|
||||
sprite = f"plant/{name}/{stage}.png"
|
||||
size = None
|
||||
if stage == 0:
|
||||
if info["general"]:
|
||||
sprite = "plant/basic/0.png"
|
||||
size = (max(1, 35 + info["offsetW"]),
|
||||
max(1, 58 + info["offsetH"]))
|
||||
views.append(PlotView(
|
||||
plot.slot, "ripe" if ripe else "growing", sprite, stage,
|
||||
(info["offsetX"], info["offsetY"]), size,
|
||||
))
|
||||
return Scene(
|
||||
hashlib.sha256(identity.key.encode()).hexdigest(),
|
||||
snapshot.account, tuple(views),
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""框架无关、不可变的渲染输入与输出。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import features
|
||||
|
||||
from farm_core.domain import Account
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderOptions:
|
||||
width: int = 1024 # 目标宽度;从此尺寸开始合成。
|
||||
format: str = "AUTO" # AUTO/WEBP/JPEG/PNG。
|
||||
quality: int = 75 # 有损编码质量,不代表分辨率。
|
||||
method: int = 2 # WebP 编码力度,0最快,6最慢。
|
||||
theme: str = "default" # 显式主题,不读取隐含季节或全局配置。
|
||||
|
||||
def __post_init__(self):
|
||||
for value, low, high in (
|
||||
(self.width, 256, 3072), (self.quality, 1, 95),
|
||||
(self.method, 0, 6),
|
||||
):
|
||||
if type(value) is not int or not low <= value <= high:
|
||||
raise ValueError("Invalid render option")
|
||||
if self.format not in ("AUTO", "WEBP", "JPEG", "PNG"):
|
||||
raise ValueError("Unsupported output format")
|
||||
if self.theme not in ("default", "spring", "summer", "autumn",
|
||||
"winter"):
|
||||
raise ValueError("Unsupported theme")
|
||||
|
||||
@classmethod
|
||||
def high(cls, **kwargs):
|
||||
return cls(width=1536, quality=85, **kwargs)
|
||||
|
||||
def codec(self, supported=("JPEG",)):
|
||||
if self.format == "AUTO":
|
||||
if "WEBP" in supported and features.check("webp"):
|
||||
return "WEBP"
|
||||
if "JPEG" in supported:
|
||||
return "JPEG"
|
||||
if "PNG" in supported:
|
||||
return "PNG"
|
||||
raise ValueError("No supported image codec")
|
||||
if self.format == "WEBP" and not features.check("webp"):
|
||||
raise ValueError("Pillow WebP support is unavailable")
|
||||
return self.format
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlotView:
|
||||
slot: int # 旧布局的1到30地块编号。
|
||||
state: str # empty/growing/ripe/withered。
|
||||
sprite: str = "" # 包内受控素材名,不来自用户路径。
|
||||
stage: int = 0 # 本季展示阶段。
|
||||
offset: tuple[int, int] = (0, 0) # 旧素材中心偏移。
|
||||
size: tuple[int, int] | None = None # 萌芽/枯萎原始尺寸覆盖。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Scene:
|
||||
scope: str # 完整身份的摘要,只用于缓存隔离。
|
||||
account: Account # 一致快照中的可见账户。
|
||||
plots: tuple[PlotView, ...] # 已解锁地块及统一时刻的阶段。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderResult:
|
||||
content: bytes # 已编码图片,不携带用户对象。
|
||||
mime: str # 与实际编码一致的媒体类型。
|
||||
width: int # 实际宽度。
|
||||
height: int # 实际高度。
|
||||
compose_ms: float # 本次合成毫秒。
|
||||
encode_ms: float # 本次编码毫秒。
|
||||
cache_hit: bool = False # 成图缓存命中。
|
||||
@@ -0,0 +1,204 @@
|
||||
"""纯快照农场合成:目标尺寸、独立素材、可见状态成图缓存。"""
|
||||
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
|
||||
from .assets import Assets, ByteLRU
|
||||
from .canvas import Canvas, font
|
||||
from .layout import scene_from_snapshot
|
||||
from .models import RenderOptions, RenderResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||
MAX_AVATAR_PIXELS = 4_000_000
|
||||
|
||||
|
||||
class FarmRenderer:
|
||||
def __init__(
|
||||
self, font_path, asset_cache_bytes=32 * 1024 * 1024,
|
||||
result_cache_bytes=8 * 1024 * 1024,
|
||||
):
|
||||
self.font_path = Path(font_path).resolve()
|
||||
if not self.font_path.is_file():
|
||||
raise ValueError("Configure an existing Chinese font path")
|
||||
font(self.font_path, 16)
|
||||
self.assets = Assets(asset_cache_bytes)
|
||||
self.results = ByteLRU(result_cache_bytes)
|
||||
|
||||
def scene(self, identity, snapshot, now):
|
||||
return scene_from_snapshot(
|
||||
identity, snapshot, now, self.assets.metadata,
|
||||
)
|
||||
|
||||
def request_key(
|
||||
self, identity, snapshot, now, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
options = options or RenderOptions()
|
||||
if avatar is not None and not isinstance(avatar, bytes):
|
||||
raise ValueError("Avatar must be bytes or None")
|
||||
digest = hashlib.sha256(avatar).hexdigest() if (
|
||||
avatar and len(avatar) <= MAX_AVATAR_BYTES
|
||||
) else "default"
|
||||
scene = self.scene(identity, snapshot, now)
|
||||
return scene, options, options.codec(supported), digest
|
||||
|
||||
def render(
|
||||
self, identity, snapshot, now, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
key = self.request_key(
|
||||
identity, snapshot, now, options, avatar, supported,
|
||||
)
|
||||
cached = self.results.get(key)
|
||||
if cached is not None:
|
||||
logger.info("farm render cache=hit bytes=%s", len(cached.content))
|
||||
return replace(cached, cache_hit=True, compose_ms=0, encode_ms=0)
|
||||
scene, options, codec, _ = key
|
||||
started = perf_counter()
|
||||
canvas = self._base(scene, options)
|
||||
self._plants(canvas, scene, options)
|
||||
self._panel(canvas, scene, options, avatar)
|
||||
composed = perf_counter()
|
||||
content = canvas.encode(codec, options.quality, options.method)
|
||||
encoded = perf_counter()
|
||||
result = RenderResult(
|
||||
content, {"JPEG":"image/jpeg", "PNG":"image/png",
|
||||
"WEBP":"image/webp"}[codec],
|
||||
*canvas.image.size, (composed - started) * 1000,
|
||||
(encoded - composed) * 1000,
|
||||
)
|
||||
self.results.put(key, result, len(content) + 4096)
|
||||
logger.info(
|
||||
"farm render cache=miss compose_ms=%.2f encode_ms=%.2f "
|
||||
"width=%s height=%s bytes=%s codec=%s",
|
||||
result.compose_ms, result.encode_ms, result.width,
|
||||
result.height, len(content), codec,
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _scale(value, options):
|
||||
return round(value * options.width / 3072)
|
||||
|
||||
def _base(self, scene, options):
|
||||
slots = frozenset(p.slot for p in scene.plots)
|
||||
key = ("base", options.theme, options.width, slots)
|
||||
cached = self.assets.cache.get(key)
|
||||
if cached is not None:
|
||||
return Canvas(cached.copy())
|
||||
s = lambda n: self._scale(n, options)
|
||||
size = (options.width, s(1872))
|
||||
background = self.assets.image(
|
||||
"background/decorateIcon/"
|
||||
f"decorate_{options.theme}_1.jpg", size,
|
||||
)
|
||||
canvas = Canvas(background)
|
||||
soil_size = tuple(max(1, s(n)) for n in (
|
||||
self.assets.metadata["soil_size"]
|
||||
))
|
||||
tiles = {
|
||||
True: self.assets.image("soil/普通土地.png", soil_size),
|
||||
False: self.assets.image("soil/草土地.png", soil_size),
|
||||
}
|
||||
expansion = False
|
||||
for slot, pos in enumerate(self.assets.metadata["positions"], 1):
|
||||
xy = (s(pos["x"]), s(pos["y"]))
|
||||
canvas.paste(tiles[slot in slots], xy)
|
||||
if slot not in slots and not expansion:
|
||||
icon = self.assets.image(
|
||||
"background/expansion.png", (max(1, s(90)),) * 2,
|
||||
)
|
||||
canvas.paste(icon, (xy[0] + s(70), xy[1] + s(10)))
|
||||
expansion = True
|
||||
self.assets.cache.put(key, canvas.image, size[0] * size[1] * 4)
|
||||
return canvas.copy()
|
||||
|
||||
def _plants(self, canvas, scene, options):
|
||||
s = lambda n: self._scale(n, options)
|
||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||
first_ripe = True
|
||||
for plot in scene.plots:
|
||||
if not plot.sprite:
|
||||
continue
|
||||
pos = self.assets.metadata["positions"][plot.slot - 1]
|
||||
metadata = self.assets.metadata["images"].get(plot.sprite)
|
||||
if metadata is None:
|
||||
logger.warning("farm render missing packaged crop sprite")
|
||||
plant = Image.new("RGBA", (max(1, s(60)),) * 2, "#597b45")
|
||||
ImageDraw.Draw(plant).text(
|
||||
(s(10), 0), "?", fill="white",
|
||||
font=font(self.font_path, max(10, s(48))),
|
||||
)
|
||||
else:
|
||||
size = plot.size or metadata["size"]
|
||||
plant = self.assets.image(
|
||||
plot.sprite, tuple(max(1, s(n)) for n in size),
|
||||
)
|
||||
offset_x, offset_y = plot.offset
|
||||
xy = (
|
||||
s(pos["x"] + soil_w / 2 + offset_x) - plant.width // 2,
|
||||
s(pos["y"] + soil_h / 2 + offset_y) - plant.height // 2,
|
||||
)
|
||||
canvas.paste(plant, xy)
|
||||
if plot.state == "ripe" and first_ripe:
|
||||
icon = self.assets.image(
|
||||
"background/ripe.png", (max(1, s(100)),) * 2,
|
||||
)
|
||||
canvas.paste(icon, (s(pos["x"] + 70), s(pos["y"] - 80)))
|
||||
first_ripe = False
|
||||
|
||||
def _avatar(self, content, size):
|
||||
if content and len(content) <= MAX_AVATAR_BYTES:
|
||||
try:
|
||||
with Image.open(BytesIO(content)) as image:
|
||||
if image.width * image.height > MAX_AVATAR_PIXELS:
|
||||
raise ValueError("Avatar dimensions exceed limit")
|
||||
image.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
avatar = Image.new("RGBA", size, "#cce3c1")
|
||||
avatar.alpha_composite(image.convert("RGBA"), (
|
||||
(size[0] - image.width) // 2,
|
||||
(size[1] - image.height) // 2,
|
||||
))
|
||||
return avatar
|
||||
except (UnidentifiedImageError, OSError, ValueError,
|
||||
Image.DecompressionBombError):
|
||||
logger.warning("farm render avatar rejected; using default")
|
||||
avatar = Image.new("RGBA", size, "#dce8cf")
|
||||
draw = ImageDraw.Draw(avatar)
|
||||
w, h = size
|
||||
draw.ellipse((w*.3, h*.12, w*.7, h*.52), fill="#6f8a54")
|
||||
draw.ellipse((w*.15, h*.55, w*.85, h*1.2), fill="#6f8a54")
|
||||
return avatar
|
||||
|
||||
def _panel(self, canvas, scene, options, avatar):
|
||||
s = lambda n: self._scale(n, options)
|
||||
canvas.rectangle(
|
||||
tuple(s(n) for n in (90, 75, 1160, 425)),
|
||||
"#fff9e8", s(24),
|
||||
)
|
||||
canvas.paste(self._avatar(avatar, (s(170), s(170))), (s(125), s(125)))
|
||||
size = max(12, s(55))
|
||||
name = canvas.fit_text(
|
||||
scene.account.name, self.font_path, size, s(790),
|
||||
)
|
||||
canvas.text((s(330), s(105)), name, self.font_path, size, "#493525")
|
||||
account = scene.account
|
||||
lines = (
|
||||
f"等级 {account.level} 经验 {account.experience}",
|
||||
f"农场币 {account.point} 点券 {account.vip_point}",
|
||||
f"已解锁 {len(scene.plots)}/30 块地",
|
||||
)
|
||||
for index, line in enumerate(lines):
|
||||
size = max(10, s(43))
|
||||
text = canvas.fit_text(line, self.font_path, size, s(790))
|
||||
canvas.text(
|
||||
(s(330), s(195 + index * 65)), text,
|
||||
self.font_path, size, "#674d34",
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""异步入口:一次快照、有限任务、共享绘制与关闭排空。"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from time import perf_counter
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RenderService:
|
||||
def __init__(self, renderer, concurrency=1, max_pending=8):
|
||||
if (type(concurrency) is not int or type(max_pending) is not int
|
||||
or concurrency < 1 or max_pending < concurrency):
|
||||
raise ValueError("Invalid render capacity")
|
||||
self.renderer = renderer
|
||||
self._semaphore = asyncio.Semaphore(concurrency)
|
||||
self._max_pending = max_pending
|
||||
self._pending = {}
|
||||
self._closed = False
|
||||
|
||||
async def my_farm(
|
||||
self, source, identity, *, now=None, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
if self._closed:
|
||||
raise RuntimeError("Render service closed")
|
||||
started = perf_counter()
|
||||
snapshot = await source.snapshot(identity)
|
||||
logger.info("farm render query_ms=%.2f", (perf_counter()-started)*1000)
|
||||
return await self.render(
|
||||
identity, snapshot, int(time.time()) if now is None else now,
|
||||
options, avatar, supported,
|
||||
)
|
||||
|
||||
async def render(
|
||||
self, identity, snapshot, now, options=None, avatar=None,
|
||||
supported=("JPEG",),
|
||||
):
|
||||
if self._closed:
|
||||
raise RuntimeError("Render service closed")
|
||||
key = self.renderer.request_key(
|
||||
identity, snapshot, now, options, avatar, supported,
|
||||
)
|
||||
task = self._pending.get(key)
|
||||
if task is None:
|
||||
if len(self._pending) >= self._max_pending:
|
||||
raise RuntimeError("Render queue full")
|
||||
task = asyncio.create_task(self._run(
|
||||
identity, snapshot, now, options, avatar, supported,
|
||||
))
|
||||
self._pending[key] = task
|
||||
|
||||
def finished(done):
|
||||
self._pending.pop(key, None)
|
||||
if not done.cancelled():
|
||||
done.exception()
|
||||
|
||||
task.add_done_callback(finished)
|
||||
return await asyncio.shield(task)
|
||||
|
||||
async def _run(self, *args):
|
||||
started = perf_counter()
|
||||
async with self._semaphore:
|
||||
logger.info(
|
||||
"farm render queue_ms=%.2f", (perf_counter()-started)*1000,
|
||||
)
|
||||
worker = asyncio.create_task(asyncio.to_thread(
|
||||
self.renderer.render, *args,
|
||||
))
|
||||
try:
|
||||
return await asyncio.shield(worker)
|
||||
except asyncio.CancelledError:
|
||||
await worker
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
self._closed = True
|
||||
pending = tuple(self._pending.values())
|
||||
if pending:
|
||||
await asyncio.shield(asyncio.gather(
|
||||
*pending, return_exceptions=True,
|
||||
))
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
await self.close()
|
||||
Reference in New Issue
Block a user