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:
323
docs/superpowers/specs/2026-07-12-currency-copper-design.md
Normal file
323
docs/superpowers/specs/2026-07-12-currency-copper-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user