add # 新增项目整体大纲

This commit is contained in:
2026-10-15 17:21:05 +08:00
parent ff03600ae9
commit ff4df9ad51
52 changed files with 15104 additions and 47 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
# 共享核心预留位置
# 共享基础核心
当前只有职责说明,未实现新核心。完整目标见 docs/02-architecture.md。
实施时创建 domain、application、ports、infrastructure、presentation。
禁止导入 nonebot、astrbot、zhenxun;内部模块采用相对导入。
先实现注册、库存、资产和一个完整交易,不直接复制旧全局管理器。
本轮已实现框架无关的作物目录、注册、交易及种植闭环。
模块责任与限制见 docs/09-foundation.md,运行方式见根 README。
运行无第三方依赖,导入不创建数据库、文件或后台线程。
本包不是机器人插件入口;integrations 下接入尚未实现。
+11
View File
@@ -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",
]
+55
View File
@@ -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()
+243
View File
@@ -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,
)
+52
View File
@@ -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
+213
View File
@@ -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] # 实际成交数值与提交后的资产。
+32
View File
@@ -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: ...
+44
View File
@@ -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)
+153
View File
@@ -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")
+253
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
"""可选共享图片层;核心模块无需导入本包。"""
from .models import RenderOptions, RenderResult
from .renderer import FarmRenderer
from .service import RenderService
__all__ = ["FarmRenderer", "RenderOptions", "RenderResult", "RenderService"]
+66
View File
@@ -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()
+77
View File
@@ -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()
+8
View File
@@ -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
+58
View File
@@ -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),
)
+74
View File
@@ -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 # 成图缓存命中。
+204
View File
@@ -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",
)
+89
View File
@@ -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()