From 8a71671df76a5a2e56a1110df9b7d6b823a7b7d7 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 18:29:10 -0500 Subject: [PATCH] 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)