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

347 lines
15 KiB
Markdown

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