feat(currency): purse_copper on GameState, with grant/take routing

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.
This commit is contained in:
2026-07-12 18:29:10 -05:00
parent 2a372bff2f
commit 8a71671df7
2 changed files with 70 additions and 9 deletions

View File

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

View File

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