From 2a372bff2f7d303ceb42565154ff1d0a8552b913 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:26:22 -0500 Subject: [PATCH 1/7] =?UTF-8?q?feat(currency):=20the=20Currency=20value=20?= =?UTF-8?q?type=20=E2=80=94=20copper,=20ratios,=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One integer, stored in copper. The ratios are rules and live in code; prices are content and land in M8. format() suppresses empty denominations so being broke reads as '47c', not '0g 0s 47c'. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/state/currency.gd | 44 ++++++++++++++++++++++++++ client/scripts/state/currency.gd.uid | 1 + client/tests/unit/test_currency.gd | 42 ++++++++++++++++++++++++ client/tests/unit/test_currency.gd.uid | 1 + 4 files changed, 88 insertions(+) create mode 100644 client/scripts/state/currency.gd create mode 100644 client/scripts/state/currency.gd.uid create mode 100644 client/tests/unit/test_currency.gd create mode 100644 client/tests/unit/test_currency.gd.uid diff --git a/client/scripts/state/currency.gd b/client/scripts/state/currency.gd new file mode 100644 index 0000000..4d19d0b --- /dev/null +++ b/client/scripts/state/currency.gd @@ -0,0 +1,44 @@ +class_name Currency +extends RefCounted +## Money. Everything is stored in COPPER — one integer, on GameState.purse_copper. +## The denominations exist so a bounded §6 move can name a unit (give_item(gold) +## is +10,000c); they are NOT stored buckets, so change-making never arises. +## +## The ratios are rules and live here in code. PRICES are content and live in +## /content (roadmap: "the economy is two systems"). Formatting happens only at +## the display edge — this is the one place that knows what "12g 40s 8c" means. + +const COPPER := 1 +const SILVER := 100 +const GOLD := 10000 + +const VALUES := {"copper": COPPER, "silver": SILVER, "gold": GOLD} + + +static func is_currency(item_id: String) -> bool: + return VALUES.has(item_id) + + +static func value(item_id: String) -> int: + return int(VALUES.get(item_id, 0)) + + +static func format(copper: int) -> String: + # Empty denominations are suppressed and at least one unit always shows: + # "0c", "47c", "1g 8c", "12g 40s". "0g 0s 8c" reads as a spreadsheet, not a + # purse — and being broke should read as poverty (§3). + if copper <= 0: + return "0c" + var parts: Array[String] = [] + var left := copper + var gold := left / GOLD # int division + if gold > 0: + parts.append("%dg" % gold) + left -= gold * GOLD + var silver := left / SILVER + if silver > 0: + parts.append("%ds" % silver) + left -= silver * SILVER + if left > 0: + parts.append("%dc" % left) + return " ".join(parts) diff --git a/client/scripts/state/currency.gd.uid b/client/scripts/state/currency.gd.uid new file mode 100644 index 0000000..2f792a9 --- /dev/null +++ b/client/scripts/state/currency.gd.uid @@ -0,0 +1 @@ +uid://cwnflbwacp5jf diff --git a/client/tests/unit/test_currency.gd b/client/tests/unit/test_currency.gd new file mode 100644 index 0000000..7a7c56c --- /dev/null +++ b/client/tests/unit/test_currency.gd @@ -0,0 +1,42 @@ +extends "res://addons/gut/test.gd" + + +func test_format_zero_is_copper(): + assert_eq(Currency.format(0), "0c") + + +func test_format_sub_silver(): + assert_eq(Currency.format(8), "8c") + assert_eq(Currency.format(47), "47c") + + +func test_format_exact_denominations(): + assert_eq(Currency.format(100), "1s") + assert_eq(Currency.format(10000), "1g") + + +func test_format_full_ladder(): + assert_eq(Currency.format(143), "1s 43c") + assert_eq(Currency.format(347), "3s 47c") + assert_eq(Currency.format(124008), "12g 40s 8c") + + +func test_format_skips_empty_middle_and_tail(): + assert_eq(Currency.format(10008), "1g 8c", "an empty silver place is skipped, not zero-padded") + assert_eq(Currency.format(124000), "12g 40s", "an empty copper place is skipped") + + +func test_value_of_each_denomination(): + assert_eq(Currency.value("copper"), 1) + assert_eq(Currency.value("silver"), 100) + assert_eq(Currency.value("gold"), 10000) + + +func test_non_currency_id_is_not_money(): + assert_false(Currency.is_currency("worn_shortsword")) + assert_eq(Currency.value("worn_shortsword"), 0) + + +func test_currency_ids_are_currency(): + for id in ["copper", "silver", "gold"]: + assert_true(Currency.is_currency(id), "%s is money" % id) diff --git a/client/tests/unit/test_currency.gd.uid b/client/tests/unit/test_currency.gd.uid new file mode 100644 index 0000000..11f2f13 --- /dev/null +++ b/client/tests/unit/test_currency.gd.uid @@ -0,0 +1 @@ +uid://dwpdm680q83ws From 8a71671df76a5a2e56a1110df9b7d6b823a7b7d7 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:29:10 -0500 Subject: [PATCH 2/7] feat(currency): purse_copper on GameState, with grant/take routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One int holds all money. grant/take are the sole routing point — currency ids go to the purse, everything else to inventory. Money is never an inventory row. The 'coin' stand-in in the two unrelated inventory tests becomes worn_shortsword; they were never about money. --- client/scripts/state/game_state.gd | 24 +++++++++++- client/tests/unit/test_game_state.gd | 55 ++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index fcfbb7a..e7e820d 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -8,7 +8,8 @@ var luck: int = 0 var luck_base: int = 0 var stats: Dictionary = {} # {str, dex, con, fth, mag} var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100) -var inventory: Dictionary = {} # item_id -> qty +var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper) +var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec). var revealed_topics: Dictionary = {} # topic_id -> true var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item) @@ -41,6 +42,27 @@ func remove_item(item_id: String, qty: int) -> void: inventory.erase(item_id) +func add_copper(amount: int) -> void: + purse_copper = maxi(0, purse_copper + amount) + + +func grant(item_id: String, qty: int) -> void: + # The single routing point (with take): currency lands in the purse, every + # other item in the inventory dict. Money is never an inventory row, so it + # can never surface in an inventory grid — exclusion by construction. + if Currency.is_currency(item_id): + add_copper(Currency.value(item_id) * qty) + else: + add_item(item_id, qty) + + +func take(item_id: String, qty: int) -> void: + if Currency.is_currency(item_id): + add_copper(-Currency.value(item_id) * qty) + else: + remove_item(item_id, qty) + + func mark_revealed(topic_id: String) -> void: revealed_topics[topic_id] = true diff --git a/client/tests/unit/test_game_state.gd b/client/tests/unit/test_game_state.gd index c56e7bb..d3bd06f 100644 --- a/client/tests/unit/test_game_state.gd +++ b/client/tests/unit/test_game_state.gd @@ -5,9 +5,9 @@ const GameState = preload("res://scripts/state/game_state.gd") func test_add_item_accumulates(): var s = GameState.new() - s.add_item("coin", 3) - s.add_item("coin", 2) - assert_eq(s.inventory["coin"], 5) + s.add_item("worn_shortsword", 3) + s.add_item("worn_shortsword", 2) + assert_eq(s.inventory["worn_shortsword"], 5) func test_npc_disposition_clamps(): @@ -32,11 +32,11 @@ func test_luck_descriptor_delegates(): func test_remove_item_decrements_and_floors_at_zero(): var gs = GameState.new() - gs.add_item("coin", 3) - gs.remove_item("coin", 2) - assert_eq(gs.inventory.get("coin", 0), 1) - gs.remove_item("coin", 5) - assert_eq(gs.inventory.get("coin", 0), 0) + gs.add_item("worn_shortsword", 3) + gs.remove_item("worn_shortsword", 2) + assert_eq(gs.inventory.get("worn_shortsword", 0), 1) + gs.remove_item("worn_shortsword", 5) + assert_eq(gs.inventory.get("worn_shortsword", 0), 0) func test_revealed_topics_tracked(): @@ -51,3 +51,42 @@ func test_gifts_given_tracked(): assert_false(gs.is_gift_given("amulet")) gs.mark_gift_given("amulet") assert_true(gs.is_gift_given("amulet")) + + +func test_purse_starts_empty(): + assert_eq(GameState.new().purse_copper, 0) + + +func test_grant_currency_lands_in_the_purse_not_the_inventory(): + var gs = GameState.new() + gs.grant("silver", 2) + assert_eq(gs.purse_copper, 200) + assert_eq(gs.inventory.size(), 0, "money is never an inventory row") + + +func test_grant_non_currency_lands_in_the_inventory(): + var gs = GameState.new() + gs.grant("worn_shortsword", 1) + assert_eq(gs.inventory.get("worn_shortsword", 0), 1) + assert_eq(gs.purse_copper, 0) + + +func test_take_currency_spends_from_the_purse(): + var gs = GameState.new() + gs.grant("gold", 1) + gs.take("copper", 5) + assert_eq(gs.purse_copper, 9995) + + +func test_take_currency_clamps_at_zero(): + var gs = GameState.new() + gs.grant("copper", 47) + gs.take("gold", 1) + assert_eq(gs.purse_copper, 0, "the purse never goes negative") + + +func test_take_non_currency_removes_from_the_inventory(): + var gs = GameState.new() + gs.grant("worn_shortsword", 2) + gs.take("worn_shortsword", 1) + assert_eq(gs.inventory.get("worn_shortsword", 0), 1) From c106dbbd3735e4dcb71a2bb17dd19125e63ddb7c Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:32:14 -0500 Subject: [PATCH 3/7] =?UTF-8?q?feat(currency):=20money=20moves=20through?= =?UTF-8?q?=20the=20bounded=20vocabulary=20(=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit give_item(gold) is +10,000c and does NOT mark gifts_given — that gate is a global one-shot keyed by item id, so marking money would let an NPC pay you exactly once per campaign. accept_item() is offered only for the denominations the purse can actually pay. The eight moves do not grow. MoveValidator is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/npc/move_applier.gd | 7 ++--- client/scripts/npc/npc_content.gd | 10 ++++++- client/tests/unit/test_move_applier.gd | 37 +++++++++++++++++++++++--- client/tests/unit/test_npc_content.gd | 37 ++++++++++++++++++++++++-- 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/client/scripts/npc/move_applier.gd b/client/scripts/npc/move_applier.gd index 1aeaa0c..5b22c64 100644 --- a/client/scripts/npc/move_applier.gd +++ b/client/scripts/npc/move_applier.gd @@ -23,10 +23,11 @@ static func apply(moves: Array, game_state, canon_log, content_db, npc_id: Strin game_state.mark_revealed(str(args[0])) "give_item": var i: String = str(args[0]) - game_state.add_item(i, 1) - game_state.mark_gift_given(i) + game_state.grant(i, 1) + if not Currency.is_currency(i): + game_state.mark_gift_given(i) # the one-shot gate is for real items only "accept_item": - game_state.remove_item(str(args[0]), 1) + game_state.take(str(args[0]), 1) "adjust_disposition": var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA) var cur: int = int(game_state.npc_dispositions.get(npc_id, 0)) diff --git a/client/scripts/npc/npc_content.gd b/client/scripts/npc/npc_content.gd index 6c5bc4a..c4752d8 100644 --- a/client/scripts/npc/npc_content.gd +++ b/client/scripts/npc/npc_content.gd @@ -21,12 +21,20 @@ static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Arra moves.append("reveal(%s)" % t) for i in caps.get("giveable_items", []): - if not game_state.is_gift_given(i): + # Currency is exempt from the one-shot gift gate — an NPC can pay you twice. + if Currency.is_currency(i) or not game_state.is_gift_given(i): moves.append("give_item(%s)" % i) for item_id in game_state.inventory.keys(): if int(game_state.inventory[item_id]) > 0: moves.append("accept_item(%s)" % item_id) + # Money is not in the inventory dict, so it needs its own offer. Only the + # denominations the purse can actually pay: an NPC cannot ask a party holding + # 47c for a gold coin. + for denom in Currency.VALUES: + if game_state.purse_copper >= Currency.value(denom): + moves.append("accept_item(%s)" % denom) + moves.append_array(UNIVERSAL) return moves diff --git a/client/tests/unit/test_move_applier.gd b/client/tests/unit/test_move_applier.gd index 2a653c9..3fd940e 100644 --- a/client/tests/unit/test_move_applier.gd +++ b/client/tests/unit/test_move_applier.gd @@ -44,9 +44,40 @@ func test_become_hostile_sets_floor(): func test_accept_item_removes_from_inventory(): var gs = GameState.new() - gs.add_item("coin", 2) - MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn") - assert_eq(gs.inventory.get("coin", 0), 1) + gs.add_item("worn_shortsword", 2) + MoveApplier.apply([_m("accept_item", ["worn_shortsword"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.inventory.get("worn_shortsword", 0), 1) + + +func test_give_item_currency_pays_the_purse(): + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 10000) + assert_eq(gs.inventory.size(), 0, "money is never an inventory row") + + +func test_give_item_currency_is_not_a_one_shot_gift(): + # gifts_given is a GLOBAL one-shot keyed by item id. If money were marked, an + # NPC could hand over coin exactly once, ever, for the whole campaign. + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn") + MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 20000) + assert_false(gs.is_gift_given("gold"), "currency is exempt from the one-shot gate") + + +func test_give_item_real_item_still_marks_the_one_shot_gate(): + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["amulet"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.inventory.get("amulet", 0), 1) + assert_true(gs.is_gift_given("amulet")) + + +func test_accept_item_currency_spends_the_purse(): + var gs = GameState.new() + gs.grant("gold", 1) + MoveApplier.apply([_m("accept_item", ["silver"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 9900) func test_refuse_and_end_are_noops(): diff --git a/client/tests/unit/test_npc_content.gd b/client/tests/unit/test_npc_content.gd index e4f7bf1..4914477 100644 --- a/client/tests/unit/test_npc_content.gd +++ b/client/tests/unit/test_npc_content.gd @@ -39,6 +39,39 @@ func test_revealed_topic_drops_out(): func test_accept_item_offered_per_held_item(): var gs = GameState.new() - gs.add_item("coin", 2) + gs.add_item("worn_shortsword", 2) var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new()) - assert_true("accept_item(coin)" in moves) + assert_true("accept_item(worn_shortsword)" in moves) + + +func test_a_broke_party_is_asked_for_nothing(): + var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new()) + for denom in ["copper", "silver", "gold"]: + assert_false("accept_item(%s)" % denom in moves, "an empty purse cannot pay a %s" % denom) + + +func test_accept_item_denomination_gated_on_affordability(): + var gs = GameState.new() + gs.grant("copper", 347) + var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new()) + assert_true("accept_item(copper)" in moves) + assert_true("accept_item(silver)" in moves) + assert_false("accept_item(gold)" in moves, "347c cannot pay a gold coin") + + +func test_a_gold_coin_is_asked_for_once_affordable(): + var gs = GameState.new() + gs.grant("gold", 1) + var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new()) + assert_true("accept_item(gold)" in moves) + + +func test_currency_gift_is_offered_repeatedly(): + var npc := _fenn() + npc["capabilities"]["giveable_items"] = ["copper", "amulet"] + var gs = GameState.new() + gs.mark_gift_given("copper") + gs.mark_gift_given("amulet") + var moves = NpcContent.available_moves(npc, gs, CanonLog.new()) + assert_true("give_item(copper)" in moves, "money is not a one-shot gift") + assert_false("give_item(amulet)" in moves, "a real item still is") From a9d9eedb7e71a05a9a3615a493faa01f5360ecf7 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:36:08 -0500 Subject: [PATCH 4/7] feat(currency): copper/silver/gold replace the coin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three denominations are move tokens, not stored buckets — there is one integer, so change-making never arises. The deserter is down to 47c: under a silver, so he cannot cover a bed and the player sees it on turn one. A parity test guards the code-side ratios against the content-side ids. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/newgame/new_game.gd | 5 +++-- client/tests/unit/test_content_db.gd | 19 ++++++++++++++++++- client/tests/unit/test_new_game.gd | 3 ++- content/origins/deserter.json | 2 +- content/world/items/coin.json | 1 - content/world/items/copper.json | 1 + content/world/items/gold.json | 1 + content/world/items/silver.json | 1 + 8 files changed, 27 insertions(+), 6 deletions(-) delete mode 100644 content/world/items/coin.json create mode 100644 content/world/items/copper.json create mode 100644 content/world/items/gold.json create mode 100644 content/world/items/silver.json diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index c06b196..3f52e46 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -61,9 +61,10 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary var qd: Dictionary = world.quest(quest_id) log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", ""))) - # inventory grants -> game state (NOT the log). Charter §2. + # inventory grants -> game state (NOT the log). Charter §2. grant() routes + # currency to the purse; everything else to the inventory dict. for grant in origin.get("inventory_grants", []): - state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0))) + state.grant(grant.get("item_id", ""), int(grant.get("qty", 0))) return {"ok": true, "errors": [], "log": log, "state": state} diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index a1b3c42..a7955b3 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -18,7 +18,24 @@ func test_loads_world_ids(): assert_true(db.has_location("greywater_docks")) assert_true(db.has_quest("find_the_ledger")) assert_true(db.has_item("worn_shortsword")) - assert_true(db.has_item("coin")) + assert_true(db.has_item("copper")) + assert_true(db.has_item("silver")) + assert_true(db.has_item("gold")) + assert_false(db.has_item("coin"), "the undifferentiated coin is gone") + + +func test_currency_items_agree_with_the_code(): + # The denomination ids exist twice — in code (Currency.VALUES, because the + # ratios are rules) and in content (slot: "currency"). Guard the two against + # drifting apart, since nothing at runtime reconciles them. + var content_ids: Array = [] + for id in db.items: + if db.items[id].get("slot", "") == "currency": + content_ids.append(id) + content_ids.sort() + var code_ids: Array = Currency.VALUES.keys() + code_ids.sort() + assert_eq(content_ids, code_ids) func test_companions_are_brannoc_and_cadwyn(): diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index b529b1f..18049f4 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -40,8 +40,9 @@ func test_numeric_luck_absent_from_log(): func test_inventory_and_dispo_land_in_state_not_log(): var res := _build({"name": "Aldric", "class_id": "sellsword"}) - assert_eq(res["state"].inventory.get("coin", 0), 3) assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1) + assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin") + assert_false("copper" in res["state"].inventory, "money is never an inventory row") assert_false("inventory" in res["log"].to_dict()) diff --git a/content/origins/deserter.json b/content/origins/deserter.json index d7e0b47..f68cc1a 100644 --- a/content/origins/deserter.json +++ b/content/origins/deserter.json @@ -15,7 +15,7 @@ "disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 }, "inventory_grants": [ { "item_id": "worn_shortsword", "qty": 1 }, - { "item_id": "coin", "qty": 3 } + { "item_id": "copper", "qty": 47 } ], "start_quest_id": "find_the_ledger", "build_constraints": { diff --git a/content/world/items/coin.json b/content/world/items/coin.json deleted file mode 100644 index eeb0ea3..0000000 --- a/content/world/items/coin.json +++ /dev/null @@ -1 +0,0 @@ -{ "id": "coin", "name": "coin", "slot": "currency" } diff --git a/content/world/items/copper.json b/content/world/items/copper.json new file mode 100644 index 0000000..9e4c32e --- /dev/null +++ b/content/world/items/copper.json @@ -0,0 +1 @@ +{ "id": "copper", "name": "copper coin", "slot": "currency" } diff --git a/content/world/items/gold.json b/content/world/items/gold.json new file mode 100644 index 0000000..1caff29 --- /dev/null +++ b/content/world/items/gold.json @@ -0,0 +1 @@ +{ "id": "gold", "name": "gold coin", "slot": "currency" } diff --git a/content/world/items/silver.json b/content/world/items/silver.json new file mode 100644 index 0000000..5f3d119 --- /dev/null +++ b/content/world/items/silver.json @@ -0,0 +1 @@ +{ "id": "silver", "name": "silver coin", "slot": "currency" } From 6c496d3ed8ac4e2e6389ea2c20d16f5d6855e305 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:39:25 -0500 Subject: [PATCH 5/7] feat(currency): the command-bar purse reads in denominations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed placeholder was a flat 1240 gold — 12.4 million copper under the real model, which is 1,240 plot points in the command bar. It reseeds to 347c, a party carrying silver and no gold: '◈ 3s 47c'. This is the only call site of Currency.format() — the display edge. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scenes/shell/MainWindowShell.tscn | 4 ++-- client/scripts/ui/shell/main_window_shell.gd | 6 +++--- client/scripts/ui/shell/shell_state.gd | 4 ++-- client/tests/unit/test_shell_state.gd | 8 +++++++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn index f38f727..2845bf3 100644 --- a/client/scenes/shell/MainWindowShell.tscn +++ b/client/scenes/shell/MainWindowShell.tscn @@ -154,10 +154,10 @@ layout_mode = 2 theme_type_variation = &"Mono" text = "MP 18 / 30" -[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"] +[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"] layout_mode = 2 theme_type_variation = &"Mono" -text = "◈ 1240 gp" +text = "◈ 3s 47c" [node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"] layout_mode = 2 diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd index fcdce62..1e0920c 100644 --- a/client/scripts/ui/shell/main_window_shell.gd +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -29,7 +29,7 @@ var _http: HTTPRequest @onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens @onready var _hp: Label = $Split/World/CommandBar/Vitals/HP @onready var _mp: Label = $Split/World/CommandBar/Vitals/MP -@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold +@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse @onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables @@ -93,8 +93,8 @@ func _side_color(e: TurnEntry) -> Color: func _bind_vitals() -> void: _hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]] _mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]] - _gold.text = "◈ %d gp" % _state.vitals["gold"] - _gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) + _purse.text = "◈ %s" % Currency.format(_state.vitals["purse_copper"]) + _purse.add_theme_color_override("font_color", Palette.GOLD_BRIGHT) func _bind_consumables() -> void: diff --git a/client/scripts/ui/shell/shell_state.gd b/client/scripts/ui/shell/shell_state.gd index 27caa65..89b1e7c 100644 --- a/client/scripts/ui/shell/shell_state.gd +++ b/client/scripts/ui/shell/shell_state.gd @@ -4,7 +4,7 @@ extends RefCounted ## 2a; later systems (combat vitals, inventory consumables) become the writers. ## No Luck/LCK value lives here — it is never surfaced (§7). -var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0} +var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0} var turn_order: Array[TurnEntry] = [] var consumables: Array = [] var round_label: String = "" @@ -19,7 +19,7 @@ func toggle_dock() -> bool: static func seed() -> ShellState: var s := ShellState.new() - s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240} + s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "purse_copper": 347} s.turn_order = [ TurnEntry.new("EL", 17, &"you", true, false), TurnEntry.new("DW", 12, &"ally"), diff --git a/client/tests/unit/test_shell_state.gd b/client/tests/unit/test_shell_state.gd index cfea412..2319320 100644 --- a/client/tests/unit/test_shell_state.gd +++ b/client/tests/unit/test_shell_state.gd @@ -16,7 +16,13 @@ func test_seed_vitals(): assert_eq(s.vitals["hp_max"], 60) assert_eq(s.vitals["mp"], 18) assert_eq(s.vitals["mp_max"], 30) - assert_eq(s.vitals["gold"], 1240) + assert_eq(s.vitals["purse_copper"], 347) + assert_false("gold" in s.vitals, "the flat gold placeholder is gone") + + +func test_seed_purse_renders_as_denominations(): + var s := ShellState.seed() + assert_eq(Currency.format(s.vitals["purse_copper"]), "3s 47c") func test_seed_turn_order(): From 8e1238c316f3eced7b1d301d6d242efacfb73b70 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:47:19 -0500 Subject: [PATCH 6/7] fix(currency): cap repeated currency move tags at one per reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TagExtractor collects every [MOVE:] tag occurrence with no dedupe, and MoveValidator is a pure membership test against available_moves, so N copies of one legal move all pass validation. MoveApplier then looped and applied every copy. A model could therefore pick an arbitrary amount of money in unary by repeating one tag: [MOVE: accept_item(copper)] x47 against a 47c purse drains it to 0; [MOVE: give_item(gold)] x10 mints +100,000c. The eight-move vocabulary (§6) takes no quantity argument precisely so the AI cannot choose a number — this closed that gap by another door and was a §2 breach (AI owns text, never state). MoveApplier.apply() now tracks currency moves (give_item/accept_item on a denomination) already applied within one call, keyed on name+denom, and skips repeats. Non-currency moves are untouched — repeated give_item(amulet) still yields multiple copies, which stays bounded by the pre-existing cross-reply gifts_given gate and is a separate, already-reported issue. move_validator.gd and tag_extractor.gd are untouched. Also adds the Finding-2 test the currency spec (§7) named but never delivered: after GameState.grant("gold", 1), inventory stays empty, so accept_item(gold) in available_moves comes only from the affordability loop, never double-offered via the inventory.keys() loop. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/scripts/npc/move_applier.gd | 11 +++++ client/tests/unit/test_move_applier.gd | 57 ++++++++++++++++++++++++++ client/tests/unit/test_npc_content.gd | 15 +++++++ 3 files changed, 83 insertions(+) diff --git a/client/scripts/npc/move_applier.gd b/client/scripts/npc/move_applier.gd index 5b22c64..d9e87f6 100644 --- a/client/scripts/npc/move_applier.gd +++ b/client/scripts/npc/move_applier.gd @@ -11,9 +11,20 @@ const HOSTILE_FLOOR := -100 static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void: + # A currency move (give_item/accept_item on a denomination) applies AT MOST + # ONCE per reply. Without this, a model repeating one tag N times would pick + # an arbitrary amount in unary — the AI choosing a number is a §2 breach. + # Real-item duplicates are unaffected; that stays bounded by gifts_given. + var currency_applied_this_reply := {} # "name(denom)" -> true for move in moves: var name: String = move.get("name", "") var args: Array = move.get("args", []) + if name in ["give_item", "accept_item"] and not args.is_empty() \ + and Currency.is_currency(str(args[0])): + var currency_key := "%s(%s)" % [name, str(args[0])] + if currency_applied_this_reply.has(currency_key): + continue + currency_applied_this_reply[currency_key] = true match name: "offer_quest": var q: String = str(args[0]) diff --git a/client/tests/unit/test_move_applier.gd b/client/tests/unit/test_move_applier.gd index 3fd940e..542c18c 100644 --- a/client/tests/unit/test_move_applier.gd +++ b/client/tests/unit/test_move_applier.gd @@ -84,3 +84,60 @@ func test_refuse_and_end_are_noops(): var gs = GameState.new() MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn") assert_eq(gs.npc_dispositions.size(), 0) + + +# --- Finding 1: a repeated currency move tag in ONE reply must not let the AI +# pick an arbitrary amount by unary repetition (§2 breach). A currency move +# (give_item/accept_item on a denomination) applies AT MOST ONCE per apply() +# call, keyed on move name + denomination. + +func test_repeated_accept_item_silver_applies_once(): + var gs = GameState.new() + gs.purse_copper = 10000 + MoveApplier.apply([_m("accept_item", ["silver"]), _m("accept_item", ["silver"])], + gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 9900, "second accept_item(silver) tag must be a no-op") + + +func test_repeated_give_item_gold_applies_once(): + var gs = GameState.new() + var moves: Array = [] + for i in range(10): + moves.append(_m("give_item", ["gold"])) + MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 10000, "nine of the ten give_item(gold) tags must be no-ops") + + +func test_repeated_accept_item_copper_applies_once(): + var gs = GameState.new() + gs.purse_copper = 47 + var moves: Array = [] + for i in range(47): + moves.append(_m("accept_item", ["copper"])) + MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 46, "46 of the 47 accept_item(copper) tags must be no-ops") + + +func test_different_currency_moves_both_still_apply(): + # Proves dedup is keyed per (move name + denomination), not per reply as a whole. + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["silver"])], + gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.purse_copper, 9900) + + +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). + var gs = GameState.new() + MoveApplier.apply([_m("give_item", ["amulet"]), _m("give_item", ["amulet"]), _m("give_item", ["amulet"])], + gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.inventory.get("amulet", 0), 3, "non-currency give_item is out of scope for this fix") + + +func test_repeated_non_currency_accept_item_still_applies_each_time(): + var gs = GameState.new() + gs.add_item("worn_shortsword", 5) + MoveApplier.apply([_m("accept_item", ["worn_shortsword"]), _m("accept_item", ["worn_shortsword"])], + gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.inventory.get("worn_shortsword", 0), 3, "non-currency accept_item is out of scope for this fix") diff --git a/client/tests/unit/test_npc_content.gd b/client/tests/unit/test_npc_content.gd index 4914477..d88306b 100644 --- a/client/tests/unit/test_npc_content.gd +++ b/client/tests/unit/test_npc_content.gd @@ -66,6 +66,21 @@ func test_a_gold_coin_is_asked_for_once_affordable(): assert_true("accept_item(gold)" in moves) +func test_currency_never_arrives_via_the_inventory_loop(): + # §7 of the currency spec: currency never enters the inventory dict, so the + # only accept_item() in available_moves comes from the affordability + # loop — never a double-offer via the inventory.keys() loop. + var gs = GameState.new() + gs.grant("gold", 1) + assert_true(gs.inventory.is_empty(), "currency must never be written into inventory") + var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new()) + var gold_offers := 0 + for m in moves: + if m == "accept_item(gold)": + gold_offers += 1 + assert_eq(gold_offers, 1, "accept_item(gold) must be offered exactly once") + + func test_currency_gift_is_offered_repeatedly(): var npc := _fenn() npc["capabilities"]["giveable_items"] = ["copper", "amulet"] From a4955432903808bfcc6c11264517281a47454712 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:49:11 -0500 Subject: [PATCH 7/7] 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) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- client/tests/unit/test_move_applier.gd | 18 ++++++++++++++++++ .../2026-07-12-currency-copper-design.md | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) 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