test(currency): pin both halves of the dedup key

The existing test varied the move name and the denomination together, so a
name-only or denom-only key would have passed it too. Two assertions nail it:
give_item(gold)+give_item(silver) kills a name-only key, and
give_item(gold)+accept_item(gold) kills a denom-only key.

The spec now records the per-reply ceiling as a decision rather than an
accident, and flags the pre-existing non-currency duplicate-tag path as open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
This commit is contained in:
2026-07-12 18:49:11 -05:00
parent 8e1238c316
commit a495543290
2 changed files with 37 additions and 0 deletions

View File

@@ -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).

View File

@@ -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(<denom>)` is offered for each denomination the player can actually