From 0d3b5f41e0df69602c0a3e504ad68c717d4029fd Mon Sep 17 00:00:00 2001 From: Art_Sakura <1754798088@qq.com> Date: Tue, 20 Oct 2026 14:13:31 +0800 Subject: [PATCH] =?UTF-8?q?add=20#=20=E6=96=B0=E5=A2=9E=E7=BB=8F=E9=AA=8C?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/zhenxun-preview.yml | 3 +- docs/16-pest-mechanics.md | 2 +- docs/20-experience-rules.md | 25 +++++++++++ integrations/zhenxun/runtime.py | 2 + scripts/check_workflows.py | 11 ++++- src/farm_core/application.py | 10 ++++- src/farm_core/presentation.py | 2 + src/farm_render/renderer.py | 2 +- tests/test_pests.py | 31 ++++++++++++- tests/test_render.py | 11 +++-- tests/test_transactions.py | 63 +++++++++++++++++++++++++-- 11 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 docs/20-experience-rules.md diff --git a/.github/workflows/zhenxun-preview.yml b/.github/workflows/zhenxun-preview.yml index ce0e378..7f2003c 100644 --- a/.github/workflows/zhenxun-preview.yml +++ b/.github/workflows/zhenxun-preview.yml @@ -74,7 +74,8 @@ jobs: sync-preview: if: >- - github.event_name == 'workflow_dispatch' && inputs.publish && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.publish)) && github.ref == 'refs/heads/main' && github.repository == 'Shu-Ying/main_plugin_farm' needs: [test, build] diff --git a/docs/16-pest-mechanics.md b/docs/16-pest-mechanics.md index 990915e..e654655 100644 --- a/docs/16-pest-mechanics.md +++ b/docs/16-pest-mechanics.md @@ -49,7 +49,7 @@ v1→v2→v3、v2→v3 都在初始化事务内迁移,保留已有资产、流 ## QQ 指令与绘制 发送 `农场除虫` 或 `除虫`,一次清除自己所有地块现存虫子, -不收费、不奖励经验;无虫时返回明确提示。重复事件返回原结果, +不收费;每实际清除 1 只虫奖励 1 经验,无虫时不奖励并返回明确提示。重复事件返回原结果, 事务失败全部回滚。`农场详述` 显示虫数、当前减速及已冻结减产比例, 收获结果说明发生减产的地块。 diff --git a/docs/20-experience-rules.md b/docs/20-experience-rules.md new file mode 100644 index 0000000..75b443c --- /dev/null +++ b/docs/20-experience-rules.md @@ -0,0 +1,25 @@ +# 当前经验规则 + +当前正式实现有四种经验来源: + +| 来源 | 结算规则 | +| --- | --- | +| 成功播种 | 每成功播种 1 块地获得 2 经验;一次播种 3 块地获得 6 经验 | +| 收获成熟作物 | 每块地每季按播种时冻结的作物经验与该地块经验加成结算,然后累加 | +| 铲除枯萎作物 | 每实际清理 1 块枯萎地块获得 2 经验 | +| 除虫 | 每实际清除 1 只虫获得 1 经验;跨地块累加 | + +收获单地块经验为 `基础经验 × (100 + 地块经验加成百分比) // 100`。 +多季作物每季收获都会结算;虫害减产不减少收获经验。 +购买、出售、查看农场或商店均不增加经验。 +开通农场初始经验为 0。未实现的签到等玩法不属于当前经验来源。 + +播种、铲除和除虫奖励与对应地块更新及流水在同一数据库事务中提交。 +失败不奖励,同一消息重试不重复奖励;已有历史操作不补发。 +没有枯萎作物或虫子时不奖励。成熟后除虫仍按清除数量奖励, +但不会恢复已确定的本季虫害减产。铲除仍仅支持枯萎作物。 + +等级 k 的累计经验门槛为 `200 × k × (k + 1) // 2`。 +经验条显示本级已获经验与本级所需经验: +`累计经验 - 当前等级门槛` / `200 × (k + 1)`。 +例如累计经验 599、等级 1 时显示 399/400。 diff --git a/integrations/zhenxun/runtime.py b/integrations/zhenxun/runtime.py index 6615a4b..d47aee4 100644 --- a/integrations/zhenxun/runtime.py +++ b/integrations/zhenxun/runtime.py @@ -31,6 +31,8 @@ HELP = ( "收获|铲除|农场除虫|我的种子|我的作物\n" "作物加锁 作物|作物解锁 作物|出售作物 [作物] [数量]\n" "作物可填写名称或 #编号;同名作物请使用商店或仓库中的编号。\n" + "每成功播种1块地获得2经验;收获经验按作物结算。\n" + "每铲除1块枯萎地块获得2经验;每清除1只虫获得1经验。\n" "每只虫使生长速度降低5%;成熟时每只虫使本季减产5%。\n" "成熟后除虫不能恢复本季减产;生长期间可发送:农场除虫。\n" "我的农场按北京时间自动切换春夏秋冬背景;高清使用相同季节。\n" diff --git a/scripts/check_workflows.py b/scripts/check_workflows.py index 6fa3a15..489259e 100644 --- a/scripts/check_workflows.py +++ b/scripts/check_workflows.py @@ -30,9 +30,16 @@ def main(): if inputs["publish"]["default"] != "false": raise ValueError("Publish must be opt-in") sync = source["jobs"]["sync-preview"] + expected_condition = ( + "(github.event_name == 'push' || " + "(github.event_name == 'workflow_dispatch' && inputs.publish)) && " + "github.ref == 'refs/heads/main' && " + "github.repository == 'Shu-Ying/main_plugin_farm'" + ) if (sync["environment"] != "zhenxun-preview" - or "refs/heads/main" not in sync["if"] - or "workflow_dispatch" not in sync["if"] + or " ".join(sync["if"].split()) != expected_condition + or trigger["push"]["branches"] != ["main"] + or source["jobs"]["build"]["needs"] != "test" or sync["needs"] != ["test", "build"]): raise ValueError("Publish boundary differs") if generated["permissions"] != {"contents":"read"}: diff --git a/src/farm_core/application.py b/src/farm_core/application.py index f845f89..556f6da 100644 --- a/src/farm_core/application.py +++ b/src/farm_core/application.py @@ -120,11 +120,14 @@ class FarmService: repository.save_plot(start_growth(replace( plot, plant=plant, mature_at=mature_at, harvest_count=0, ), now)) + experience = integer(amount * 2) + repository.asset(Asset.EXPERIENCE, experience) return self._result( repository, name=plant.name, plant_id=plant.plant_id, quantity=amount, slots=[p.slot for p in empty[:amount]], seeds=repository.item(plant).seeds, + experience=experience, ) return await self._execute( @@ -200,8 +203,11 @@ class FarmService: growth_updated_at=None, next_pest_at=None, maturity_pests=0, )) + experience = integer(len(withered) * 2) + repository.asset(Asset.EXPERIENCE, experience) return self._result( repository, slots=[p.slot for p in withered], + experience=experience, ) return await self._execute( @@ -221,9 +227,11 @@ class FarmService: repository.save_plot(advance_plot(updated, now)) if not cleared: raise FarmError("nothing_to_clear") + quantity = integer(sum(item['pests'] for item in cleared)) + repository.asset(Asset.EXPERIENCE, quantity) return self._result( repository, cleared=cleared, - quantity=sum(item["pests"] for item in cleared), + quantity=quantity, experience=quantity, ) return await self._execute( diff --git a/src/farm_core/presentation.py b/src/farm_core/presentation.py index ada26fe..da2f222 100644 --- a/src/farm_core/presentation.py +++ b/src/farm_core/presentation.py @@ -19,6 +19,8 @@ def receipt_text(receipt: Receipt) -> str: lines.append(f"实际数量:{data['quantity']}") if "proceeds" in data: lines.append(f"本次收入:{data['proceeds']}") + if "experience" in data: + lines.append(f"本次经验:+{data['experience']}") for item in data.get("harvested", ()): if item.get("yield_loss_percent", 0): lines.append( diff --git a/src/farm_render/renderer.py b/src/farm_render/renderer.py index 1930b8e..7b4211d 100644 --- a/src/farm_render/renderer.py +++ b/src/farm_render/renderer.py @@ -224,7 +224,7 @@ class FarmRenderer: account = scene.account level = account.level current = account.experience - 200 * level * (level + 1) // 2 - next_total = 200 * (level + 1) * (level + 2) // 2 + next_total = 200 * (level + 1) for entry in layout['sprites']: x, y, w, h = entry['rect'] image = self.assets.image( diff --git a/tests/test_pests.py b/tests/test_pests.py index d7c8d9a..b126dea 100644 --- a/tests/test_pests.py +++ b/tests/test_pests.py @@ -118,14 +118,41 @@ class PestTests(unittest.IsolatedAsyncioTestCase): await self.service.clear_pests(self.who, "failed-clear") self.assertEqual(await self.store.snapshot(self.who), before) - async def test_empty_clear_and_ripe_clear_reward_no_assets(self): + async def test_empty_clear_has_no_reward_and_clear_awards_per_pest(self): with self.assertRaisesRegex(FarmError, "nothing_to_clear"): await self.service.clear_pests(self.who, "empty-clear") self.now = 4600 before = await self.service.snapshot(self.who) await self.service.clear_pests(self.who, "clear") after = await self.service.snapshot(self.who) - self.assertEqual(before.account, after.account) + self.assertEqual(after.account.experience, before.account.experience+1) + self.assertEqual(after.account.point, before.account.point) + self.assertEqual(after.account.vip_point, before.account.vip_point) + + async def test_multi_plot_pest_rewards_replay_and_transaction_rollback(self): + await self.service.buy(self.who, '虫害测试菜', 1, 'buy-second') + await self.service.sow(self.who, '虫害测试菜', 1, 'sow-second') + self.now = 11800 + before = await self.store.snapshot(self.who) + ledger = await self.store.ledger(self.who) + with patch.object(SQLiteRepository, 'asset', + side_effect=RuntimeError('reward failure')): + with self.assertRaises(RuntimeError): + await self.service.clear_pests(self.who, 'clear-six') + self.assertEqual(await self.store.snapshot(self.who), before) + self.assertEqual(await self.store.ledger(self.who), ledger) + receipts = await asyncio.gather(*( + self.service.clear_pests(self.who, 'clear-six') for _ in range(3) + )) + self.assertTrue(all(r == receipts[0] for r in receipts)) + self.assertEqual(receipts[0].data['quantity'], 6) + self.assertEqual(receipts[0].data['experience'], 6) + after = await self.service.snapshot(self.who) + self.assertEqual(after.account.experience, before.account.experience+6) + with self.assertRaisesRegex(FarmError, 'nothing_to_clear'): + await self.service.clear_pests(self.who, 'empty-after') + self.assertEqual((await self.service.snapshot(self.who)).account, + after.account) async def test_projection_is_independent_of_query_frequency(self): initial = (await self.store.snapshot(self.who)).plots[0] diff --git a/tests/test_render.py b/tests/test_render.py index a6f6d7e..2b8c4b3 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -137,8 +137,8 @@ class RenderTests(unittest.TestCase): def test_web_panel_fields_fit_their_prefab_rectangles(self): options = RenderOptions(width=3072) for exp, level, current, threshold in ( - (0, 0, 0, 200), (199, 0, 199, 200), (200, 1, 0, 600), - (599, 1, 399, 600), (600, 2, 0, 1200), (3420, 5, 420, 4200), + (0, 0, 0, 200), (199, 0, 199, 200), (200, 1, 0, 400), + (599, 1, 399, 400), (600, 2, 0, 600), (3420, 5, 420, 1200), ): data = snapshot(name="农场", point=12580, vip_point=36, experience=exp) @@ -173,7 +173,10 @@ class RenderTests(unittest.TestCase): original = self.renderer.assets.image( 'webgl/panel/ExpProgress.png', (172, 18), ) - for exp in (0, 100, 199): + for exp, current, required in ( + (0, 0, 200), (100, 100, 200), (199, 199, 200), + (599, 399, 400), (1199, 599, 600), + ): scene = self.renderer.scene( self.who, snapshot(experience=exp), 0, ) @@ -185,7 +188,7 @@ class RenderTests(unittest.TestCase): self.assertEqual(len(calls), 6) else: self.assertEqual(len(calls), 7) - filled = exp * 172 // 200 + filled = current * 172 // required self.assertEqual(calls[4].args[0].tobytes(), original.crop((0, 0, filled, 18)).tobytes()) diff --git a/tests/test_transactions.py b/tests/test_transactions.py index eb433c5..c3bc425 100644 --- a/tests/test_transactions.py +++ b/tests/test_transactions.py @@ -86,7 +86,7 @@ class TransactionTests(unittest.IsolatedAsyncioTestCase): sale = await self.service.sell(self.identity, None, None, "sell") self.assertEqual(sale.data["proceeds"], 20) self.assertEqual(sale.data["account"]["point"], 510) - self.assertEqual(sale.data["account"]["experience"], 20) + self.assertEqual(sale.data["account"]["experience"], 22) snapshot = await self.service.snapshot(self.identity) self.assertEqual(snapshot.inventory[0].seeds, 0) self.assertEqual(snapshot.inventory[0].crops, 0) @@ -106,7 +106,7 @@ class TransactionTests(unittest.IsolatedAsyncioTestCase): await self.mature_radish() self.assertEqual( (await self.service.snapshot(self.identity)).account.experience, - 120, + 122, ) await self.seed("balance-100", lambda repo: ( repo.asset(Asset.POINT, -390) or {} @@ -185,7 +185,7 @@ class TransactionTests(unittest.IsolatedAsyncioTestCase): snapshot = await self.service.snapshot(self.identity) self.assertTrue(snapshot.plots[0].withered) self.assertEqual(snapshot.inventory[0].crops, 6) - self.assertEqual(snapshot.account.experience, 60) + self.assertEqual(snapshot.account.experience, 62) with self.assertRaisesRegex(FarmError, "nothing_to_harvest"): await self.service.harvest(self.identity, "third") @@ -338,7 +338,62 @@ class TransactionTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(sum(not isinstance(r, Exception) for r in results), 1) snapshot = await self.service.snapshot(self.identity) self.assertEqual(snapshot.inventory[0].crops, 2) - self.assertEqual(snapshot.account.experience, 20) + self.assertEqual(snapshot.account.experience, 22) + + async def test_sowing_awards_two_per_plot_and_replay_once(self): + await self.service.buy(self.identity, '萝卜', 3, 'buy') + first = await self.service.sow(self.identity, '萝卜', 2, 'sow-two') + self.assertEqual(first.data['experience'], 4) + self.assertEqual(first.data['account']['experience'], 4) + self.assertIn('本次经验:+4', receipt_text(first)) + repeated = await asyncio.gather(*( + self.service.sow(self.identity, '萝卜', 2, 'sow-two') + for _ in range(4) + )) + self.assertTrue(all(result == first for result in repeated)) + await self.service.sow(self.identity, '萝卜', None, 'sow-rest') + self.assertEqual( + (await self.service.snapshot(self.identity)).account.experience, 6, + ) + + async def test_eradication_rewards_each_plot_once_and_rolls_back(self): + await self.mature_radish(count=2) + before = await self.service.snapshot(self.identity) + ledger = await self.store.ledger(self.identity) + with patch.object(SQLiteRepository, 'asset', + side_effect=sqlite3.OperationalError('injected')): + with self.assertRaises(StorageError): + await self.service.eradicate(self.identity, 'clear') + self.assertEqual(await self.service.snapshot(self.identity), before) + self.assertEqual(await self.store.ledger(self.identity), ledger) + receipts = await asyncio.gather(*( + self.service.eradicate(self.identity, 'clear') for _ in range(3) + )) + self.assertTrue(all(r == receipts[0] for r in receipts)) + self.assertEqual(receipts[0].data['experience'], 4) + after = await self.service.snapshot(self.identity) + self.assertEqual(after.account.experience, before.account.experience+4) + self.assertTrue(all(p.plant is None for p in after.plots)) + with self.assertRaisesRegex(FarmError, 'nothing_to_eradicate'): + await self.service.eradicate(self.identity, 'empty') + self.assertEqual(await self.service.snapshot(self.identity), after) + + async def test_failed_sowing_rolls_back_experience_seeds_and_plots(self): + await self.service.buy(self.identity, '萝卜', 2, 'buy') + before = await self.service.snapshot(self.identity) + ledger = await self.store.ledger(self.identity) + with self.assertRaisesRegex(FarmError, 'insufficient_stock'): + await self.service.sow(self.identity, '萝卜', 3, 'too-many') + with patch.object(SQLiteRepository, 'asset', + side_effect=sqlite3.OperationalError('injected')): + with self.assertRaises(StorageError): + await self.service.sow(self.identity, '萝卜', 2, 'sow-fail') + self.assertEqual(await self.service.snapshot(self.identity), before) + self.assertEqual(await self.store.ledger(self.identity), ledger) + receipt = await self.service.sow( + self.identity, '萝卜', 2, 'sow-fail', + ) + self.assertEqual(receipt.data['account']['experience'], 4) async def test_restart_and_cancelled_request_can_replay_result(self): entered = threading.Event()