fixed # 新增开垦指令

This commit is contained in:
2026-10-23 12:11:08 +08:00
parent 79ae03bf95
commit d0c1741940
12 changed files with 455 additions and 9 deletions
+4 -1
View File
@@ -41,7 +41,10 @@ FARM_DEPLOYMENT=nonebot
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、 我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。 作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。 发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
范围与当前真寻生成版一致;旧版开垦、签到、改名、土地升级等命令 开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。
初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果;
来源不明、编号异常或补后超出30位置则保留原状并明确提示。
范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令
尚未在共享核心中实现,此版本不提供这些命令。 尚未在共享核心中实现,此版本不提供这些命令。
## 生成与发布 ## 生成与发布
+13 -5
View File
@@ -6,17 +6,25 @@
- 开垦保留图片序号 7~24 的付费条件;红、黑、金土地各保留 1~24。 - 开垦保留图片序号 7~24 的付费条件;红、黑、金土地各保留 1~24。
- 图片金币单位为万,配置中的 `coin_cost` 为乘 10000 后的整数。 - 图片金币单位为万,配置中的 `coin_cost` 为乘 10000 后的整数。
- 前 6 块由 `initial_plot_count: 6` 表示,不重复保存空条件记录。 - 前 6 块由 `initial_plot_count: 6` 表示,不重复保存空条件记录。
新用户默认 6 块空地是用户另行明确指定的规则。已有账号不自动补地。 新用户默认6块;可靠识别为初始3块的旧用户一次性补3块普通空地。
- `ordinal` 是图片表内的条件序号,不等同于实际地块 `slot`。 - 开垦时,`expansion.rows.ordinal` 对应补偿完成后的下一实际地块编号。
后续升级服务必须明确这两者的对应方式,不能混用。 土地升级表仍只是规则数据,本次未接入土地升级。
- 表外序号,以及材料、星钻替代支付、冷却和额外解锁条件没有在图中 - 表外序号,以及材料、星钻替代支付、冷却和额外解锁条件没有在图中
给出。不能默认免费、默认通过,或套用最后一行。24 不是土地总上限。 给出。不能默认免费、默认通过,或套用最后一行。24 不是土地总上限。
- 本次提供规则数据;开垦和土地升级指令尚未接入这份配置。 - 已提供“开垦”(每次1块)和“开垦 条件”(查询等级及费用)。
等级检查、扣币及新增空地在同一事务内完成,重放同消息不重复扣款。
目前配置覆盖7~24号,25号起明确提示规则未配置,不扣币。
## 新用户注册 ## 新用户注册
注册事务创建 1~6 号空地,初始农场币仍为 500。重复注册不额外发地。 注册事务创建 1~6 号空地,初始农场币仍为 500。重复注册不额外发地。
不修改、补齐或重编号已有数据库中的地块记录。 不重编号已有地块。旧库迁入用户及首次成功注册回执未记录plot_count
或记录为3的用户,在首次业务访问时一次性追加3块普通空地,保留已付费
开垦成果:3→6、5→8、8→11。首次注册记录为6的用户不重复赠地。
补偿与版本标识保存在operations中,重试、并发及重启不重复发放。
来源不明、地块编号不连续/数量异常或补后超过当前30位置时明确报错、
保持原状,不截断、不折现,也不标记已补偿;管理员修复后可重试。
已迁移及后续迁入用户都通过共享服务处理,不直接改动旧farm.db。
## 绘图编号顺序 ## 绘图编号顺序
+4 -1
View File
@@ -27,7 +27,10 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。 `/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
仅`/我的农场 高清`支持高清;其他图片统一遵循质量配置。 仅`/我的农场 高清`支持高清;其他图片统一遵循质量配置。
仓库保留分页和文字;偷菜通过`/偷菜 @目标`,不接受手输QQ号。 仓库保留分页和文字;偷菜通过`/偷菜 @目标`,不接受手输QQ号。
不提供共享核心尚未实现的签到、开垦、改名、土地升级及外部货币兑换。 `/开垦 条件`查询下一块地的等级及费用,`/开垦`每次增加1块普通空地。
可靠识别为初始3块的旧用户一次性补3块,保留已开垦成果;来源或容量
异常会明确提示管理员处理。没有配置的地块不放行、不扣款。
不提供共享核心尚未实现的签到、改名、土地升级及外部货币兑换。
存档按AstrBot部署、平台实例、机器人及用户隔离,不自动合并NoneBot或 存档按AstrBot部署、平台实例、机器人及用户隔离,不自动合并NoneBot或
真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。 真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。
+11
View File
@@ -8,6 +8,17 @@
使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。 使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。
本轮没有拉取、推送或操作真实机器人。 本轮没有拉取、推送或操作真实机器人。
## 开垦与旧用户土地
发送“开垦 条件”查看下一块地的等级和农场币费用,“开垦”每次新增
1块普通空地。目前配置覆盖7~24号地,表外拒绝开垦且不扣币。
扣款、新增地块及流水在同一事务完成,重复消息不重复扣款。
有可靠旧库迁移记录,或首次成功注册记录证明初始3块的账号,首次
业务访问时一次性补3块,保留付费成果、作物及土地加成。新版初始6块
账号不额外发地。来源不明、编号/数量异常或补后超出当前30位置的账号
保留原状并提示管理员处理,不自动截断或折现。
## 图片质量 ## 图片质量
真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。 真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。
+20 -1
View File
@@ -31,11 +31,13 @@ COMMANDS = {
"作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell", "作物加锁":"lock", "作物解锁":"unlock", "出售作物":"sell",
"农场除虫":"clear_pests", "除虫":"clear_pests", "地块编号":"plot_ids", "农场除虫":"clear_pests", "除虫":"clear_pests", "地块编号":"plot_ids",
"偷菜":"steal", "偷菜":"steal",
"开垦":"reclaim",
} }
HELP = ( HELP = (
"开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n" "开通农场 [名称]|我的农场 [高清]|农场详述 [页码]\n"
"地块编号|查看当前土地编号及开垦位置\n" "地块编号|查看当前土地编号及开垦位置\n"
"偷菜 @目标|每日最多5次成功偷取\n" "偷菜 @目标|每日最多5次成功偷取\n"
"开垦|每次增加1块普通空地;开垦 条件|查看等级和费用\n"
"我的种子/我的作物 [页码] [文字],默认图片。\n" "我的种子/我的作物 [页码] [文字],默认图片。\n"
"全部图片使用农场图片质量配置;仅我的农场支持高清。\n" "全部图片使用农场图片质量配置;仅我的农场支持高清。\n"
"种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n" "种子商店 [页码]|购买种子 作物 数量|播种 作物 [数量]\n"
@@ -51,6 +53,13 @@ HELP = (
"旧版农场数据在首次使用时迁移,绑定当前机器人和平台。" "旧版农场数据在首次使用时迁移,绑定当前机器人和平台。"
) )
ERRORS = { ERRORS = {
'land_origin_unknown': '农场初始土地来源无法确认,未补地或扣币,'
'请联系管理员核查注册或迁移记录。',
'land_compensation_capacity': '旧用户补3块后将超出当前30个绘图位置,'
'已保留原状,请联系管理员单独处理补偿。',
'land_layout_invalid': '地块编号或数量异常,未补地或扣币,请联系管理员核查。',
'land_rules_invalid': '开垦配置异常,未扣币,请联系管理员检查。',
'land_rule_unavailable': '下一块地的开垦规则尚未配置,未扣币。',
'cannot_steal_self': '不能偷自己的农场。', 'cannot_steal_self': '不能偷自己的农场。',
'different_farm_scope': '只能偷取当前机器人、平台内的农场。', 'different_farm_scope': '只能偷取当前机器人、平台内的农场。',
'target_not_registered': '目标尚未开通当前农场。', 'target_not_registered': '目标尚未开通当前农场。',
@@ -204,6 +213,16 @@ class Runtime:
) )
if action == "help": if action == "help":
return 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', image_actions = ('farm', 'plot_ids', 'details', 'shop',
'seeds', 'crops') 'seeds', 'crops')
if action in image_actions and action != 'farm' and '高清' in args: if action in image_actions and action != 'farm' and '高清' in args:
@@ -347,7 +366,7 @@ class Runtime:
receipt = await self.service.lock( receipt = await self.service.lock(
who, args[0], action == "lock", op, who, args[0], action == "lock", op,
) )
elif action in ("harvest", "eradicate", "clear_pests"): elif action in ("harvest", "eradicate", "clear_pests", "reclaim"):
if args: if args:
raise ValueError() raise ValueError()
receipt = await getattr(self.service, action)(who, op) receipt = await getattr(self.service, action)(who, op)
+65
View File
@@ -1,6 +1,7 @@
"""应用用例:一次业务动作对应一次完整存储事务。""" """应用用例:一次业务动作对应一次完整存储事务。"""
from dataclasses import asdict, replace from dataclasses import asdict, replace
import logging
import time import time
import secrets import secrets
@@ -11,6 +12,10 @@ from .domain import (
from .ports import Store from .ports import Store
from .pests import advance_plot, start_growth from .pests import advance_plot, start_growth
from .yield_rules import season_yield, remaining_yield 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: class FarmService:
@@ -30,6 +35,7 @@ class FarmService:
self._identity(identity) self._identity(identity)
text(operation_id) text(operation_id)
now = integer(self.clock()) now = integer(self.clock())
await self.ensure_initial_plots(identity)
return await self.store.transact( return await self.store.transact(
identity, operation_id, action, parameters, now, identity, operation_id, action, parameters, now,
lambda repository: operation(repository, now), lambda repository: operation(repository, now),
@@ -45,11 +51,70 @@ class FarmService:
async def snapshot(self, identity: Identity): async def snapshot(self, identity: Identity):
self._identity(identity) self._identity(identity)
now = integer(self.clock()) now = integer(self.clock())
await self.ensure_initial_plots(identity)
snapshot = await self.store.snapshot(identity) snapshot = await self.store.snapshot(identity)
return replace(snapshot, plots=tuple( return replace(snapshot, plots=tuple(
advance_plot(plot, now) for plot in snapshot.plots 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( async def register(
self, identity: Identity, name: str, operation_id: str, self, identity: Identity, name: str, operation_id: str,
): ):
+60
View File
@@ -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
+2
View File
@@ -17,6 +17,8 @@ class Repository(Protocol):
def lock(self, plant: Plant, locked: bool) -> None: ... def lock(self, plant: Plant, locked: bool) -> None: ...
def plots(self) -> tuple[Plot, ...]: ... def plots(self) -> tuple[Plot, ...]: ...
def save_plot(self, plot: Plot) -> None: ... def save_plot(self, plot: Plot) -> None: ...
def add_plot(self, slot: int) -> None: ...
def registration_plot_count(self) -> int | None: ...
class Store(Protocol): class Store(Protocol):
+5 -1
View File
@@ -27,7 +27,7 @@ def receipt_text(receipt: Receipt) -> str:
"register": "注册", "buy": "购买", "sow": "播种", "register": "注册", "buy": "购买", "sow": "播种",
"harvest": "收获", "eradicate": "铲除", "harvest": "收获", "eradicate": "铲除",
"lock": "锁定设置", "sell": "出售", "lock": "锁定设置", "sell": "出售",
"clear_pests": "除虫", "clear_pests": "除虫", "reclaim": "开垦",
} }
data = receipt.data data = receipt.data
account = data["account"] account = data["account"]
@@ -40,6 +40,10 @@ def receipt_text(receipt: Receipt) -> str:
lines.append(f"本次收入:{data['proceeds']}") lines.append(f"本次收入:{data['proceeds']}")
if "experience" in data: if "experience" in data:
lines.append(f"本次经验:+{data['experience']}") 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", ()): for item in data.get("harvested", ()):
if item.get("yield_loss_percent", 0): if item.get("yield_loss_percent", 0):
lines.append( lines.append(
+32
View File
@@ -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: def item(self, plant: Plant) -> InventoryItem:
row = self._db.execute( row = self._db.execute(
"SELECT name, seeds, crops, locked FROM inventory " "SELECT name, seeds, crops, locked FROM inventory "
+209
View File
@@ -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)
+30
View File
@@ -71,6 +71,36 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('参数有误', await self.call('plot_ids', '1')) self.assertIn('参数有误', await self.call('plot_ids', '1'))
self.assertIn('开通农场', await self.call('plot_ids', user_id='other')) 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): async def test_plot_ids_text_fallback_uses_actual_owned_slots(self):
await self.call('register') await self.call('register')
rendering = self.runtime.rendering rendering = self.runtime.rendering