diff --git a/docs/superpowers/specs/2026-07-12-currency-copper-design.md b/docs/superpowers/specs/2026-07-12-currency-copper-design.md new file mode 100644 index 0000000..e48249a --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-currency-copper-design.md @@ -0,0 +1,323 @@ +# 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. + +**`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.