# 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`: ```gdscript "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: ```gdscript 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). ```gdscript 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: ```gdscript 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`:** ```gdscript "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()` 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()` 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 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`: ```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_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 --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.