Files
code_of_conquest_dnd/docs/superpowers/plans/2026-07-12-currency-copper.md
Phillip Tarrant a90cc16463 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
2026-07-12 17:58:44 -05:00

32 KiB

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

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/:

./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).

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
./run_tests.sh -gtest=res://tests/unit/test_currency.gd

Expected: PASS, 8 tests.

  • Step 5: Run the full suite
./run_tests.sh

Expected: all green. Nothing else references Currency yet.

  • Step 6: Commit
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) -> voidthe 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":

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:

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
./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:

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:

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
./run_tests.sh -gtest=res://tests/unit/test_game_state.gd

Expected: PASS.

  • Step 5: Run the full suite
./run_tests.sh

Expected: all green.

  • Step 6: Commit
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:

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:

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
./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:

			"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:

	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
./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
./run_tests.sh

Expected: all green.

  • Step 6: Commit
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:

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:

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
./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
git rm content/world/items/coin.json

Create content/world/items/copper.json:

{ "id": "copper", "name": "copper coin", "slot": "currency" }

Create content/world/items/silver.json:

{ "id": "silver", "name": "silver coin", "slot": "currency" }

Create content/world/items/gold.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:

  "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:

	# 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
./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:

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.jsonno 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
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:

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
./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:

var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0}

and in seed(), line 22:

	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:

@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse

and in _bind_vitals() (line 96):

	_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
./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.

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
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
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
PYTHONPATH=tools python3 -m content_build --check

Expected: passes.

  • Step 3: The generated tree is byte-identical
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
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.