From d0c1741940ba92c5883ef5a0060924554e46d861 Mon Sep 17 00:00:00 2001 From: Art_Sakura <1754798088@qq.com> Date: Fri, 23 Oct 2026 12:11:08 +0800 Subject: [PATCH] =?UTF-8?q?fixed=20#=20=E6=96=B0=E5=A2=9E=E5=BC=80?= =?UTF-8?q?=E5=9E=A6=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/12-nonebot.md | 5 +- docs/land-rules.md | 18 ++- integrations/astrbot/README.md | 5 +- integrations/zhenxun/README.md | 11 ++ integrations/zhenxun/runtime.py | 21 +++- src/farm_core/application.py | 65 ++++++++++ src/farm_core/land.py | 60 +++++++++ src/farm_core/ports.py | 2 + src/farm_core/presentation.py | 6 +- src/farm_core/repository.py | 32 +++++ tests/test_land.py | 209 ++++++++++++++++++++++++++++++++ tests/test_zhenxun_runtime.py | 30 +++++ 12 files changed, 455 insertions(+), 9 deletions(-) create mode 100644 src/farm_core/land.py create mode 100644 tests/test_land.py diff --git a/docs/12-nonebot.md b/docs/12-nonebot.md index 5f52302..8484be9 100644 --- a/docs/12-nonebot.md +++ b/docs/12-nonebot.md @@ -41,7 +41,10 @@ FARM_DEPLOYMENT=nonebot 我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、 作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。 发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。 -范围与当前真寻生成版一致;旧版开垦、签到、改名、土地升级等命令 +开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。 +初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果; +来源不明、编号异常或补后超出30位置则保留原状并明确提示。 +范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令 尚未在共享核心中实现,此版本不提供这些命令。 ## 生成与发布 diff --git a/docs/land-rules.md b/docs/land-rules.md index 616f96a..19446a7 100644 --- a/docs/land-rules.md +++ b/docs/land-rules.md @@ -6,17 +6,25 @@ - 开垦保留图片序号 7~24 的付费条件;红、黑、金土地各保留 1~24。 - 图片金币单位为万,配置中的 `coin_cost` 为乘 10000 后的整数。 - 前 6 块由 `initial_plot_count: 6` 表示,不重复保存空条件记录。 - 新用户默认 6 块空地是用户另行明确指定的规则。已有账号不自动补地。 -- `ordinal` 是图片表内的条件序号,不等同于实际地块 `slot`。 - 后续升级服务必须明确这两者的对应方式,不能混用。 + 新用户默认6块;可靠识别为初始3块的旧用户一次性补3块普通空地。 +- 开垦时,`expansion.rows.ordinal` 对应补偿完成后的下一实际地块编号。 + 土地升级表仍只是规则数据,本次未接入土地升级。 - 表外序号,以及材料、星钻替代支付、冷却和额外解锁条件没有在图中 给出。不能默认免费、默认通过,或套用最后一行。24 不是土地总上限。 -- 本次提供规则数据;开垦和土地升级指令尚未接入这份配置。 +- 已提供“开垦”(每次1块)和“开垦 条件”(查询等级及费用)。 + 等级检查、扣币及新增空地在同一事务内完成,重放同消息不重复扣款。 + 目前配置覆盖7~24号,25号起明确提示规则未配置,不扣币。 ## 新用户注册 注册事务创建 1~6 号空地,初始农场币仍为 500。重复注册不额外发地。 -不修改、补齐或重编号已有数据库中的地块记录。 +不重编号已有地块。旧库迁入用户及首次成功注册回执未记录plot_count +或记录为3的用户,在首次业务访问时一次性追加3块普通空地,保留已付费 +开垦成果:3→6、5→8、8→11。首次注册记录为6的用户不重复赠地。 +补偿与版本标识保存在operations中,重试、并发及重启不重复发放。 +来源不明、地块编号不连续/数量异常或补后超过当前30位置时明确报错、 +保持原状,不截断、不折现,也不标记已补偿;管理员修复后可重试。 +已迁移及后续迁入用户都通过共享服务处理,不直接改动旧farm.db。 ## 绘图编号顺序 diff --git a/integrations/astrbot/README.md b/integrations/astrbot/README.md index b3abc44..f321a3a 100644 --- a/integrations/astrbot/README.md +++ b/integrations/astrbot/README.md @@ -27,7 +27,10 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印 `/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。 仅`/我的农场 高清`支持高清;其他图片统一遵循质量配置。 仓库保留分页和文字;偷菜通过`/偷菜 @目标`,不接受手输QQ号。 -不提供共享核心尚未实现的签到、开垦、改名、土地升级及外部货币兑换。 +`/开垦 条件`查询下一块地的等级及费用,`/开垦`每次增加1块普通空地。 +可靠识别为初始3块的旧用户一次性补3块,保留已开垦成果;来源或容量 +异常会明确提示管理员处理。没有配置的地块不放行、不扣款。 +不提供共享核心尚未实现的签到、改名、土地升级及外部货币兑换。 存档按AstrBot部署、平台实例、机器人及用户隔离,不自动合并NoneBot或 真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。 diff --git a/integrations/zhenxun/README.md b/integrations/zhenxun/README.md index a5d4d9d..6f89c25 100644 --- a/integrations/zhenxun/README.md +++ b/integrations/zhenxun/README.md @@ -8,6 +8,17 @@ 使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。 本轮没有拉取、推送或操作真实机器人。 +## 开垦与旧用户土地 + +发送“开垦 条件”查看下一块地的等级和农场币费用,“开垦”每次新增 +1块普通空地。目前配置覆盖7~24号地,表外拒绝开垦且不扣币。 +扣款、新增地块及流水在同一事务完成,重复消息不重复扣款。 + +有可靠旧库迁移记录,或首次成功注册记录证明初始3块的账号,首次 +业务访问时一次性补3块,保留付费成果、作物及土地加成。新版初始6块 +账号不额外发地。来源不明、编号/数量异常或补后超出当前30位置的账号 +保留原状并提示管理员处理,不自动截断或折现。 + ## 图片质量 真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。 diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index f719c2c..5a2f255 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -31,11 +31,13 @@ COMMANDS = { "作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell", "农场除虫":"clear_pests", "除虫":"clear_pests", "地块编号":"plot_ids", "偷菜":"steal", + "开垦":"reclaim", } HELP = ( "开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n" "地块编号|查看当前土地编号及开垦位置\n" "偷菜 @目标|每日最多5次成功偷取\n" + "开垦|每次增加1块普通空地;开垦 条件|查看等级和费用\n" "我的种子/我的作物 [页码] [文字],默认图片。\n" "全部图片使用农场图片质量配置;仅我的农场支持高清。\n" "种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n" @@ -51,6 +53,13 @@ HELP = ( "旧版农场数据在首次使用时迁移,绑定当前机器人和平台。" ) ERRORS = { + 'land_origin_unknown': '农场初始土地来源无法确认,未补地或扣币,' + '请联系管理员核查注册或迁移记录。', + 'land_compensation_capacity': '旧用户补3块后将超出当前30个绘图位置,' + '已保留原状,请联系管理员单独处理补偿。', + 'land_layout_invalid': '地块编号或数量异常,未补地或扣币,请联系管理员核查。', + 'land_rules_invalid': '开垦配置异常,未扣币,请联系管理员检查。', + 'land_rule_unavailable': '下一块地的开垦规则尚未配置,未扣币。', 'cannot_steal_self': '不能偷自己的农场。', 'different_farm_scope': '只能偷取当前机器人、平台内的农场。', 'target_not_registered': '目标尚未开通当前农场。', @@ -204,6 +213,16 @@ class Runtime: ) if action == "help": return HELP + if action == 'reclaim' and args == ['条件']: + conditions = await self.service.reclaim_conditions(who) + return ( + f"下一块:{conditions['slot']}号普通空地\n" + f"所需等级:{conditions['required_level']}" + f"(当前{conditions['level']})\n" + f"所需农场币:{conditions['coin_cost']}" + f"(当前{conditions['point']})\n" + '满足条件后发送:开垦' + ) image_actions = ('farm', 'plot_ids', 'details', 'shop', 'seeds', 'crops') if action in image_actions and action != 'farm' and '高清' in args: @@ -347,7 +366,7 @@ class Runtime: receipt = await self.service.lock( who, args[0], action == "lock", op, ) - elif action in ("harvest", "eradicate", "clear_pests"): + elif action in ("harvest", "eradicate", "clear_pests", "reclaim"): if args: raise ValueError() receipt = await getattr(self.service, action)(who, op) diff --git a/src/farm_core/application.py b/src/farm_core/application.py index 7bdb8d9..731884e 100644 --- a/src/farm_core/application.py +++ b/src/farm_core/application.py @@ -1,6 +1,7 @@ """应用用例:一次业务动作对应一次完整存储事务。""" from dataclasses import asdict, replace +import logging import time import secrets @@ -11,6 +12,10 @@ from .domain import ( from .ports import Store from .pests import advance_plot, start_growth from .yield_rules import season_yield, remaining_yield +from .land import COMPENSATION_ID, SUPPORTED_PLOTS, next_rule, owned_count + + +LOGGER = logging.getLogger(__name__) class FarmService: @@ -30,6 +35,7 @@ class FarmService: self._identity(identity) text(operation_id) now = integer(self.clock()) + await self.ensure_initial_plots(identity) return await self.store.transact( identity, operation_id, action, parameters, now, lambda repository: operation(repository, now), @@ -45,11 +51,70 @@ class FarmService: async def snapshot(self, identity: Identity): self._identity(identity) now = integer(self.clock()) + await self.ensure_initial_plots(identity) snapshot = await self.store.snapshot(identity) return replace(snapshot, plots=tuple( advance_plot(plot, now) for plot in snapshot.plots )) + async def ensure_initial_plots(self, identity: Identity): + self._identity(identity) + + def operation(repository): + repository.account() + initial = repository.registration_plot_count() + if initial is None: + raise FarmError('land_origin_unknown') + count = owned_count(repository.plots()) + if count < initial: + raise FarmError('land_layout_invalid') + added = 3 if initial == 3 else 0 + if count + added > SUPPORTED_PLOTS: + raise FarmError('land_compensation_capacity') + for slot in range(count + 1, count + added + 1): + repository.add_plot(slot) + return self._result( + repository, added=added, previous_plot_count=count, + plot_count=count + added, original_plot_count=initial, + ) + + try: + return await self.store.transact( + identity, COMPENSATION_ID, 'land_compensation', {'version': 1}, + integer(self.clock()), operation, + ) + except FarmError as error: + if error.code != 'not_registered': + LOGGER.warning('farm initial land preparation failed reason=%s', + error.code) + raise + return None + + async def reclaim_conditions(self, identity: Identity): + snapshot = await self.snapshot(identity) + rule = next_rule(snapshot.plots) + return { + 'slot': rule.ordinal, 'required_level': rule.required_level, + 'coin_cost': rule.coin_cost, 'level': snapshot.account.level, + 'point': snapshot.account.point, + } + + async def reclaim(self, identity: Identity, operation_id: str): + def operation(repository, now): + account = repository.account() + rule = next_rule(repository.plots()) + if account.level < rule.required_level: + raise FarmError('level_required') + repository.asset(Asset.POINT, -rule.coin_cost) + repository.add_plot(rule.ordinal) + return self._result( + repository, slot=rule.ordinal, quantity=1, + cost=rule.coin_cost, plot_count=rule.ordinal, + ) + + return await self._execute(identity, operation_id, 'reclaim', {}, + operation) + async def register( self, identity: Identity, name: str, operation_id: str, ): diff --git a/src/farm_core/land.py b/src/farm_core/land.py new file mode 100644 index 0000000..fdb9fa8 --- /dev/null +++ b/src/farm_core/land.py @@ -0,0 +1,60 @@ +"""只读取已配置的开垦条件,不推算表外费用。""" + +from dataclasses import dataclass +from functools import lru_cache +from importlib import resources +import json +from types import MappingProxyType + +from .domain import FarmError, integer + + +SUPPORTED_PLOTS = 30 # 当前布局支持的位置数,不代表永久土地总上限。 +COMPENSATION_ID = 'initial-plots-3-to-6:v1' # 每个账号唯一的补偿操作。 + + +@dataclass(frozen=True) +class ExpansionRule: + ordinal: int # 本次开垦后新增地块的实际编号。 + required_level: int # 开垦所需最低等级。 + coin_cost: int # 本次扣除的农场币。 + + +@lru_cache(maxsize=1) +def expansion_rules(): + content = resources.files(__package__).joinpath( + 'data', 'land_rules.json', + ).read_bytes() + try: + data = json.loads(content) + integer(data['initial_plot_count'], 6, 6) + rows = data['expansion']['rows'] + if not isinstance(rows, list) or not rows: + raise ValueError('Missing expansion rules') + rules = {} + for row in rows: + rule = ExpansionRule( + integer(row['ordinal'], 7, SUPPORTED_PLOTS), + integer(row['required_level']), integer(row['coin_cost'], 1), + ) + if rule.ordinal in rules: + raise ValueError('Duplicate expansion ordinal') + rules[rule.ordinal] = rule + return MappingProxyType(rules) + except (KeyError, TypeError, ValueError) as error: + raise FarmError('land_rules_invalid') from error + + +def owned_count(plots): + slots = tuple(plot.slot for plot in plots) + if not slots or slots != tuple(range(1, len(slots) + 1)): + raise FarmError('land_layout_invalid') + return len(slots) + + +def next_rule(plots): + count = owned_count(plots) + rule = expansion_rules().get(count + 1) + if rule is None: + raise FarmError('land_rule_unavailable') + return rule diff --git a/src/farm_core/ports.py b/src/farm_core/ports.py index 3c5e29f..a80d4e0 100644 --- a/src/farm_core/ports.py +++ b/src/farm_core/ports.py @@ -17,6 +17,8 @@ class Repository(Protocol): def lock(self, plant: Plant, locked: bool) -> None: ... def plots(self) -> tuple[Plot, ...]: ... def save_plot(self, plot: Plot) -> None: ... + def add_plot(self, slot: int) -> None: ... + def registration_plot_count(self) -> int | None: ... class Store(Protocol): diff --git a/src/farm_core/presentation.py b/src/farm_core/presentation.py index 5c2a3ab..48fbbd0 100644 --- a/src/farm_core/presentation.py +++ b/src/farm_core/presentation.py @@ -27,7 +27,7 @@ def receipt_text(receipt: Receipt) -> str: "register": "注册", "buy": "购买", "sow": "播种", "harvest": "收获", "eradicate": "铲除", "lock": "锁定设置", "sell": "出售", - "clear_pests": "除虫", + "clear_pests": "除虫", "reclaim": "开垦", } data = receipt.data account = data["account"] @@ -40,6 +40,10 @@ def receipt_text(receipt: Receipt) -> str: lines.append(f"本次收入:{data['proceeds']}") if "experience" in data: lines.append(f"本次经验:+{data['experience']}") + if receipt.action == 'reclaim': + lines.append(f"新增地块:{data['slot']}号普通空地") + lines.append(f"本次消耗:{data['cost']}农场币") + lines.append(f"当前土地:{data['plot_count']}块") for item in data.get("harvested", ()): if item.get("yield_loss_percent", 0): lines.append( diff --git a/src/farm_core/repository.py b/src/farm_core/repository.py index 917a6d0..ec67560 100644 --- a/src/farm_core/repository.py +++ b/src/farm_core/repository.py @@ -76,6 +76,38 @@ class SQLiteRepository: ), ) + def registration_plot_count(self) -> int | None: + rows = self._db.execute( + "SELECT action, result FROM operations WHERE identity=? " + "AND action IN ('legacy_import', 'register') " + "ORDER BY created_at, rowid", (self._identity,), + ).fetchall() + try: + for row in rows: + data = json.loads(row['result']) + if row['action'] == 'legacy_import': + if (type(data.get('migration_version')) is int + and data['migration_version'] == 1 + and data.get('legacy_rows', {}).get('user')): + return 3 + elif data.get('created') is True: + count = data.get('plot_count', 3) + if type(count) is int and count in (3, 6): + return count + return None + except (ValueError, TypeError, AttributeError): + return None + return None + + def add_plot(self, slot: int) -> None: + from .land import SUPPORTED_PLOTS + + integer(slot, 1, SUPPORTED_PLOTS) + self._db.execute( + 'INSERT INTO plots(identity, slot) VALUES (?, ?)', + (self._identity, slot), + ) + def item(self, plant: Plant) -> InventoryItem: row = self._db.execute( "SELECT name, seeds, crops, locked FROM inventory " diff --git a/tests/test_land.py b/tests/test_land.py new file mode 100644 index 0000000..f0cfffc --- /dev/null +++ b/tests/test_land.py @@ -0,0 +1,209 @@ +import asyncio +from contextlib import closing +from dataclasses import replace +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +from farm_core import Asset, Catalogue, FarmService, Identity, SQLiteStore +from farm_core.domain import FarmError, Plot +from farm_core.repository import SQLiteRepository + + +class LandTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.temp = tempfile.TemporaryDirectory() + self.path = Path(self.temp.name) / 'farm.sqlite3' + self.store = SQLiteStore(self.path) + await self.store.initialize() + self.service = FarmService(self.store, Catalogue.bundled(), lambda: 1000) + self.who = Identity('test', 'bot', 'qq', 'land') + await self.service.register(self.who, '农场', 'register') + + async def asyncTearDown(self): + await self.store.close() + self.temp.cleanup() + + def old_account(self, count, missing_count=False): + with closing(sqlite3.connect(self.path)) as db, db: + result = json.loads(db.execute( + "SELECT result FROM operations WHERE action='register'" + ).fetchone()[0]) + if missing_count: + result.pop('plot_count', None) + else: + result['plot_count'] = 3 + db.execute("UPDATE operations SET result=? WHERE action='register'", + (json.dumps(result),)) + db.execute('DELETE FROM plots WHERE slot>?', (count,)) + db.executemany('INSERT OR IGNORE INTO plots(identity, slot) ' + 'VALUES (?, ?)', [(self.who.key, s) + for s in range(1, count + 1)]) + + async def funds(self, point=30000, experience=5600): + def operation(repo): + account = repo.account() + repo.asset(Asset.POINT, point - account.point) + repo.asset(Asset.EXPERIENCE, experience - account.experience) + return {} + await self.store.transact(self.who, 'funds', 'fixture', {}, 1000, + operation) + + async def test_new_account_conditions_charge_and_replay(self): + await self.funds(point=10000, experience=3000) + conditions = await self.service.reclaim_conditions(self.who) + self.assertEqual((conditions['slot'], conditions['required_level'], + conditions['coin_cost']), (7, 5, 10000)) + receipt = await self.service.reclaim(self.who, 'reclaim') + self.assertEqual(receipt.data['slot'], 7) + self.assertEqual(receipt.data['account']['point'], 0) + self.assertEqual(len((await self.service.snapshot(self.who)).plots), 7) + self.assertEqual(await self.service.reclaim(self.who, 'reclaim'), + receipt) + self.assertEqual((await self.service.reclaim_conditions( + self.who, + ))['coin_cost'], 20000) + + async def test_level_balance_and_unknown_rule_leave_state(self): + with self.assertRaisesRegex(FarmError, 'not_registered'): + await self.service.reclaim(replace(self.who, user_id='missing'), + 'not-registered') + with self.assertRaisesRegex(FarmError, 'level_required'): + await self.service.reclaim(self.who, 'level') + await self.funds(point=9999, experience=3000) + before = await self.store.snapshot(self.who) + with self.assertRaisesRegex(FarmError, 'insufficient_funds'): + await self.service.reclaim(self.who, 'balance') + self.assertEqual(await self.store.snapshot(self.who), before) + with closing(sqlite3.connect(self.path)) as db, db: + db.executemany('INSERT INTO plots(identity, slot) VALUES (?, ?)', + [(self.who.key, s) for s in range(7, 25)]) + before = await self.store.snapshot(self.who) + with self.assertRaisesRegex(FarmError, 'land_rule_unavailable'): + await self.service.reclaim(self.who, 'unconfigured') + self.assertEqual(await self.store.snapshot(self.who), before) + + async def test_old_paid_land_is_preserved_and_compensation_survives_restart(self): + self.old_account(8, missing_count=True) + plant = self.service.catalogue.get('白萝卜') + original = Plot(1, plant, 5000, yield_bonus=20) + await self.store.transact( + self.who, 'crop', 'fixture', {}, 1000, + lambda repo: (repo.save_plot(original) or {}), + ) + before = await self.store.snapshot(self.who) + snapshots = await asyncio.gather(*( + self.service.snapshot(self.who) for _ in range(4) + )) + self.assertTrue(all(len(s.plots) == 11 for s in snapshots)) + self.assertEqual(snapshots[0].account, before.account) + self.assertEqual(snapshots[0].plots[:8], before.plots) + self.assertTrue(all(p.plant is None and p.yield_bonus == 0 + for p in snapshots[0].plots[8:])) + await self.store.close() + await self.store.initialize() + self.assertEqual(len((await self.service.snapshot(self.who)).plots), 11) + with closing(sqlite3.connect(self.path)) as db: + self.assertEqual(db.execute("SELECT COUNT(*) FROM operations " + "WHERE action='land_compensation'").fetchone()[0], + 1) + + async def test_old_three_and_five_receive_three(self): + for count in (3, 5, 27): + with self.subTest(count=count): + self.old_account(count) + with closing(sqlite3.connect(self.path)) as db, db: + db.execute("DELETE FROM operations " + "WHERE action='land_compensation'") + self.assertEqual(len((await self.service.snapshot( + self.who, + )).plots), count + 3) + + async def test_unknown_capacity_and_noncontiguous_keep_all_assets(self): + self.old_account(28) + before = await self.store.snapshot(self.who) + with self.assertRaisesRegex(FarmError, 'land_compensation_capacity'): + await self.service.snapshot(self.who) + self.assertEqual(await self.store.snapshot(self.who), before) + with closing(sqlite3.connect(self.path)) as db, db: + db.execute('DELETE FROM plots WHERE slot>5 OR slot=2') + before = await self.store.snapshot(self.who) + with self.assertRaisesRegex(FarmError, 'land_layout_invalid'): + await self.service.snapshot(self.who) + self.assertEqual(await self.store.snapshot(self.who), before) + with closing(sqlite3.connect(self.path)) as db, db: + db.execute("DELETE FROM operations WHERE action='register'") + with self.assertRaisesRegex(FarmError, 'land_origin_unknown'): + await self.service.snapshot(self.who) + self.assertEqual(await self.store.snapshot(self.who), before) + + async def test_compensation_failure_rolls_back_and_can_retry(self): + self.old_account(3) + before = await self.store.snapshot(self.who) + original = SQLiteRepository.add_plot + + def fail(repo, slot): + original(repo, slot) + raise RuntimeError('fixture') + + with patch.object(SQLiteRepository, 'add_plot', fail): + with self.assertRaises(RuntimeError): + await self.service.snapshot(self.who) + self.assertEqual(await self.store.snapshot(self.who), before) + self.assertEqual(len((await self.service.snapshot(self.who)).plots), 6) + + async def test_reclaim_failure_and_concurrent_different_events(self): + await self.funds() + await self.service.snapshot(self.who) + before = await self.store.snapshot(self.who) + with patch.object(SQLiteRepository, 'add_plot', + side_effect=RuntimeError('fixture')): + with self.assertRaises(RuntimeError): + await self.service.reclaim(self.who, 'failed') + self.assertEqual(await self.store.snapshot(self.who), before) + receipts = await asyncio.gather( + self.service.reclaim(self.who, 'one'), + self.service.reclaim(self.who, 'two'), + ) + self.assertEqual({r.data['slot'] for r in receipts}, {7, 8}) + self.assertEqual((await self.service.snapshot(self.who)).account.point, 0) + + async def test_same_event_concurrently_charges_once(self): + await self.funds(point=10000, experience=3000) + receipts = await asyncio.gather(*( + self.service.reclaim(self.who, 'same-event') for _ in range(4) + )) + self.assertTrue(all(r == receipts[0] for r in receipts)) + self.assertEqual(len((await self.service.snapshot(self.who)).plots), 7) + ledger = await self.store.ledger(self.who) + costs = [entry['delta'] for entry in ledger + if entry['operation_id'] == 'same-event'] + self.assertEqual(costs, [-10000]) + + async def test_last_configured_plot_can_be_bought_without_extrapolation(self): + with closing(sqlite3.connect(self.path)) as db, db: + db.executemany('INSERT INTO plots(identity, slot) VALUES (?, ?)', + [(self.who.key, s) for s in range(7, 24)]) + await self.funds(point=2000000, experience=156000) + receipt = await self.service.reclaim(self.who, 'last') + self.assertEqual(receipt.data['slot'], 24) + self.assertEqual(receipt.data['cost'], 2000000) + self.assertEqual(receipt.data['account']['point'], 0) + with self.assertRaisesRegex(FarmError, 'land_rule_unavailable'): + await self.service.reclaim(self.who, 'beyond') + + async def test_legacy_import_receives_compensation_after_migration(self): + from test_legacy_migration import create_legacy + source = Path(self.temp.name) / 'old.db' + create_legacy(source) + legacy = replace(self.who, user_id='1') + await self.store.migrate_legacy(source, legacy, self.service.catalogue, + 1000) + before = await self.store.snapshot(legacy) + after = await self.service.snapshot(legacy) + self.assertEqual(len(after.plots), 7) + self.assertEqual(after.account, before.account) + self.assertEqual(after.plots[:4], before.plots) diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index 0069233..cc986f6 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -71,6 +71,36 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): self.assertIn('参数有误', await self.call('plot_ids', '1')) self.assertIn('开通农场', await self.call('plot_ids', user_id='other')) + async def test_reclaim_conditions_charge_replay_and_arguments(self): + self.assertEqual(self.module.COMMANDS['开垦'], 'reclaim') + self.assertIn('开垦 条件', await self.call('help')) + await self.call('register') + who = self.runtime_identity() + asset = importlib.import_module( + self.package + '._vendor.farm_core.domain', + ).Asset + + def fund(repo): + repo.asset(asset.POINT, 9500) + repo.asset(asset.EXPERIENCE, 3000) + return {} + + await self.runtime.store.transact(who, 'fund', 'fixture', {}, + self.runtime.service.clock(), fund) + self.assertIn('7号', await self.call('reclaim', '条件', message=None)) + before = await self.runtime.service.snapshot(who) + self.assertIn('参数有误', await self.call('reclaim', '2')) + self.assertIn('未执行', await self.call('reclaim', message=None)) + self.assertEqual(await self.runtime.service.snapshot(who), before) + result = await self.call('reclaim', message='reclaim') + self.assertIn('开垦已完成', result) + self.assertIn('10000农场币', result) + self.assertEqual(result, await self.call('reclaim', message='reclaim')) + after = await self.runtime.service.snapshot(who) + self.assertEqual(len(after.plots), 7) + self.assertEqual(after.account.point, 0) + self.assertIn('8号', await self.call('reclaim', '条件', message=None)) + async def test_plot_ids_text_fallback_uses_actual_owned_slots(self): await self.call('register') rendering = self.runtime.rendering