docs(spec): copper-based currency with real denominations

Money stops being a flat `coin` and becomes copper, stored as a single
int, formatted only at the display edge.

The approved model (1g = 100s = 10,000c, store everything in copper) said
money should stay an inventory item under one `copper` id. It can't. §6 is
what kills it: `give_item(x)` adds exactly 1 and carries no quantity, and
`gifts_given` is a global one-shot keyed by item id — so a single `copper`
id would be givable once, for one copper, for the whole campaign.

So the purse is one int on GameState, and the three denominations are
content items that exist only to let a bounded move name a unit —
give_item(gold) is +10,000c. Three ids, not three buckets: the
change-making problem never arises because there is only ever one number.
A quantity arg on the move was rejected — it hands the model an unbounded
integer to invent, which is the §2 line.

Currency routes to the purse and never enters `inventory`, so it can't
appear in an inventory grid by construction. No filter needed.

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 17:52:13 -05:00
parent 9e8fc55a44
commit 37ec1bb1be

View File

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