mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-28 13:10:02 +08:00
fixed # 新增开垦指令
This commit is contained in:
+4
-1
@@ -41,7 +41,10 @@ FARM_DEPLOYMENT=nonebot
|
||||
我的种子、我的作物、种子商店、购买种子、播种、收获、铲除、
|
||||
作物加锁、作物解锁、出售作物、农场除虫、除虫、偷菜、农场帮助。
|
||||
发送「农场帮助」查看参数。图片与提及由 Alconna 统一消息适配。
|
||||
范围与当前真寻生成版一致;旧版开垦、签到、改名、土地升级等命令
|
||||
开垦每次增加1块普通空地;开垦 条件查询下一块地的等级与费用。
|
||||
初始3块的旧账号按可靠注册或旧库迁移记录一次性补3块,保留已开垦成果;
|
||||
来源不明、编号异常或补后超出30位置则保留原状并明确提示。
|
||||
范围与当前真寻生成版一致;旧版签到、改名、土地升级等命令
|
||||
尚未在共享核心中实现,此版本不提供这些命令。
|
||||
|
||||
## 生成与发布
|
||||
|
||||
+13
-5
@@ -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。
|
||||
|
||||
## 绘图编号顺序
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ Windows双击主仓库根目录的`build_astrbot.cmd`,成功后控制台打印
|
||||
`/我的农场`、`/种子商店`、`/我的种子 文字`、`/播种 白萝卜`、`/收获`。
|
||||
仅`/我的农场 高清`支持高清;其他图片统一遵循质量配置。
|
||||
仓库保留分页和文字;偷菜通过`/偷菜 @目标`,不接受手输QQ号。
|
||||
不提供共享核心尚未实现的签到、开垦、改名、土地升级及外部货币兑换。
|
||||
`/开垦 条件`查询下一块地的等级及费用,`/开垦`每次增加1块普通空地。
|
||||
可靠识别为初始3块的旧用户一次性补3块,保留已开垦成果;来源或容量
|
||||
异常会明确提示管理员处理。没有配置的地块不放行、不扣款。
|
||||
不提供共享核心尚未实现的签到、改名、土地升级及外部货币兑换。
|
||||
|
||||
存档按AstrBot部署、平台实例、机器人及用户隔离,不自动合并NoneBot或
|
||||
真寻账号。缺少稳定消息ID时拒绝写操作。字体不可用时尽可能退回文字。
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
使用 scripts/build_zhenxun.py 生成ZIP,详见 docs/11-zhenxun-preview.md。
|
||||
本轮没有拉取、推送或操作真实机器人。
|
||||
|
||||
## 开垦与旧用户土地
|
||||
|
||||
发送“开垦 条件”查看下一块地的等级和农场币费用,“开垦”每次新增
|
||||
1块普通空地。目前配置覆盖7~24号地,表外拒绝开垦且不扣币。
|
||||
扣款、新增地块及流水在同一事务完成,重复消息不重复扣款。
|
||||
|
||||
有可靠旧库迁移记录,或首次成功注册记录证明初始3块的账号,首次
|
||||
业务访问时一次性补3块,保留付费成果、作物及土地加成。新版初始6块
|
||||
账号不额外发地。来源不明、编号/数量异常或补后超出当前30位置的账号
|
||||
保留原状并提示管理员处理,不自动截断或折现。
|
||||
|
||||
## 图片质量
|
||||
|
||||
真寻网页插件配置新增“农场图片质量”,整数1~95,默认75。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user