The tag extractor returns every occurrence of a move tag and the validator is
a pure membership test, so MoveApplier applied duplicates. A model repeating a
tag N times therefore picked an amount in unary — the AI choosing a number,
which is the §2 breach the bounded vocabulary exists to prevent.
The currency fix (8e1238c) closed this for denominations only. Generalizing it
to every move that names a thing covers real items too — three give_item(amulet)
tags are one amulet, not three — and lets the currency special case be deleted
rather than kept alongside a second mechanism. ID_MOVES is reused from
MoveValidator instead of restated.
Also caps adjust_disposition on the reply's NET swing. MAX_DELTA was clamped
per tag, so three +15s moved standing by 45 — precisely the wholesale swing its
own comment says it forbids. Applied in emission order, so a later
become_hostile still floors what came before it.
offer_quest and reveal needed no change: add_quest self-guards on has_quest and
mark_revealed is idempotent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
15 KiB
Currency — copper-based money with real denominations
Date: 2026-07-12
Status: approved, ready for planning
Charter: §2 (code owns state), §3 (grit), §6 (the eight bounded moves), §18 (git)
Roadmap: content/roadmap.md — "The economy is two systems"; this is the lands before M7 item.
Supersedes: the currency paragraph of docs/superpowers/specs/2026-07-12-content-track-design.md §4.
1. What this is
Money stops being an undifferentiated coin and becomes copper, with denominations.
1 gold = 100 silver = 10,000 copper
1 silver = 100 copper
Everything is stored in copper. One integer. Formatting happens only at the
display edge (12g 40s 8c).
Gold is scary. A peasant's whole life is copper, a mercenary's fee is silver, a gold coin is a plot point. The party counting coppers for an inn bed is §3 grit.
This work is the value type, the denominations, the purse, and the display edge. The price table is content and belongs to M8's BOM line. Not here.
2. What is actually there today (verified at 9e8fc55)
content/world/items/coin.json={ "id": "coin", "name": "coin", "slot": "currency" }. Items undercontent/world/items/are hand-written JSON, not emitted bycontent_build. The directory holds exactlycoin.json,worn_shortsword.json,README.md.- There is no purse.
GameState(client/scripts/state/game_state.gd) hasinventory(item_id -> qty) and no money field. Money rides as thecoininventory item. _state.vitals["gold"]in the shell is not real state — it is a hardcoded seed placeholder (client/scripts/ui/shell/shell_state.gd:22,"gold": 1240), asserted byclient/tests/unit/test_shell_state.gd:19, rendered bymain_window_shell.gd:96as"◈ %d gp", with authored placeholder text"◈ 1240 gp"inMainWindowShell.tscn:157.- Six live consumers of the
coinid:content/origins/deserter.json(inventory_grants),test_game_state.gd,test_new_game.gd,test_content_db.gd,test_npc_content.gd,test_move_applier.gd. - Item
slotis read by no code. There is no encumbrance or weight system. Nothing is designed around either.
3. The decision that shaped this: money cannot be an inventory item
The obvious model — one item id copper, qty = the copper amount, purse as an
accessor over inventory — is wrong, and §6 is what kills it.
client/scripts/npc/move_applier.gd:24-29:
"give_item":
var i: String = str(args[0])
game_state.add_item(i, 1)
game_state.mark_gift_given(i)
"accept_item":
game_state.remove_item(str(args[0]), 1)
- The money moves carry no quantity.
give_item(x)adds exactly 1. Sogive_item(copper)is an NPC handing over one copper. Money could never move a meaningful amount through the bounded vocabulary. gifts_givenis a global one-shot.mark_gift_given(i)is keyed by item id with no NPC scoping, andnpc_content.gd:25only offersgive_item(i)whennot is_gift_given(i). Once any NPC gives item X, no NPC can ever give X again. A singlecopperid would be givable exactly once, for one copper, for the campaign.
The charter constraint — an NPC must still be able to give or take money through
accept_item / give_item, and the eight moves do not grow — is precisely the
constraint that model fails.
A quantity argument (accept_item(copper, 50)) was rejected. It keeps the list at
eight, but it hands the model an unbounded integer to invent. That is the §2 line.
§6's whole design is that the model picks from a closed vocabulary.
4. The design
4.1 The purse is one integer
GameState gains:
var purse_copper: int = 0
That is the sole store of money. It is not in inventory. It is not in the
canon log (§11 — neither is inventory or numeric Luck; state stays out of the log).
4.2 Denominations are move tokens, not buckets
Three content items — copper (1c), silver (100c), gold (10,000c) — each
slot: "currency". They are never inventory rows. They exist so a bounded move can
name a unit:
give_item(gold)→purse_copper += 10000accept_item(silver)→purse_copper -= 100
Three ids does not mean three stored quantities. There is one integer. Denominations are multipliers on a move, not buckets in a purse — so the change-making problem ("does the player hold 143 copper or 1s 43c?") never arises. There is only ever 143.
This also lands §3 in the mechanics: give_item(gold) is a gold coin, singular, a plot
point. Brannoc gets accept_item(copper).
4.3 Currency is excluded from the inventory grid by construction
Money is purse-only — it never appears as an item tile. Because currency routes to
purse_copper and never enters the inventory dict, it cannot show up in a grid. No
filter is needed; exclusion is structural.
slot: "currency" therefore stays a content annotation, not a code branch. Its
correctness is guarded by a parity test (§4.7) rather than by a runtime filter.
4.4 Currency — the value type
client/scripts/state/currency.gd. Follows the Luck precedent exactly: class_name,
extends RefCounted, all-static, constants at the top. No autoload (the project has
none).
class_name Currency
extends RefCounted
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
static func value(item_id: String) -> int # 0 for a non-currency id
static func format(copper: int) -> String
The ratios live in code, not in the item JSON. The roadmap's split is "the loop is
code, the numbers are content" — and the ratios are the money system's rules, not a
price. Prices change; 1g = 100s does not. format() needs the ratios in code
regardless, so a value_copper field in the JSON would be a second source of truth for
the same fact — the thing §2 exists to prevent.
4.5 The display rule
format() suppresses empty denominations and always shows at least one unit.
| copper | renders |
|---|---|
0 |
0c |
8 |
8c |
100 |
1s |
143 |
1s 43c |
347 |
3s 47c |
10000 |
1g |
10008 |
1g 8c |
124000 |
12g 40s |
124008 |
12g 40s 8c |
8c reads as poverty. 0g 0s 8c reads as a spreadsheet. §3 takes the first.
Negative input is not a valid purse state; format() is specified only for copper >= 0.
4.6 Routing lives in exactly one place
Two call sites move items into and out of state (new_game.gd:65,
move_applier.gd:24-29). To keep one routing site, GameState gains:
func grant(item_id: String, qty: int) -> void # currency -> purse_copper; else add_item
func take(item_id: String, qty: int) -> void # currency -> purse_copper; else remove_item
add_item / remove_item stay as the raw inventory primitives.
MoveApplier:
"give_item":
var i: String = str(args[0])
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.take(str(args[0]), 1)
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 existed for non-currency moves and predated this work.
Closed in a follow-up (fix/duplicate-move-tags), which generalized the rule — any
move that names a thing applies at most once per reply — and so deleted the currency
special-case rather than leaving two mechanisms side by side. That follow-up also found
MAX_DELTA was clamped per tag, so three [ADJUST_DISPOSITION: +15] tags moved
standing by 45; the cap is now on the reply's net swing, which is what the constant's own
comment always claimed. (offer_quest and reveal turned out to be safe already —
add_quest self-guards and mark_revealed is idempotent.)
NpcContent.available_moves:
accept_item(<denom>)is offered for each denomination the player can actually pay:purse_copper >= Currency.value(denom). An NPC cannot ask for a gold coin from a party holding 47c.give_item(<denom>)fromcapabilities.giveable_itemsskips thegifts_givengate — currency is not a one-shot gift.- The existing
accept_itemloop overinventory.keys()is unchanged and now naturally never yields a currency id.
new_game.gd: inventory_grants calls state.grant(id, qty). No origin-schema
change — inventory_grants already carries item_id + qty, and
docs/schemas/origin.schema.json is additionalProperties: false, so adding a money
field would have been a contract amendment. Routing avoids it.
content_db.unresolved_refs() needs no change: the currency ids are real items, so
has_item() resolves them.
4.7 Parity guard
Because the denomination ids exist in code (Currency.VALUES) and in content
(content/world/items/*.json), a test asserts they agree: every id in
Currency.VALUES is an item in the DB, and every item with slot == "currency" is a
key in Currency.VALUES. Drift is caught at test time, not at runtime.
5. Content changes
- Delete
content/world/items/coin.json. - Add
copper.json,silver.json,gold.json:
{ "id": "copper", "name": "copper coin", "slot": "currency" }
{ "id": "silver", "name": "silver coin", "slot": "currency" }
{ "id": "gold", "name": "gold coin", "slot": "currency" }
-
content/origins/deserter.json—{ "item_id": "coin", "qty": 3 }becomes{ "item_id": "copper", "qty": 47 }→ the purse reads47c.His situation line is "Down to your last coin and owed a favour you can't repay." 47 is under a silver, so he cannot cover a bed and the player sees that on turn one. It is uneven, because round numbers read as an allowance and odd ones read as what is left. Three coppers is funnier and unplayable.
-
client/scripts/ui/shell/shell_state.gd:22— the seed placeholder"gold": 1240becomes"purse_copper": 347→◈ 3s 47c. A party carrying silver and no gold. A literal port would have been 1,240 gold = 12.4 million copper — 1,240 plot points in the command bar.
6. Client changes
| File | Change |
|---|---|
client/scripts/state/currency.gd |
new — the value type (§4.4) |
client/scripts/state/game_state.gd |
purse_copper; grant() / take() routing (§4.6) |
client/scripts/npc/move_applier.gd |
give_item / accept_item route through grant/take; gifts_given marked for non-currency only |
client/scripts/npc/npc_content.gd |
denomination accept_item gated on affordability; give_item currency skips the one-shot gate |
client/scripts/newgame/new_game.gd |
inventory_grants → state.grant() |
client/scripts/ui/shell/shell_state.gd |
vitals["gold"] → vitals["purse_copper"], seed 347 |
client/scripts/ui/shell/main_window_shell.gd |
_gold.text = "◈ %s" % Currency.format(...) |
client/scenes/shell/MainWindowShell.tscn |
authored placeholder text "◈ 1240 gp" → "◈ 3s 47c" (ADR 0001 — the layout and its authored text live in the .tscn) |
MoveValidator is unchanged — accept_item / give_item are already ID_MOVES,
and legality is membership in available_moves, which NpcContent now computes
correctly for currency.
The server is unchanged. available_moves is computed client-side and sent; no
prompt or /npc/speak contract changes.
7. Tests (TDD — these are written first)
test_currency.gd (new) — format() across the whole table in §4.5, including the
zero, the sub-silver, and both skipped-middle cases (1g 8c, 12g 40s); value();
is_currency() false for worn_shortsword.
test_game_state.gd — purse_copper defaults to 0; grant("silver", 2) → 200
and leaves inventory empty; grant("worn_shortsword", 1) → inventory, purse untouched;
take("copper", 5); take clamps at 0, never negative.
test_move_applier.gd — give_item(gold) → purse_copper == 10000 and
is_gift_given("gold") == false; give_item(gold) twice → 20000 (not one-shot);
accept_item(silver) → -100; non-currency give_item/accept_item behave exactly as
before.
test_npc_content.gd — accept_item(gold) absent at 347c, present at 10000c;
accept_item(copper) present at 47c; give_item(copper) still offered after a prior
give_item(copper); no currency id ever arrives via the inventory.keys() loop.
test_new_game.gd — the deserter starts with purse_copper == 47 and no coin
or copper row in inventory.
test_content_db.gd — has_item() for copper/silver/gold; coin is gone;
the §4.7 parity assertion.
test_shell_state.gd — seed vitals["purse_copper"] == 347.
8. Verification
- Client GUT suite green.
PYTHONPATH=tools python3 -m content_build --checkgreen.- Generated
content/world/**andcontent/server/**byte-identical apart from the intentionalitems/change.content/world/items/is hand-written, butcontent/world/**does have a client consumer (content_db.gdvianpc_harness.gd, plustest_content_db.gd) — the Duncarrow lesson. Deletingcoin.jsonis a client change and is covered by the tests above.
9. Out of scope
- The price table. What a shortsword costs, a night at the inn, an ale, a quest purse. That is content and belongs to M8's BOM line.
- The shop loop — buy/sell, the ~40% fence rate, disabled-CTA reasons. M8.
- The purse in the canon log. The DM does not currently know the party is broke;
neither
inventorynor numeric Luck enters the log. Whether "the party is destitute" should reach the Narrator is a real question — deliberately deferred, not decided here. - Encumbrance / coin weight. No such system exists. Not inventing one.