[P0][CR-B1a] 从 HUD 丢金币再捡回,引擎账(存档)凭空增加金币 #521

Closed
opened 2026-09-29 06:43:55 +00:00 by troytt · 1 comment
Owner

来源:#516|优先级 P0|评审编号 B1a(B1 的子问题,已复现)|基线 e475c2e

问题描述

金币有两本账:

  • engine.bag.gold:进存档;
  • hudManager.inventory.gold:界面上显示的数。

从背包面板丢金币时,只扣了 HUD 的账,引擎却在地上凭空生成了一堆金币。捡回来时,两本账又各加一笔。

结果:HUD 显示的数字不变,引擎账(也就是存档里的那本)每丢一次,就多出一次丢掉的金额。玩家只要反复"丢 → 捡",就能让存档里的金币一直涨到背包上限(clvl × 10000)。

证据(调用链)

  1. HUD 扣账:背包面板丢金币时,this.inventory.dropGold(amt) 扣 HUD 的账,然后回调 onDropGoldToGround(hud-manager.ts:1118-1122)。
  2. 转交引擎:回调直接调用 engine.dropGold(...)(toolbar-chat-ui.ts:605-607)。
  3. 引擎凭空生成:engine.dropGold 只在地上创建金币堆,不扣 engine.bag.gold(engine.ts:1530)。
  4. 捡回时双记:engine.pickupGold(engine.ts:1573)给 engine.bag 记账(入账在 engine.ts:1587、engine.ts:1616),调用方再给 HUD 记一笔(mouse-controller.ts:458-464、skill-caster.ts:373-379)。
  5. 存档只认引擎:存档只序列化 engine.bag(engine.ts:1178-1182)。

复现

复现脚本 probe-gold-mint.mts(点击展开)

把脚本保存为仓库根目录下的 probe-gold-mint.mts,然后执行 npx tsx probe-gold-mint.mts。

脚本直接调用 HUD 丢金币路径里引擎侧的两步:engine.dropGold,然后 engine.pickupGold。

// Probe: engine-side gold ledger on the HUD "drop gold" -> pick up round trip.
// HUD path (hud-manager.ts ~L1119): inventory.dropGold(amt) debits the HUD ledger,
// then onDropGoldToGround -> engine.dropGold(amount). This probe checks whether
// engine.dropGold debits engine.bag, and what engine.pickupGold credits.
// Run from repo root: npx tsx <this file>
import { GameEngine } from './src/game/engine.ts'

const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
const engine = new GameEngine(terrain as any, {
  spawn: { x: 100, y: 100 },
  stats: [],
  monsterCount: 0,
  skills: [],
  npcDefs: [],
  combatOptions: {
    playerSpeed: 100,
    playerReach: 50,
    playerCooldownTicks: 10,
    playerDamage: 5,
    playerManaPerAttack: 0,
    respawnTicks: 100,
  },
} as any)

const p = engine.world.player
console.log(`engine player vitals: hp=${p.hp}/${p.maxHp} mana=${p.mana}/${p.maxMana} level=${p.level ?? '(unset)'}`)

for (let round = 1; round <= 3; round++) {
  const before = engine.bag.gold
  const pile = engine.dropGold(1000, p.x, p.y)
  const afterDrop = engine.bag.gold
  const res = pile ? engine.pickupGold(pile.id) : { success: false, amount: 0 }
  const afterPickup = engine.bag.gold
  console.log(
    `round ${round}: bag.gold before=${before} afterDrop=${afterDrop} ` +
    `pickup=${JSON.stringify(res)} afterPickup=${afterPickup}`,
  )
}

输出:

engine player vitals: hp=60/60 mana=30/30 level=1
round 1: bag.gold before=0 afterDrop=0 pickup={"success":true,"amount":1000,"remaining":0} afterPickup=1000
round 2: bag.gold before=1000 afterDrop=1000 pickup={"success":true,"amount":1000,"remaining":0} afterPickup=2000
round 3: bag.gold before=2000 afterDrop=2000 pickup={"success":true,"amount":1000,"remaining":0} afterPickup=3000

afterDrop 始终等于 before,说明丢金币没有扣引擎的账;每轮净增 1000。

根因

#520:金币没有唯一的归属方。"丢金币"这个跨越前后端的功能,只在 HUD 一侧扣了账。

修复指南

根治要靠 #520,让金币只存在于引擎。在那之前可以先止血:

  1. 引擎先扣款:GameEngine.dropGold(amount, x, y) 改为先从 this.bag.gold 扣款。
    • 余额不足时返回 null 或抛错。
    • 地上金币堆的金额等于实际扣掉的金额。
  2. HUD 只发命令:HUD 丢金币只调用 engine.dropGold,数字从引擎读取;不再调用 inventory.dropGold 自己扣账。
  3. 区分"制造"和"转移":
    • 引擎里制造金币的入口(怪物掉落、任务奖励)和转移金币的入口(丢弃、拾取、交易)分开命名。
    • 转移入口必须保持总量守恒:丢弃或拾取前后,bag.gold 加上地面金币总额不变。给这类入口加上断言。

验收标准

  • 复现脚本改写为回归测试:3 轮"丢 1000 再捡回"之后,engine.bag.gold 仍等于初始值。
  • 余额不足时丢金币失败,地上不产生金币堆。
  • 守恒测试:丢弃或拾取前后,bag.gold 加地面金币总额不变。
  • 在界面上丢金币再捡回,HUD 与引擎的数字一致;存档、读档后仍然一致。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #520:根治方案。
  • #522:金币堆的删除同样是按距离匹配的。
> 来源:#516|优先级 P0|评审编号 B1a(B1 的子问题,已复现)|基线 `e475c2e` ## 问题描述 金币有两本账: - `engine.bag.gold`:进存档; - `hudManager.inventory.gold`:界面上显示的数。 从背包面板丢金币时,只扣了 HUD 的账,引擎却在地上**凭空**生成了一堆金币。捡回来时,两本账又各加一笔。 结果:HUD 显示的数字不变,引擎账(也就是存档里的那本)每丢一次,就多出一次丢掉的金额。玩家只要反复"丢 → 捡",就能让存档里的金币一直涨到背包上限(`clvl × 10000`)。 ## 证据(调用链) 1. **HUD 扣账**:背包面板丢金币时,`this.inventory.dropGold(amt)` 扣 HUD 的账,然后回调 `onDropGoldToGround`([hud-manager.ts:1118-1122](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/ui/hud-manager.ts#L1118-L1122))。 2. **转交引擎**:回调直接调用 `engine.dropGold(...)`([toolbar-chat-ui.ts:605-607](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/toolbar-chat-ui.ts#L605-L607))。 3. **引擎凭空生成**:`engine.dropGold` 只在地上创建金币堆,**不扣 `engine.bag.gold`**([engine.ts:1530](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1530))。 4. **捡回时双记**:`engine.pickupGold`([engine.ts:1573](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1573))给 `engine.bag` 记账(入账在 [engine.ts:1587](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1587)、[engine.ts:1616](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1616)),调用方再给 HUD 记一笔([mouse-controller.ts:458-464](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/frontend/mouse-controller.ts#L458-L464)、[skill-caster.ts:373-379](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/scene/backend/skill-caster.ts#L373-L379))。 5. **存档只认引擎**:存档只序列化 `engine.bag`([engine.ts:1178-1182](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/engine.ts#L1178-L1182))。 ## 复现 <details> <summary>复现脚本 probe-gold-mint.mts(点击展开)</summary> 把脚本保存为仓库根目录下的 `probe-gold-mint.mts`,然后执行 `npx tsx probe-gold-mint.mts`。 脚本直接调用 HUD 丢金币路径里引擎侧的两步:`engine.dropGold`,然后 `engine.pickupGold`。 ```ts // Probe: engine-side gold ledger on the HUD "drop gold" -> pick up round trip. // HUD path (hud-manager.ts ~L1119): inventory.dropGold(amt) debits the HUD ledger, // then onDropGoldToGround -> engine.dropGold(amount). This probe checks whether // engine.dropGold debits engine.bag, and what engine.pickupGold credits. // Run from repo root: npx tsx <this file> import { GameEngine } from './src/game/engine.ts' const terrain = { widthPx: 1000, heightPx: 1000, overlap: () => 0 } const engine = new GameEngine(terrain as any, { spawn: { x: 100, y: 100 }, stats: [], monsterCount: 0, skills: [], npcDefs: [], combatOptions: { playerSpeed: 100, playerReach: 50, playerCooldownTicks: 10, playerDamage: 5, playerManaPerAttack: 0, respawnTicks: 100, }, } as any) const p = engine.world.player console.log(`engine player vitals: hp=${p.hp}/${p.maxHp} mana=${p.mana}/${p.maxMana} level=${p.level ?? '(unset)'}`) for (let round = 1; round <= 3; round++) { const before = engine.bag.gold const pile = engine.dropGold(1000, p.x, p.y) const afterDrop = engine.bag.gold const res = pile ? engine.pickupGold(pile.id) : { success: false, amount: 0 } const afterPickup = engine.bag.gold console.log( `round ${round}: bag.gold before=${before} afterDrop=${afterDrop} ` + `pickup=${JSON.stringify(res)} afterPickup=${afterPickup}`, ) } ``` </details> 输出: ```text engine player vitals: hp=60/60 mana=30/30 level=1 round 1: bag.gold before=0 afterDrop=0 pickup={"success":true,"amount":1000,"remaining":0} afterPickup=1000 round 2: bag.gold before=1000 afterDrop=1000 pickup={"success":true,"amount":1000,"remaining":0} afterPickup=2000 round 3: bag.gold before=2000 afterDrop=2000 pickup={"success":true,"amount":1000,"remaining":0} afterPickup=3000 ``` `afterDrop` 始终等于 `before`,说明丢金币没有扣引擎的账;每轮净增 1000。 ## 根因 #520:金币没有唯一的归属方。"丢金币"这个跨越前后端的功能,只在 HUD 一侧扣了账。 ## 修复指南 根治要靠 #520,让金币只存在于引擎。在那之前可以先止血: 1. **引擎先扣款**:`GameEngine.dropGold(amount, x, y)` 改为先从 `this.bag.gold` 扣款。 - 余额不足时返回 `null` 或抛错。 - 地上金币堆的金额等于实际扣掉的金额。 2. **HUD 只发命令**:HUD 丢金币只调用 `engine.dropGold`,数字从引擎读取;不再调用 `inventory.dropGold` 自己扣账。 3. **区分"制造"和"转移"**: - 引擎里制造金币的入口(怪物掉落、任务奖励)和转移金币的入口(丢弃、拾取、交易)分开命名。 - 转移入口必须保持总量守恒:丢弃或拾取前后,`bag.gold` 加上地面金币总额不变。给这类入口加上断言。 ## 验收标准 - [ ] 复现脚本改写为回归测试:3 轮"丢 1000 再捡回"之后,`engine.bag.gold` 仍等于初始值。 - [ ] 余额不足时丢金币失败,地上不产生金币堆。 - [ ] 守恒测试:丢弃或拾取前后,`bag.gold` 加地面金币总额不变。 - [ ] 在界面上丢金币再捡回,HUD 与引擎的数字一致;存档、读档后仍然一致。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #520:根治方案。 - #522:金币堆的删除同样是按距离匹配的。
Author
Owner

已在提交 206b59e(fix(gold): prevent HUD drop-gold and world pickup gold duplication (#521))中完成修复,并在 5de27f8 中补充端到端与 136 关全量验证套件。

修复与验证摘要

  • 修复内容:将 HUD 丢弃金币与世界地面拾取金币统一路由至 GameEngine 唯一金库(dropPlayerGold / dropGold 与 pickupGold),严格保证 initialGold - N + N === initialGold 守恒,并按 1.13c clvl * 10,000(getInventoryGoldLimit)执行背包金币上限与超额余量落盘。
  • 专项回归测试:tests/p0-521-gold-duplication.test.ts
  • 总体验收门禁:npm run build 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
已在提交 [`206b59e`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/206b59e72d3b04c2bb6654dbc0e787c77e178a88)(`fix(gold): prevent HUD drop-gold and world pickup gold duplication (#521)`)中完成修复,并在 [`5de27f8`](https://git.projectdiablo2.cn/troytt/diablo2-web/commit/5de27f8177d43524444de7aa301cc470c4f0844e) 中补充端到端与 136 关全量验证套件。 ### 修复与验证摘要 - **修复内容**:将 HUD 丢弃金币与世界地面拾取金币统一路由至 `GameEngine` 唯一金库(`dropPlayerGold` / `dropGold` 与 `pickupGold`),严格保证 `initialGold - N + N === initialGold` 守恒,并按 1.13c `clvl * 10,000`(`getInventoryGoldLimit`)执行背包金币上限与超额余量落盘。 - **专项回归测试**:[`tests/p0-521-gold-duplication.test.ts`](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/5de27f8177d43524444de7aa301cc470c4f0844e/tests/p0-521-gold-duplication.test.ts) - **总体验收门禁**:`npm run build` 0 错误、14 个 P0/E2E 测试套件(190/190 用例)100% 通过、全仓 Vitest 6,460/6,460 通过、136/136 关无头浏览器巡检通过。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#521
No description provided.