docs(plan): copper-based currency, six tasks
TDD, one test cycle per task, green suite at every boundary. Task 4 carries the content swap and the generated-tree check (the Duncarrow lesson). 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:
861
docs/superpowers/plans/2026-07-12-currency-copper.md
Normal file
861
docs/superpowers/plans/2026-07-12-currency-copper.md
Normal file
@@ -0,0 +1,861 @@
|
||||
# Copper-Based Currency Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the flat `coin` item with copper-based money — a single `purse_copper` integer on `GameState`, three denominations that exist only as bounded-move tokens, and formatting at the display edge.
|
||||
|
||||
**Architecture:** All money is stored as one integer, in copper. `Currency` (a static value type, shaped exactly like the existing `Luck`) holds the ratios and the formatter. Three content items — `copper`/`silver`/`gold` — are never inventory rows; they exist so a §6 bounded move can name a unit (`give_item(gold)` = +10,000c). `GameState.grant()` / `take()` are the single routing point: currency ids go to the purse, everything else to the `inventory` dict. Because currency never enters `inventory`, it cannot appear in an inventory grid — exclusion is structural, not a filter.
|
||||
|
||||
**Tech Stack:** Godot 4.7, GDScript, GUT (headless via `client/run_tests.sh`). Content is hand-written JSON under `content/`, checked by `content_build`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-12-currency-copper-design.md` — read it before starting. Everything below implements it.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Charter §2 — code owns state.** The AI never picks a money amount. The denominations are a closed vocabulary; a quantity argument on a move was explicitly rejected.
|
||||
- **Charter §6 — the eight moves do not grow.** `give_item` / `accept_item` keep their existing single-id signature. `MoveValidator` is **not** modified.
|
||||
- **Ratios live in code, prices live in content.** `Currency.SILVER = 100`, `Currency.GOLD = 10000`. The item JSONs carry **no** `value_copper` field — that would be a second source of truth.
|
||||
- **The price table is out of scope.** It is M8 content. Do not invent prices.
|
||||
- **Charter §18 — git.** This is code, so it lands on `feat/currency` off `dev`. Never commit to `master`. Do not merge to `dev` until the human confirms.
|
||||
- **The full client suite must be green at the end of every task.** Run from `client/`: `./run_tests.sh`
|
||||
- **The content build must stay green.** Run from the repo root: `PYTHONPATH=tools python3 -m content_build --check`
|
||||
- **Generated `content/world/**` and `content/server/**` stay byte-identical** except for the intentional `content/world/items/` change (Task 4). `content/world/**` has a client consumer (`content_db.gd`) — this is the Duncarrow lesson.
|
||||
- **Editor-first (ADR 0001).** `MainWindowShell.tscn` holds the authored node tree *and its placeholder text*. Edit the `.tscn`; never build nodes in `_ready()`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `client/scripts/state/currency.gd` | **new.** The value type: ratios, `is_currency`, `value`, `format`. Pure — no state, no I/O. |
|
||||
| `client/scripts/state/game_state.gd` | Owns `purse_copper`. `grant()` / `take()` are the sole routing point between purse and inventory. |
|
||||
| `client/scripts/npc/move_applier.gd` | Routes the two money moves through `grant`/`take`; marks `gifts_given` for real items only. |
|
||||
| `client/scripts/npc/npc_content.gd` | Offers `accept_item(<denom>)` only when the purse can pay; exempts currency from the one-shot gift gate. |
|
||||
| `client/scripts/newgame/new_game.gd` | `inventory_grants` route through `state.grant()`. |
|
||||
| `client/scripts/ui/shell/shell_state.gd` | Seed placeholder becomes `purse_copper`. |
|
||||
| `client/scripts/ui/shell/main_window_shell.gd` | The display edge — the only place `Currency.format()` is called. |
|
||||
| `client/scenes/shell/MainWindowShell.tscn` | The authored purse label (node name + placeholder text). |
|
||||
| `content/world/items/{copper,silver,gold}.json` | **new.** Denomination tokens. `coin.json` is deleted. |
|
||||
| `content/origins/deserter.json` | Grants `47c` instead of 3 `coin`. |
|
||||
|
||||
**Existing tests use `"coin"` as a generic stand-in item id** (in `test_game_state`, `test_move_applier`, `test_npc_content`). Those are not money tests — they just needed *an* item. As each is touched, the stand-in becomes `"worn_shortsword"` (a real, non-currency item), so no test is left asserting on an id that no longer exists.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Branch
|
||||
|
||||
- [ ] **Step 1: Cut the branch off `dev`**
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git status --porcelain # expect: clean
|
||||
git checkout -b feat/currency
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `Currency` value type
|
||||
|
||||
**Files:**
|
||||
- Create: `client/scripts/state/currency.gd`
|
||||
- Test: `client/tests/unit/test_currency.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: global class `Currency` with `const COPPER := 1`, `const SILVER := 100`, `const GOLD := 10000`, `const VALUES := {"copper": COPPER, "silver": SILVER, "gold": GOLD}`, and three statics — `is_currency(item_id: String) -> bool`, `value(item_id: String) -> int` (returns `0` for a non-currency id), `format(copper: int) -> String`. Every later task depends on these exact names.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `client/tests/unit/test_currency.gd`. `Currency` is referenced by global `class_name` (the convention in `test_shell_state.gd`, which uses `ShellState`/`TurnEntry` bare); `run_tests.sh` runs `godot --headless --import` first so a brand-new `class_name` resolves.
|
||||
|
||||
```gdscript
|
||||
extends "res://addons/gut/test.gd"
|
||||
|
||||
|
||||
func test_format_zero_is_copper():
|
||||
assert_eq(Currency.format(0), "0c")
|
||||
|
||||
|
||||
func test_format_sub_silver():
|
||||
assert_eq(Currency.format(8), "8c")
|
||||
assert_eq(Currency.format(47), "47c")
|
||||
|
||||
|
||||
func test_format_exact_denominations():
|
||||
assert_eq(Currency.format(100), "1s")
|
||||
assert_eq(Currency.format(10000), "1g")
|
||||
|
||||
|
||||
func test_format_full_ladder():
|
||||
assert_eq(Currency.format(143), "1s 43c")
|
||||
assert_eq(Currency.format(347), "3s 47c")
|
||||
assert_eq(Currency.format(124008), "12g 40s 8c")
|
||||
|
||||
|
||||
func test_format_skips_empty_middle_and_tail():
|
||||
assert_eq(Currency.format(10008), "1g 8c", "an empty silver place is skipped, not zero-padded")
|
||||
assert_eq(Currency.format(124000), "12g 40s", "an empty copper place is skipped")
|
||||
|
||||
|
||||
func test_value_of_each_denomination():
|
||||
assert_eq(Currency.value("copper"), 1)
|
||||
assert_eq(Currency.value("silver"), 100)
|
||||
assert_eq(Currency.value("gold"), 10000)
|
||||
|
||||
|
||||
func test_non_currency_id_is_not_money():
|
||||
assert_false(Currency.is_currency("worn_shortsword"))
|
||||
assert_eq(Currency.value("worn_shortsword"), 0)
|
||||
|
||||
|
||||
func test_currency_ids_are_currency():
|
||||
for id in ["copper", "silver", "gold"]:
|
||||
assert_true(Currency.is_currency(id), "%s is money" % id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run from `client/`:
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_currency.gd
|
||||
```
|
||||
|
||||
Expected: FAIL — the parser cannot resolve the identifier `Currency` (it does not exist yet).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
Create `client/scripts/state/currency.gd`. Follow the `Luck` precedent in the same directory: `class_name`, `extends RefCounted`, all-static, tunable constants at the top, no autoload (the project has none).
|
||||
|
||||
```gdscript
|
||||
class_name Currency
|
||||
extends RefCounted
|
||||
## Money. Everything is stored in COPPER — one integer, on GameState.purse_copper.
|
||||
## The denominations exist so a bounded §6 move can name a unit (give_item(gold)
|
||||
## is +10,000c); they are NOT stored buckets, so change-making never arises.
|
||||
##
|
||||
## The ratios are rules and live here in code. PRICES are content and live in
|
||||
## /content (roadmap: "the economy is two systems"). Formatting happens only at
|
||||
## the display edge — this is the one place that knows what "12g 40s 8c" means.
|
||||
|
||||
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:
|
||||
return VALUES.has(item_id)
|
||||
|
||||
|
||||
static func value(item_id: String) -> int:
|
||||
return int(VALUES.get(item_id, 0))
|
||||
|
||||
|
||||
static func format(copper: int) -> String:
|
||||
# Empty denominations are suppressed and at least one unit always shows:
|
||||
# "0c", "47c", "1g 8c", "12g 40s". "0g 0s 8c" reads as a spreadsheet, not a
|
||||
# purse — and being broke should read as poverty (§3).
|
||||
if copper <= 0:
|
||||
return "0c"
|
||||
var parts: Array[String] = []
|
||||
var left := copper
|
||||
var gold := left / GOLD # int division
|
||||
if gold > 0:
|
||||
parts.append("%dg" % gold)
|
||||
left -= gold * GOLD
|
||||
var silver := left / SILVER
|
||||
if silver > 0:
|
||||
parts.append("%ds" % silver)
|
||||
left -= silver * SILVER
|
||||
if left > 0:
|
||||
parts.append("%dc" % left)
|
||||
return " ".join(parts)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_currency.gd
|
||||
```
|
||||
|
||||
Expected: PASS, 8 tests.
|
||||
|
||||
- [ ] **Step 5: Run the full suite**
|
||||
|
||||
```bash
|
||||
./run_tests.sh
|
||||
```
|
||||
|
||||
Expected: all green. Nothing else references `Currency` yet.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/state/currency.gd client/tests/unit/test_currency.gd
|
||||
git commit -m "feat(currency): the Currency value type — copper, ratios, formatting
|
||||
|
||||
One integer, stored in copper. The ratios are rules and live in code; prices
|
||||
are content and land in M8. format() suppresses empty denominations so being
|
||||
broke reads as '47c', not '0g 0s 47c'."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: The purse on `GameState`
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/scripts/state/game_state.gd`
|
||||
- Test: `client/tests/unit/test_game_state.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Currency.is_currency()`, `Currency.value()` from Task 1.
|
||||
- Produces: `GameState.purse_copper: int` (defaults to `0`); `GameState.add_copper(amount: int) -> void` (clamps at `0`); `GameState.grant(item_id: String, qty: int) -> void` and `GameState.take(item_id: String, qty: int) -> void` — **the sole routing point**. Tasks 3 and 4 call `grant`/`take` and nothing else. `add_item` / `remove_item` remain as the raw inventory primitives and keep their current behaviour.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `client/tests/unit/test_game_state.gd`, first **replace the `"coin"` stand-in** in the two existing tests — it is about to stop being an item, and these tests are not about money. Change `test_add_item_accumulates` and `test_remove_item_decrements_and_floors_at_zero` to use `"worn_shortsword"`:
|
||||
|
||||
```gdscript
|
||||
func test_add_item_accumulates():
|
||||
var s = GameState.new()
|
||||
s.add_item("worn_shortsword", 3)
|
||||
s.add_item("worn_shortsword", 2)
|
||||
assert_eq(s.inventory["worn_shortsword"], 5)
|
||||
|
||||
|
||||
func test_remove_item_decrements_and_floors_at_zero():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("worn_shortsword", 3)
|
||||
gs.remove_item("worn_shortsword", 2)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
gs.remove_item("worn_shortsword", 5)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 0)
|
||||
```
|
||||
|
||||
Then append the new purse tests to the same file:
|
||||
|
||||
```gdscript
|
||||
func test_purse_starts_empty():
|
||||
assert_eq(GameState.new().purse_copper, 0)
|
||||
|
||||
|
||||
func test_grant_currency_lands_in_the_purse_not_the_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.grant("silver", 2)
|
||||
assert_eq(gs.purse_copper, 200)
|
||||
assert_eq(gs.inventory.size(), 0, "money is never an inventory row")
|
||||
|
||||
|
||||
func test_grant_non_currency_lands_in_the_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.grant("worn_shortsword", 1)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
assert_eq(gs.purse_copper, 0)
|
||||
|
||||
|
||||
func test_take_currency_spends_from_the_purse():
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
gs.take("copper", 5)
|
||||
assert_eq(gs.purse_copper, 9995)
|
||||
|
||||
|
||||
func test_take_currency_clamps_at_zero():
|
||||
var gs = GameState.new()
|
||||
gs.grant("copper", 47)
|
||||
gs.take("gold", 1)
|
||||
assert_eq(gs.purse_copper, 0, "the purse never goes negative")
|
||||
|
||||
|
||||
func test_take_non_currency_removes_from_the_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.grant("worn_shortsword", 2)
|
||||
gs.take("worn_shortsword", 1)
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_game_state.gd
|
||||
```
|
||||
|
||||
Expected: FAIL — `purse_copper`, `grant`, and `take` do not exist on `GameState`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
In `client/scripts/state/game_state.gd`, add the field next to `inventory`:
|
||||
|
||||
```gdscript
|
||||
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
|
||||
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).
|
||||
```
|
||||
|
||||
Add these methods below `remove_item`:
|
||||
|
||||
```gdscript
|
||||
func add_copper(amount: int) -> void:
|
||||
purse_copper = maxi(0, purse_copper + amount)
|
||||
|
||||
|
||||
func grant(item_id: String, qty: int) -> void:
|
||||
# The single routing point (with take): currency lands in the purse, every
|
||||
# other item in the inventory dict. Money is never an inventory row, so it
|
||||
# can never surface in an inventory grid — exclusion by construction.
|
||||
if Currency.is_currency(item_id):
|
||||
add_copper(Currency.value(item_id) * qty)
|
||||
else:
|
||||
add_item(item_id, qty)
|
||||
|
||||
|
||||
func take(item_id: String, qty: int) -> void:
|
||||
if Currency.is_currency(item_id):
|
||||
add_copper(-Currency.value(item_id) * qty)
|
||||
else:
|
||||
remove_item(item_id, qty)
|
||||
```
|
||||
|
||||
The clamp in `add_copper` is defence in depth. Affordability is gated upstream by `NpcContent` (Task 3) — the clamp is not the mechanism.
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_game_state.gd
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full suite**
|
||||
|
||||
```bash
|
||||
./run_tests.sh
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/state/game_state.gd client/tests/unit/test_game_state.gd
|
||||
git commit -m "feat(currency): purse_copper on GameState, with grant/take routing
|
||||
|
||||
One int holds all money. grant/take are the sole routing point — currency ids
|
||||
go to the purse, everything else to inventory. Money is never an inventory row.
|
||||
|
||||
The 'coin' stand-in in the two unrelated inventory tests becomes
|
||||
worn_shortsword; they were never about money."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: The two money moves
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/scripts/npc/move_applier.gd:22-29`
|
||||
- Modify: `client/scripts/npc/npc_content.gd:23-30`
|
||||
- Test: `client/tests/unit/test_move_applier.gd`
|
||||
- Test: `client/tests/unit/test_npc_content.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GameState.grant()` / `take()` / `purse_copper` (Task 2); `Currency.is_currency()` / `value()` / `VALUES` (Task 1).
|
||||
- Produces: nothing new. `MoveValidator` is **not** touched — `give_item` / `accept_item` are already in its `ID_MOVES`, and legality is membership in `available_moves`, which `NpcContent` now computes correctly for money.
|
||||
|
||||
This is the task the whole design exists for. Two facts about the current code drive it:
|
||||
|
||||
1. `give_item(x)` adds exactly **1** and carries no quantity — so a money id must be a *denomination*, not a unit count.
|
||||
2. `mark_gift_given(i)` is a **global one-shot** keyed by item id, and `npc_content.gd:25` only offers `give_item(i)` when `not is_gift_given(i)` — so currency must be **exempt** from that gate, or an NPC could hand over money exactly once per campaign.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `client/tests/unit/test_move_applier.gd`, replace `test_accept_item_removes_from_inventory` (it uses the `"coin"` stand-in) and append the money tests:
|
||||
|
||||
```gdscript
|
||||
func test_accept_item_removes_from_inventory():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("worn_shortsword", 2)
|
||||
MoveApplier.apply([_m("accept_item", ["worn_shortsword"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("worn_shortsword", 0), 1)
|
||||
|
||||
|
||||
func test_give_item_currency_pays_the_purse():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 10000)
|
||||
assert_eq(gs.inventory.size(), 0, "money is never an inventory row")
|
||||
|
||||
|
||||
func test_give_item_currency_is_not_a_one_shot_gift():
|
||||
# gifts_given is a GLOBAL one-shot keyed by item id. If money were marked, an
|
||||
# NPC could hand over coin exactly once, ever, for the whole campaign.
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
MoveApplier.apply([_m("give_item", ["gold"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 20000)
|
||||
assert_false(gs.is_gift_given("gold"), "currency is exempt from the one-shot gate")
|
||||
|
||||
|
||||
func test_give_item_real_item_still_marks_the_one_shot_gate():
|
||||
var gs = GameState.new()
|
||||
MoveApplier.apply([_m("give_item", ["amulet"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.inventory.get("amulet", 0), 1)
|
||||
assert_true(gs.is_gift_given("amulet"))
|
||||
|
||||
|
||||
func test_accept_item_currency_spends_the_purse():
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
MoveApplier.apply([_m("accept_item", ["silver"])], gs, CanonLog.new(), _content(), "fenn")
|
||||
assert_eq(gs.purse_copper, 9900)
|
||||
```
|
||||
|
||||
In `client/tests/unit/test_npc_content.gd`, replace `test_accept_item_offered_per_held_item` (the `"coin"` stand-in) and append:
|
||||
|
||||
```gdscript
|
||||
func test_accept_item_offered_per_held_item():
|
||||
var gs = GameState.new()
|
||||
gs.add_item("worn_shortsword", 2)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(worn_shortsword)" in moves)
|
||||
|
||||
|
||||
func test_a_broke_party_is_asked_for_nothing():
|
||||
var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new())
|
||||
for denom in ["copper", "silver", "gold"]:
|
||||
assert_false("accept_item(%s)" % denom in moves, "an empty purse cannot pay a %s" % denom)
|
||||
|
||||
|
||||
func test_accept_item_denomination_gated_on_affordability():
|
||||
var gs = GameState.new()
|
||||
gs.grant("copper", 347)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(copper)" in moves)
|
||||
assert_true("accept_item(silver)" in moves)
|
||||
assert_false("accept_item(gold)" in moves, "347c cannot pay a gold coin")
|
||||
|
||||
|
||||
func test_a_gold_coin_is_asked_for_once_affordable():
|
||||
var gs = GameState.new()
|
||||
gs.grant("gold", 1)
|
||||
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
|
||||
assert_true("accept_item(gold)" in moves)
|
||||
|
||||
|
||||
func test_currency_gift_is_offered_repeatedly():
|
||||
var npc := _fenn()
|
||||
npc["capabilities"]["giveable_items"] = ["copper", "amulet"]
|
||||
var gs = GameState.new()
|
||||
gs.mark_gift_given("copper")
|
||||
gs.mark_gift_given("amulet")
|
||||
var moves = NpcContent.available_moves(npc, gs, CanonLog.new())
|
||||
assert_true("give_item(copper)" in moves, "money is not a one-shot gift")
|
||||
assert_false("give_item(amulet)" in moves, "a real item still is")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_move_applier.gd
|
||||
./run_tests.sh -gtest=res://tests/unit/test_npc_content.gd
|
||||
```
|
||||
|
||||
Expected: FAIL — `give_item(gold)` currently calls `add_item("gold", 1)` and marks the gift, and no denomination is ever offered as an `accept_item`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
In `client/scripts/npc/move_applier.gd`, replace the two money branches:
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
In `client/scripts/npc/npc_content.gd`, change the `giveable_items` loop and add the denomination loop after the inventory loop:
|
||||
|
||||
```gdscript
|
||||
for i in caps.get("giveable_items", []):
|
||||
# Currency is exempt from the one-shot gift gate — an NPC can pay you twice.
|
||||
if Currency.is_currency(i) or not game_state.is_gift_given(i):
|
||||
moves.append("give_item(%s)" % i)
|
||||
|
||||
for item_id in game_state.inventory.keys():
|
||||
if int(game_state.inventory[item_id]) > 0:
|
||||
moves.append("accept_item(%s)" % item_id)
|
||||
|
||||
# Money is not in the inventory dict, so it needs its own offer. Only the
|
||||
# denominations the purse can actually pay: an NPC cannot ask a party holding
|
||||
# 47c for a gold coin.
|
||||
for denom in Currency.VALUES:
|
||||
if game_state.purse_copper >= Currency.value(denom):
|
||||
moves.append("accept_item(%s)" % denom)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_move_applier.gd
|
||||
./run_tests.sh -gtest=res://tests/unit/test_npc_content.gd
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full suite**
|
||||
|
||||
```bash
|
||||
./run_tests.sh
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/npc/move_applier.gd client/scripts/npc/npc_content.gd \
|
||||
client/tests/unit/test_move_applier.gd client/tests/unit/test_npc_content.gd
|
||||
git commit -m "feat(currency): money moves through the bounded vocabulary (§6)
|
||||
|
||||
give_item(gold) is +10,000c and does NOT mark gifts_given — that gate is a
|
||||
global one-shot keyed by item id, so marking money would let an NPC pay you
|
||||
exactly once per campaign. accept_item(<denom>) is offered only for the
|
||||
denominations the purse can actually pay.
|
||||
|
||||
The eight moves do not grow. MoveValidator is untouched."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Content — the denominations replace `coin`
|
||||
|
||||
**Files:**
|
||||
- Delete: `content/world/items/coin.json`
|
||||
- Create: `content/world/items/copper.json`, `content/world/items/silver.json`, `content/world/items/gold.json`
|
||||
- Modify: `content/origins/deserter.json:18`
|
||||
- Modify: `client/scripts/newgame/new_game.gd:65`
|
||||
- Test: `client/tests/unit/test_content_db.gd`
|
||||
- Test: `client/tests/unit/test_new_game.gd`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GameState.grant()` (Task 2), `Currency.VALUES` (Task 1).
|
||||
- Produces: item ids `copper` / `silver` / `gold` in `ContentDB.items`, each with `slot: "currency"`. `content_db.unresolved_refs()` needs **no** change — the denominations are real items, so `has_item()` resolves the deserter's grant.
|
||||
|
||||
`inventory_grants` keeps its schema. `docs/schemas/origin.schema.json` is `additionalProperties: false`, so a new money field would have been a contract amendment — routing the existing `item_id` + `qty` through `grant()` avoids it entirely. **Do not touch the origin schema.**
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
In `client/tests/unit/test_content_db.gd`, update `test_loads_world_ids` and append the parity guard:
|
||||
|
||||
```gdscript
|
||||
func test_loads_world_ids():
|
||||
assert_true(db.has_location("greywater_docks"))
|
||||
assert_true(db.has_quest("find_the_ledger"))
|
||||
assert_true(db.has_item("worn_shortsword"))
|
||||
assert_true(db.has_item("copper"))
|
||||
assert_true(db.has_item("silver"))
|
||||
assert_true(db.has_item("gold"))
|
||||
assert_false(db.has_item("coin"), "the undifferentiated coin is gone")
|
||||
|
||||
|
||||
func test_currency_items_agree_with_the_code():
|
||||
# The denomination ids exist twice — in code (Currency.VALUES, because the
|
||||
# ratios are rules) and in content (slot: "currency"). Guard the two against
|
||||
# drifting apart, since nothing at runtime reconciles them.
|
||||
var content_ids: Array = []
|
||||
for id in db.items:
|
||||
if db.items[id].get("slot", "") == "currency":
|
||||
content_ids.append(id)
|
||||
content_ids.sort()
|
||||
var code_ids: Array = Currency.VALUES.keys()
|
||||
code_ids.sort()
|
||||
assert_eq(content_ids, code_ids)
|
||||
```
|
||||
|
||||
In `client/tests/unit/test_new_game.gd`, replace `test_inventory_and_dispo_land_in_state_not_log`:
|
||||
|
||||
```gdscript
|
||||
func test_inventory_and_dispo_land_in_state_not_log():
|
||||
var res := _build({"name": "Aldric", "class_id": "sellsword"})
|
||||
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
|
||||
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
|
||||
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
|
||||
assert_false("inventory" in res["log"].to_dict())
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_content_db.gd
|
||||
./run_tests.sh -gtest=res://tests/unit/test_new_game.gd
|
||||
```
|
||||
|
||||
Expected: FAIL — `has_item("copper")` is false, and the deserter's grant still lands 3 `coin` in the inventory.
|
||||
|
||||
- [ ] **Step 3: Swap the content**
|
||||
|
||||
```bash
|
||||
git rm content/world/items/coin.json
|
||||
```
|
||||
|
||||
Create `content/world/items/copper.json`:
|
||||
|
||||
```json
|
||||
{ "id": "copper", "name": "copper coin", "slot": "currency" }
|
||||
```
|
||||
|
||||
Create `content/world/items/silver.json`:
|
||||
|
||||
```json
|
||||
{ "id": "silver", "name": "silver coin", "slot": "currency" }
|
||||
```
|
||||
|
||||
Create `content/world/items/gold.json`:
|
||||
|
||||
```json
|
||||
{ "id": "gold", "name": "gold coin", "slot": "currency" }
|
||||
```
|
||||
|
||||
No `value_copper` field. The ratios are in `Currency` — a second copy here would be a second source of truth.
|
||||
|
||||
In `content/origins/deserter.json`, change the grant (line 18). His situation line already reads *"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:
|
||||
|
||||
```json
|
||||
"inventory_grants": [
|
||||
{ "item_id": "worn_shortsword", "qty": 1 },
|
||||
{ "item_id": "copper", "qty": 47 }
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Route the grants**
|
||||
|
||||
In `client/scripts/newgame/new_game.gd`, the `inventory_grants` loop (currently `state.add_item(...)`) routes through `grant`:
|
||||
|
||||
```gdscript
|
||||
# inventory grants -> game state (NOT the log). Charter §2. grant() routes
|
||||
# currency to the purse; everything else to the inventory dict.
|
||||
for grant in origin.get("inventory_grants", []):
|
||||
state.grant(grant.get("item_id", ""), int(grant.get("qty", 0)))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_content_db.gd
|
||||
./run_tests.sh -gtest=res://tests/unit/test_new_game.gd
|
||||
./run_tests.sh
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Verify the content build and the generated tree**
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=tools python3 -m content_build --check
|
||||
git status --porcelain content/
|
||||
```
|
||||
|
||||
Expected: the build check passes. `git status` shows **only** the four `content/world/items/` changes and `content/origins/deserter.json` — **no** other file under `content/world/` or `content/server/` may appear. If one does, stop: the generated tree has drifted and that is a client-visible change (the Duncarrow lesson).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add content/world/items/ content/origins/deserter.json \
|
||||
client/scripts/newgame/new_game.gd \
|
||||
client/tests/unit/test_content_db.gd client/tests/unit/test_new_game.gd
|
||||
git commit -m "feat(currency): copper/silver/gold replace the coin
|
||||
|
||||
The three denominations are move tokens, not stored buckets — there is one
|
||||
integer, so change-making never arises. The deserter is down to 47c: under a
|
||||
silver, so he cannot cover a bed and the player sees it on turn one.
|
||||
|
||||
A parity test guards the code-side ratios against the content-side ids."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: The display edge
|
||||
|
||||
**Files:**
|
||||
- Modify: `client/scripts/ui/shell/shell_state.gd:7,22`
|
||||
- Modify: `client/scripts/ui/shell/main_window_shell.gd:28,96`
|
||||
- Modify: `client/scenes/shell/MainWindowShell.tscn:157-160`
|
||||
- Test: `client/tests/unit/test_shell_state.gd:19`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Currency.format()` (Task 1).
|
||||
- Produces: `ShellState.vitals["purse_copper"]` replaces `vitals["gold"]`. This is the **only** call site of `Currency.format()` in the codebase.
|
||||
|
||||
`vitals["gold"]` is a hardcoded seed placeholder, not real state — the shell has no `GameState`. A literal port would be absurd: 1,240 gold = 12.4 *million* copper, i.e. 1,240 plot points sitting in the command bar. It reseeds to `347` → `◈ 3s 47c`: a party carrying silver and no gold.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In `client/tests/unit/test_shell_state.gd`, change the last assertion of `test_seed_vitals`:
|
||||
|
||||
```gdscript
|
||||
func test_seed_vitals():
|
||||
var s := ShellState.seed()
|
||||
assert_eq(s.vitals["hp"], 42)
|
||||
assert_eq(s.vitals["hp_max"], 60)
|
||||
assert_eq(s.vitals["mp"], 18)
|
||||
assert_eq(s.vitals["mp_max"], 30)
|
||||
assert_eq(s.vitals["purse_copper"], 347)
|
||||
assert_false("gold" in s.vitals, "the flat gold placeholder is gone")
|
||||
|
||||
|
||||
func test_seed_purse_renders_as_denominations():
|
||||
var s := ShellState.seed()
|
||||
assert_eq(Currency.format(s.vitals["purse_copper"]), "3s 47c")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_shell_state.gd
|
||||
```
|
||||
|
||||
Expected: FAIL — `vitals` has no `purse_copper` key.
|
||||
|
||||
- [ ] **Step 3: Reseed the state**
|
||||
|
||||
In `client/scripts/ui/shell/shell_state.gd`, line 7:
|
||||
|
||||
```gdscript
|
||||
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0}
|
||||
```
|
||||
|
||||
and in `seed()`, line 22:
|
||||
|
||||
```gdscript
|
||||
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "purse_copper": 347}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Format at the edge**
|
||||
|
||||
In `client/scripts/ui/shell/main_window_shell.gd`, rename the node ref (line 28) — the label is a purse now, not a gold counter:
|
||||
|
||||
```gdscript
|
||||
@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse
|
||||
```
|
||||
|
||||
and in `_bind_vitals()` (line 96):
|
||||
|
||||
```gdscript
|
||||
_purse.text = "◈ %s" % Currency.format(_state.vitals["purse_copper"])
|
||||
_purse.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
|
||||
```
|
||||
|
||||
In `client/scenes/shell/MainWindowShell.tscn`, rename the node and update its authored placeholder text (ADR 0001 — the authored text lives in the scene, not in `_ready()`):
|
||||
|
||||
```
|
||||
[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Mono"
|
||||
text = "◈ 3s 47c"
|
||||
```
|
||||
|
||||
`Gold` is referenced nowhere else — `main_window_shell.gd:28` is the only path to that node.
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
```bash
|
||||
./run_tests.sh -gtest=res://tests/unit/test_shell_state.gd
|
||||
./run_tests.sh
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Verify the scene actually renders**
|
||||
|
||||
Open the shell and confirm the command bar reads `◈ 3s 47c` — an editor-authored scene that only passes unit tests is not verified.
|
||||
|
||||
```bash
|
||||
cd client && godot scenes/shell/MainWindowShell.tscn
|
||||
```
|
||||
|
||||
Expected: the command bar shows `◈ 3s 47c` in gold. No parser errors in the output about a missing `Gold` node.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add client/scripts/ui/shell/shell_state.gd client/scripts/ui/shell/main_window_shell.gd \
|
||||
client/scenes/shell/MainWindowShell.tscn client/tests/unit/test_shell_state.gd
|
||||
git commit -m "feat(currency): the command-bar purse reads in denominations
|
||||
|
||||
The seed placeholder was a flat 1240 gold — 12.4 million copper under the real
|
||||
model, which is 1,240 plot points in the command bar. It reseeds to 347c, a
|
||||
party carrying silver and no gold: '◈ 3s 47c'.
|
||||
|
||||
This is the only call site of Currency.format() — the display edge."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Final verification
|
||||
|
||||
**Files:** none — this task only runs things.
|
||||
|
||||
- [ ] **Step 1: The full client suite**
|
||||
|
||||
```bash
|
||||
cd client && ./run_tests.sh
|
||||
```
|
||||
|
||||
Expected: green, with zero failures. Paste the summary line into the final report — do not claim green without it.
|
||||
|
||||
- [ ] **Step 2: The content build**
|
||||
|
||||
```bash
|
||||
PYTHONPATH=tools python3 -m content_build --check
|
||||
```
|
||||
|
||||
Expected: passes.
|
||||
|
||||
- [ ] **Step 3: The generated tree is byte-identical**
|
||||
|
||||
```bash
|
||||
git diff --stat dev...HEAD -- content/world content/server
|
||||
```
|
||||
|
||||
Expected: **only** `content/world/items/` — `coin.json` deleted, `copper.json` / `silver.json` / `gold.json` added. Any other path under `content/world/` or `content/server/` means the generated tree drifted and must be explained before merging.
|
||||
|
||||
- [ ] **Step 4: Confirm `coin` is gone**
|
||||
|
||||
```bash
|
||||
grep -rn '"coin"\|item_id": "coin"\|accept_item(coin)\|has_item("coin")' client content --include=*.gd --include=*.json
|
||||
```
|
||||
|
||||
Expected: **no matches.** (`content/lore/*.md` and `content/world/canon/blood-price.json` use the *word* "coin" in prose — those are lore, not ids, and are untouched.)
|
||||
|
||||
- [ ] **Step 5: Report, and stop**
|
||||
|
||||
Report the results to the human. **Do not merge.** §18: `feat/currency` merges into `dev` with `--no-ff` only after the human confirms, and `master` is never touched.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec section | Task |
|
||||
|---|---|
|
||||
| §4.1 the purse is one integer | 2 |
|
||||
| §4.2 denominations as move tokens | 3, 4 |
|
||||
| §4.3 excluded from the grid by construction | 2 (routing) + 3 (`accept_item` no longer sourced from `inventory`) |
|
||||
| §4.4 the `Currency` value type | 1 |
|
||||
| §4.5 the display rule | 1 (`format`), 5 (the call site) |
|
||||
| §4.6 routing in one place | 2 (`grant`/`take`), 3 (`MoveApplier`, `NpcContent`), 4 (`new_game`) |
|
||||
| §4.7 parity guard | 4 |
|
||||
| §5 content changes | 4 |
|
||||
| §6 client changes | 2, 3, 4, 5 |
|
||||
| §7 tests | every task, written first |
|
||||
| §8 verification | 6 |
|
||||
| §9 out of scope (prices, shop, canon-log purse, encumbrance) | not built — stated in Global Constraints |
|
||||
|
||||
**Type consistency:** `purse_copper`, `add_copper`, `grant`, `take`, `is_currency`, `value`, `format`, `VALUES` are spelled identically in every task that defines or consumes them. `MoveValidator` is untouched in all tasks, as the spec requires.
|
||||
Reference in New Issue
Block a user