Merge feat/currency into dev

Money becomes copper-based with real denominations (1g = 100s = 10,000c),
stored as one integer and formatted only at the display edge.

Money is not an inventory item: give_item/accept_item carry no quantity and
gifts_given is a global one-shot, so a single 'copper' id would be givable
once, for one copper, per campaign. The denominations are move tokens instead
— give_item(gold) is +10,000c — and currency routes to the purse, never the
inventory dict, so it cannot surface in an inventory grid by construction.

Prices remain content and remain M8.

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:53:26 -05:00
23 changed files with 401 additions and 32 deletions

View File

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

View File

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

View File

@@ -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])
@@ -23,10 +34,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))

View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://cwnflbwacp5jf

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
uid://dwpdm680q83ws

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)

View File

@@ -44,12 +44,118 @@ 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():
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_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).
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")

View File

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

View File

@@ -39,6 +39,54 @@ 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_never_arrives_via_the_inventory_loop():
# §7 of the currency spec: currency never enters the inventory dict, so the
# only accept_item(<denom>) 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"]
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")

View File

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

View File

@@ -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": {

View File

@@ -1 +0,0 @@
{ "id": "coin", "name": "coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "copper", "name": "copper coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "gold", "name": "gold coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "silver", "name": "silver coin", "slot": "currency" }

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