diff --git a/client/tests/unit/test_move_applier.gd b/client/tests/unit/test_move_applier.gd index 542c18c..09e2176 100644 --- a/client/tests/unit/test_move_applier.gd +++ b/client/tests/unit/test_move_applier.gd @@ -126,6 +126,24 @@ func test_different_currency_moves_both_still_apply(): assert_eq(gs.purse_copper, 9900) +func test_dedup_key_is_denomination_not_just_move_name(): + # Two DIFFERENT denominations under the same move name must both land. A key + # of "give_item" alone would swallow the silver. + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["gold"]), _m("give_item", ["silver"])], + gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 10100) + + +func test_dedup_key_is_move_name_not_just_denomination(): + # The same denomination under two DIFFERENT move names must both land. A key + # of "gold" alone would swallow the accept, leaving 10000 instead of 0. + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["gold"])], + gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 0) + + func test_repeated_non_currency_give_item_still_applies_each_time(): # Pins the scope line: real-item duplicate handling is UNCHANGED by this fix # (it stays bounded only by the cross-reply gifts_given gate, reported separately). diff --git a/docs/superpowers/specs/2026-07-12-currency-copper-design.md b/docs/superpowers/specs/2026-07-12-currency-copper-design.md index e48249a..9ce21ea 100644 --- a/docs/superpowers/specs/2026-07-12-currency-copper-design.md +++ b/docs/superpowers/specs/2026-07-12-currency-copper-design.md @@ -196,6 +196,25 @@ func take(item_id: String, qty: int) -> void # currency -> purse_copper; else `take` clamps the purse at `0`. The validator already gates affordability (§4.7); the clamp is defence in depth, not the mechanism. +**A currency move applies at most once per reply.** `TagExtractor` returns *every* tag +occurrence, `MoveValidator` is a pure membership test, and `MoveApplier` loops — so +without this, a model repeating `[MOVE: accept_item(copper)]` forty-seven times would +drain a 47c purse, and ten `give_item(gold)` tags would mint 100,000c. That is the model +choosing an amount **in unary** — the §2 breach that rejecting a quantity argument was +meant to prevent. `MoveApplier` therefore keys currency moves on `name(denomination)` +and drops repeats within a reply. + +The residual ceiling is deliberate: one reply may still combine *distinct* denominations +(`give_item(gold)` + `give_item(silver)` + `give_item(copper)` = +10,101c). That is a +model selecting a subset of a closed, six-element, state-gated set — an NPC handing over +a gold, a silver and a copper is a legal sentence — not inventing an integer. Unary +repetition was what made it unbounded, and that is closed. + +**The same duplicate-tag path exists for non-currency moves and predates this work** +(three `give_item(amulet)` tags yield three amulets; a repeated `offer_quest` can +double-add a quest). It is bounded *across* replies by `gifts_given` / `has_quest`, but +not *within* one. Deliberately left alone here — it is not a currency bug. **Open.** + **`NpcContent.available_moves`:** - `accept_item()` is offered for each denomination the player can actually