Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-12-currency-copper-design.md
Phillip Tarrant acc5a7b2a2 fix(npc): a move applies at most once per reply
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
2026-07-12 19:11:40 -05:00

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 under content/world/items/ are hand-written JSON, not emitted by content_build. The directory holds exactly coin.json, worn_shortsword.json, README.md.
  • There is no purse. GameState (client/scripts/state/game_state.gd) has inventory (item_id -> qty) and no money field. Money rides as the coin inventory 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 by client/tests/unit/test_shell_state.gd:19, rendered by main_window_shell.gd:96 as "◈ %d gp", with authored placeholder text "◈ 1240 gp" in MainWindowShell.tscn:157.
  • Six live consumers of the coin id: 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 slot is 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)
  1. The money moves carry no quantity. give_item(x) adds exactly 1. So give_item(copper) is an NPC handing over one copper. Money could never move a meaningful amount through the bounded vocabulary.
  2. gifts_given is a global one-shot. mark_gift_given(i) is keyed by item id with no NPC scoping, and npc_content.gd:25 only offers give_item(i) when not is_gift_given(i). Once any NPC gives item X, no NPC can ever give X again. A single copper id 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 += 10000
  • accept_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>) from capabilities.giveable_items skips the gifts_given gate — currency is not a one-shot gift.
  • The existing accept_item loop over inventory.keys() is unchanged and now naturally never yields a currency id.

new_game.gd: inventory_grants calls state.grant(id, qty). No origin-schema changeinventory_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 reads 47c.

    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": 1240 becomes "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_grantsstate.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 unchangedaccept_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.gdpurse_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.gdgive_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.gdaccept_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.gdhas_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 --check green.
  • Generated content/world/** and content/server/** byte-identical apart from the intentional items/ change. content/world/items/ is hand-written, but content/world/** does have a client consumer (content_db.gd via npc_harness.gd, plus test_content_db.gd) — the Duncarrow lesson. Deleting coin.json is 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 inventory nor 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.