Compare commits

...

14 Commits

Author SHA1 Message Date
96c270601a Merge feat/currency into dev
Money becomes copper-based with real denominations (1g = 100s = 10,000c),
stored as one integer and formatted only at the display edge.

Money is not an inventory item: give_item/accept_item carry no quantity and
gifts_given is a global one-shot, so a single 'copper' id would be givable
once, for one copper, per campaign. The denominations are move tokens instead
— give_item(gold) is +10,000c — and currency routes to the purse, never the
inventory dict, so it cannot surface in an inventory grid by construction.

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
2026-07-12 18:53:26 -05:00
a495543290 test(currency): pin both halves of the dedup key
The existing test varied the move name and the denomination together, so a
name-only or denom-only key would have passed it too. Two assertions nail it:
give_item(gold)+give_item(silver) kills a name-only key, and
give_item(gold)+accept_item(gold) kills a denom-only key.

The spec now records the per-reply ceiling as a decision rather than an
accident, and flags the pre-existing non-currency duplicate-tag path as open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:49:11 -05:00
8e1238c316 fix(currency): cap repeated currency move tags at one per reply
TagExtractor collects every [MOVE:] tag occurrence with no dedupe, and
MoveValidator is a pure membership test against available_moves, so N
copies of one legal move all pass validation. MoveApplier then looped
and applied every copy. A model could therefore pick an arbitrary
amount of money in unary by repeating one tag: [MOVE: accept_item(copper)]
x47 against a 47c purse drains it to 0; [MOVE: give_item(gold)] x10
mints +100,000c. The eight-move vocabulary (§6) takes no quantity
argument precisely so the AI cannot choose a number — this closed that
gap by another door and was a §2 breach (AI owns text, never state).

MoveApplier.apply() now tracks currency moves (give_item/accept_item on
a denomination) already applied within one call, keyed on name+denom,
and skips repeats. Non-currency moves are untouched — repeated
give_item(amulet) still yields multiple copies, which stays bounded by
the pre-existing cross-reply gifts_given gate and is a separate,
already-reported issue. move_validator.gd and tag_extractor.gd are
untouched.

Also adds the Finding-2 test the currency spec (§7) named but never
delivered: after GameState.grant("gold", 1), inventory stays empty, so
accept_item(gold) in available_moves comes only from the affordability
loop, never double-offered via the inventory.keys() loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:47:19 -05:00
6c496d3ed8 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:39:25 -05:00
a9d9eedb7e 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:36:08 -05:00
c106dbbd37 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:32:14 -05:00
8a71671df7 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.
2026-07-12 18:29:10 -05:00
2a372bff2f 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'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 18:26:22 -05:00
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
37ec1bb1be 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
2026-07-12 17:52:13 -05:00
9e8fc55a44 docs(content): create the content roadmap — Spine, BOM, Backlog
Implements the docs restructure half of the content-track design (the
currency item is code and stays unbuilt). Content stops being unbounded:
the work is now a countable checklist with an end.

content/roadmap.md — new. Three sections, only one of which is ever the
work:
  Spine     Greywater Docks + one dungeon, patron Ghaul the Yoke; the
            cosmology touching ground. Plus what is already canon.
  BOM       ~57 discrete pieces for a complete, playable game, per
            milestone. Numbers, not vibes.
  Backlog   Everything the slice does not need. Not a debt — where ideas
            go to be safe.

Its rule: never author anything that is not in the Bill of Materials.

The BOM carries a Fixed/Variable column — the seven-campaign saga spec's
only ask of this table. ~37 fixed / ~20 variable: a second town costs the
20, not the 57. That is also the exact split v2 generation would need.

content/lore/canon-roadmap.md becomes a pointer (older specs and plans
reference the old path). An items-and-prices table does not belong under
lore/.

docs/roadmap.md — M4..M9 each gain a "Content it consumes →" line into
the BOM.

world-building skill — SKILL.md and evals.json now point at
content/roadmap.md, and the skill is told to respect the BOM rule: ideas
that arrive during authoring go to the Backlog, not into the world.

Build + --check green; generated content/world and content/server are
byte-identical. No Python touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:31:30 -05:00
ef335a93c3 docs: land the saga guardrails on the milestones they constrain
Applies the seven-campaign saga direction spec (021c248) to the docs it
was written to protect. No new milestone, no new content, no BOM line.

docs/roadmap.md — a "Guarded direction" section, and a ⛨ saga note on the
four milestones that would otherwise foreclose the shape:
  M3  don't hardcode the title flow so a saga entry path can't be added
  M4  keep construct()'s `creation` a plain Dictionary; the scene must not
      become its only producer
  M5  the level curve is no longer open — L20 across 7 tiers; enemy stat
      blocks carry tier; no scaling multiplier, ever
  M9  the save is two objects (Saga + Campaign); humiliations move to the
      Saga or they die with the campaign

races-and-classes spec — the deferred "XP curve → M5" line now inherits the
ceiling rather than choosing one.

canon-roadmap — what the saga asks of content: a tier field when the
item/quest namespaces land, and a fixed-vs-variable mark on each BOM line
when the BOM is written. Tiers 2-7 and the other six kings go to the
Backlog.

Content build --check green; no generated content touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:23:26 -05:00
021c2480d8 docs(spec): character reuse + the seven-campaign arc
A direction spec: one character, seven campaigns (one per demon king),
one saga — then it ends. The character, companions, deeds and level carry
between campaigns; the world does not.

Deliberately adds no milestone and no BOM line. Its real payload is a set
of guardrails on milestones that are about to be built blank and would
otherwise foreclose the shape:

- M5 owes a level curve — design it to a ceiling of 20 across 7 tiers,
  with tiered enemy stat blocks and never a scaling multiplier.
- M9 owes a save format — two objects (Saga + Campaign), not one blob.
  Humiliations move to the Saga or they die with the campaign.
- M3/M4 — don't foreclose a saga entry path; don't couple construct() to
  the creation scene.
- Build tool — add tier:1..7 when the item/quest namespaces land.

Difficulty is fixed at world-construction time from the campaign index,
never during play. That prohibition is the whole design; a world that
re-levels with the player is a treadmill.

The canon-log contract does not change: party[].disposition,
humiliations[] and established_facts[] already exist, and player still
admits no number. The Saga becomes the source; the canon log a projection
of it. The seam is one optional fourth arg to NewGame.construct().

Greywater at tier 1 under Ghaul is, unintentionally, already campaign 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:16:50 -05:00
c171299cb4 docs(spec): content track — demand-driven, bounded by the slice
Content (lore, economy, items, quests, map) was untracked and unbounded.
Bound it: a second roadmap (content/roadmap.md) with Spine / Bill of
Materials / Backlog, demand-driven by the milestone that consumes it.

- Vertical slice = one town + one dungeon (adopt + deepen Greywater).
- Ghaul the Yoke is the ledger chain's patron, so the quest chain and
  the cosmology are one object, not two.
- Economy splits per §18: the loop is code (M8), the numbers are content.
  Currency = 1g/100s/10,000c, copper as the stored atom; lands before M7.
- Build tool needs `item` + `quest` namespaces; locations already exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:29:29 -05:00
33 changed files with 2343 additions and 79 deletions

View File

@@ -148,8 +148,13 @@ touched those, just confirm they're valid JSON.)
Summarize: new ids added (by layer), any existing id you reused in `related`,
anything you flagged for approval, and confirmation the build + check + tests are
green. Update `content/lore/canon-roadmap.md`'s Authored/Pending lists if you
added a new bible file or closed out a pending thread.
green. Update [`content/roadmap.md`](../../../content/roadmap.md) if you added a new
bible file or closed out a thread — its Spine list, or the Bill of Materials line
you just satisfied.
**Respect its rule: never author anything that is not in the Bill of Materials.**
An idea that arrives during authoring goes to that doc's Backlog, not into the
world.
## Reference files

View File

@@ -33,13 +33,13 @@
"id": 3,
"name": "new-storyline-file",
"prompt": "Start a new storyline about a roadside cult bleeding travelers to feed one of the Seven: who they are, and a rumor the player might hear. Make it its own file.",
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; canon-roadmap updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; content/roadmap.md updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
"assertions": [
"Authored a new content/lore/*.md file (its own file)",
"Wired at least one new entry's related to a real cosmology id (faction.the-seven or a lord)",
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
"python -m content_build --check exits 0 after the change",
"content/lore/canon-roadmap.md updated to reflect the new content",
"content/roadmap.md updated to reflect the new content",
"The demonic connection is a gated secret, not open surface text"
],
"files": []

View File

@@ -154,10 +154,10 @@ layout_mode = 2
theme_type_variation = &"Mono"
text = "MP 18 / 30"
[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"]
[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"]
layout_mode = 2
theme_type_variation = &"Mono"
text = "◈ 1240 gp"
text = "◈ 3s 47c"
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
layout_mode = 2

View File

@@ -61,9 +61,10 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
var qd: Dictionary = world.quest(quest_id)
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", "")))
# inventory grants -> game state (NOT the log). Charter §2.
# 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.add_item(grant.get("item_id", ""), int(grant.get("qty", 0)))
state.grant(grant.get("item_id", ""), int(grant.get("qty", 0)))
return {"ok": true, "errors": [], "log": log, "state": state}

View File

@@ -11,9 +11,20 @@ const HOSTILE_FLOOR := -100
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
# A currency move (give_item/accept_item on a denomination) applies AT MOST
# ONCE per reply. Without this, a model repeating one tag N times would pick
# an arbitrary amount in unary — the AI choosing a number is a §2 breach.
# Real-item duplicates are unaffected; that stays bounded by gifts_given.
var currency_applied_this_reply := {} # "name(denom)" -> true
for move in moves:
var name: String = move.get("name", "")
var args: Array = move.get("args", [])
if name in ["give_item", "accept_item"] and not args.is_empty() \
and Currency.is_currency(str(args[0])):
var currency_key := "%s(%s)" % [name, str(args[0])]
if currency_applied_this_reply.has(currency_key):
continue
currency_applied_this_reply[currency_key] = true
match name:
"offer_quest":
var q: String = str(args[0])
@@ -23,10 +34,11 @@ static func apply(moves: Array, game_state, canon_log, content_db, npc_id: Strin
game_state.mark_revealed(str(args[0]))
"give_item":
var i: String = str(args[0])
game_state.add_item(i, 1)
game_state.mark_gift_given(i)
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.remove_item(str(args[0]), 1)
game_state.take(str(args[0]), 1)
"adjust_disposition":
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))

View File

@@ -21,12 +21,20 @@ static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Arra
moves.append("reveal(%s)" % t)
for i in caps.get("giveable_items", []):
if not game_state.is_gift_given(i):
# 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)
moves.append_array(UNIVERSAL)
return moves

View File

@@ -0,0 +1,44 @@
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)

View File

@@ -0,0 +1 @@
uid://cwnflbwacp5jf

View File

@@ -8,7 +8,8 @@ var luck: int = 0
var luck_base: int = 0
var stats: Dictionary = {} # {str, dex, con, fth, mag}
var npc_dispositions: Dictionary = {} # world-npc id -> int (100..100)
var inventory: Dictionary = {} # item_id -> qty
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).
var revealed_topics: Dictionary = {} # topic_id -> true
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
@@ -41,6 +42,27 @@ func remove_item(item_id: String, qty: int) -> void:
inventory.erase(item_id)
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)
func mark_revealed(topic_id: String) -> void:
revealed_topics[topic_id] = true

View File

@@ -29,7 +29,7 @@ var _http: HTTPRequest
@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens
@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP
@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP
@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold
@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse
@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables
@@ -93,8 +93,8 @@ func _side_color(e: TurnEntry) -> Color:
func _bind_vitals() -> void:
_hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
_mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
_gold.text = "%d gp" % _state.vitals["gold"]
_gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
_purse.text = "%s" % Currency.format(_state.vitals["purse_copper"])
_purse.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
func _bind_consumables() -> void:

View File

@@ -4,7 +4,7 @@ extends RefCounted
## 2a; later systems (combat vitals, inventory consumables) become the writers.
## No Luck/LCK value lives here — it is never surfaced (§7).
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0}
var turn_order: Array[TurnEntry] = []
var consumables: Array = []
var round_label: String = ""
@@ -19,7 +19,7 @@ func toggle_dock() -> bool:
static func seed() -> ShellState:
var s := ShellState.new()
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "purse_copper": 347}
s.turn_order = [
TurnEntry.new("EL", 17, &"you", true, false),
TurnEntry.new("DW", 12, &"ally"),

View File

@@ -18,7 +18,24 @@ 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("coin"))
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)
func test_companions_are_brannoc_and_cadwyn():

View File

@@ -0,0 +1,42 @@
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)

View File

@@ -0,0 +1 @@
uid://dwpdm680q83ws

View File

@@ -5,9 +5,9 @@ const GameState = preload("res://scripts/state/game_state.gd")
func test_add_item_accumulates():
var s = GameState.new()
s.add_item("coin", 3)
s.add_item("coin", 2)
assert_eq(s.inventory["coin"], 5)
s.add_item("worn_shortsword", 3)
s.add_item("worn_shortsword", 2)
assert_eq(s.inventory["worn_shortsword"], 5)
func test_npc_disposition_clamps():
@@ -32,11 +32,11 @@ func test_luck_descriptor_delegates():
func test_remove_item_decrements_and_floors_at_zero():
var gs = GameState.new()
gs.add_item("coin", 3)
gs.remove_item("coin", 2)
assert_eq(gs.inventory.get("coin", 0), 1)
gs.remove_item("coin", 5)
assert_eq(gs.inventory.get("coin", 0), 0)
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)
func test_revealed_topics_tracked():
@@ -51,3 +51,42 @@ func test_gifts_given_tracked():
assert_false(gs.is_gift_given("amulet"))
gs.mark_gift_given("amulet")
assert_true(gs.is_gift_given("amulet"))
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)

View File

@@ -44,12 +44,118 @@ func test_become_hostile_sets_floor():
func test_accept_item_removes_from_inventory():
var gs = GameState.new()
gs.add_item("coin", 2)
MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("coin", 0), 1)
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)
func test_refuse_and_end_are_noops():
var gs = GameState.new()
MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.npc_dispositions.size(), 0)
# --- Finding 1: a repeated currency move tag in ONE reply must not let the AI
# pick an arbitrary amount by unary repetition (§2 breach). A currency move
# (give_item/accept_item on a denomination) applies AT MOST ONCE per apply()
# call, keyed on move name + denomination.
func test_repeated_accept_item_silver_applies_once():
var gs = GameState.new()
gs.purse_copper = 10000
MoveApplier.apply([_m("accept_item", ["silver"]), _m("accept_item", ["silver"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 9900, "second accept_item(silver) tag must be a no-op")
func test_repeated_give_item_gold_applies_once():
var gs = GameState.new()
var moves: Array = []
for i in range(10):
moves.append(_m("give_item", ["gold"]))
MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 10000, "nine of the ten give_item(gold) tags must be no-ops")
func test_repeated_accept_item_copper_applies_once():
var gs = GameState.new()
gs.purse_copper = 47
var moves: Array = []
for i in range(47):
moves.append(_m("accept_item", ["copper"]))
MoveApplier.apply(moves, gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 46, "46 of the 47 accept_item(copper) tags must be no-ops")
func test_different_currency_moves_both_still_apply():
# Proves dedup is keyed per (move name + denomination), not per reply as a whole.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["silver"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 9900)
func test_dedup_key_is_denomination_not_just_move_name():
# Two DIFFERENT denominations under the same move name must both land. A key
# of "give_item" alone would swallow the silver.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"]), _m("give_item", ["silver"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 10100)
func test_dedup_key_is_move_name_not_just_denomination():
# The same denomination under two DIFFERENT move names must both land. A key
# of "gold" alone would swallow the accept, leaving 10000 instead of 0.
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["gold"]), _m("accept_item", ["gold"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.purse_copper, 0)
func test_repeated_non_currency_give_item_still_applies_each_time():
# Pins the scope line: real-item duplicate handling is UNCHANGED by this fix
# (it stays bounded only by the cross-reply gifts_given gate, reported separately).
var gs = GameState.new()
MoveApplier.apply([_m("give_item", ["amulet"]), _m("give_item", ["amulet"]), _m("give_item", ["amulet"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("amulet", 0), 3, "non-currency give_item is out of scope for this fix")
func test_repeated_non_currency_accept_item_still_applies_each_time():
var gs = GameState.new()
gs.add_item("worn_shortsword", 5)
MoveApplier.apply([_m("accept_item", ["worn_shortsword"]), _m("accept_item", ["worn_shortsword"])],
gs, CanonLog.new(), _content(), "fenn")
assert_eq(gs.inventory.get("worn_shortsword", 0), 3, "non-currency accept_item is out of scope for this fix")

View File

@@ -40,8 +40,9 @@ func test_numeric_luck_absent_from_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("coin", 0), 3)
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())

View File

@@ -39,6 +39,54 @@ func test_revealed_topic_drops_out():
func test_accept_item_offered_per_held_item():
var gs = GameState.new()
gs.add_item("coin", 2)
gs.add_item("worn_shortsword", 2)
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
assert_true("accept_item(coin)" in moves)
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_never_arrives_via_the_inventory_loop():
# §7 of the currency spec: currency never enters the inventory dict, so the
# only accept_item(<denom>) in available_moves comes from the affordability
# loop — never a double-offer via the inventory.keys() loop.
var gs = GameState.new()
gs.grant("gold", 1)
assert_true(gs.inventory.is_empty(), "currency must never be written into inventory")
var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new())
var gold_offers := 0
for m in moves:
if m == "accept_item(gold)":
gold_offers += 1
assert_eq(gold_offers, 1, "accept_item(gold) must be offered exactly once")
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")

View File

@@ -16,7 +16,13 @@ func test_seed_vitals():
assert_eq(s.vitals["hp_max"], 60)
assert_eq(s.vitals["mp"], 18)
assert_eq(s.vitals["mp_max"], 30)
assert_eq(s.vitals["gold"], 1240)
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")
func test_seed_turn_order():

View File

@@ -4,15 +4,21 @@ Cross-cutting authored writing, split by role in the data model (see the canon
log spec in [`/docs`](../docs)):
```
/world Static, ID-referenced game content. Identical every playthrough.
roadmap.md The content track — Spine · Bill of Materials · Backlog. START HERE.
/lore Authored Markdown bibles. THE SOURCE OF TRUTH; world/ + server/ are built from it.
/world Static, ID-referenced game content. Identical every playthrough. BUILT.
/locations The map — towns, dungeons, points of interest (by id)
/npcs Per-NPC persona + knowledge lists (charter §6)
/quests Story skeletons and quest definitions (charter §17)
/items Item definitions — gear, consumables, cursed items (§7)
/server Spoiler bodies + personas. API-only; excluded from the client export. BUILT.
/origins Thin starting-state seeds. One per starting point. POC authors one.
/fallback Authored degraded-DM text for every AI surface (charter §13)
```
**[`roadmap.md`](roadmap.md) governs what gets authored.** Its rule: *never author
anything that is not in the Bill of Materials.* Ideas that arrive go to its Backlog.
## The three layers
- **`world/`** is static content the origin and the canon log reference by stable

View File

@@ -1,41 +1,11 @@
# Canon Roadmap
# Moved
The world's answer to `docs/roadmap.md`: what is authored vs pending, and the
order the Margreave is being written. Source of truth is `content/lore/*.md`;
`content/world` + `content/server` are BUILT from it (`python -m content_build`).
The canon roadmap is now the **content roadmap**: [`content/roadmap.md`](../roadmap.md).
## Authored (status: canon)
It grew past canon — it holds the Spine, a Bill of Materials (items, prices,
quests, the map), and a Backlog. An items-and-prices table does not belong under
`lore/`. See the
[content-track design](../../docs/superpowers/specs/2026-07-12-content-track-design.md).
- **cosmology.md** — the overarching theme: the Warden (distant good god), the
Sent (his angels), the Seven (demonic host), and the two economies +
possession (`rule.the-pact`, `rule.blood-price`, `rule.possession`). The
backdrop every future piece of content reconciles against.
- **the-seven.md** — the seven demon kings, one `person.*` each: Kareth (Red
King), Vael (Quiet Knife), Morren (Grey Mother), Ghaul (the Yoke), Ishri (Open
Mouth), Nuun (Hollow Choir), Draeth (Crowned Worm).
- **FTH-axis callings** (folded into the cosmology, from the races/classes
spec): the Bonesetter channels the Warden, the **Bloodsworn** (resolves the
warlock's former `[CANON-TBD]` name) pacts one of the Seven. Applying `Bloodsworn` to calling
data and a Bloodsworn's patron choice are M4; any Luck effect is an M5/
Improviser reconcile.
- **mechanics.md** — hosts `rule.disposition-ladder` (the required-once gate
vocabulary), reparented out of the retired Duncarrow scaffolding.
- **specimen.md** — a synthetic `status: candidate` disposition-gate specimen
(validated by `--check`, emitted nowhere); the world-building skill's structure
example. Not game canon. The Duncarrow scaffolding (Crell chain, Mera Fenn) is
deleted.
## Lesson from the Duncarrow purge
Generated content under `content/world/` has a client-side consumer:
`client/scripts/content/content_db.gd` (loaded via `client/scripts/harness/npc_harness.gd`)
and its GUT tests in `client/tests/unit/test_content_db.gd`. Deleting generated
content — a whole namespace of topics/npcs/canon — is a **client change**, not
just a content change; check `client/` before purging.
## Pending (not yet authored)
- Per-town knowledge that grounds the cosmology: possessed NPCs, blood-harvest
sites, shrine fronts — authored as gated `rumor`/`fact`/`secret`.
- The Barbarian ⚔ calling name (`[CANON-TBD]`); the Bloodsworn's patron-choice
detail (M4). Regions/towns of the Margreave beyond the scaffolding.
This pointer exists because older specs and plans reference the old path. It holds
no canon and emits nothing.

View File

@@ -15,7 +15,7 @@
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [
{ "item_id": "worn_shortsword", "qty": 1 },
{ "item_id": "coin", "qty": 3 }
{ "item_id": "copper", "qty": 47 }
],
"start_quest_id": "find_the_ledger",
"build_constraints": {

177
content/roadmap.md Normal file
View File

@@ -0,0 +1,177 @@
# Content Roadmap
The world's answer to [`docs/roadmap.md`](../docs/roadmap.md). That doc sequences
**systems**; this one sequences **content** — lore, the economy, items, quests, the
map — and makes it **countable rather than open-ended**.
Source of truth is `content/lore/*.md`; `content/world` + `content/server` are
BUILT from it (`python3 -m content_build`). Realizes the
[content-track design](../docs/superpowers/specs/2026-07-12-content-track-design.md).
Three sections. **Only one of them is ever the work.**
1. **[Spine](#1-the-spine)** — the slice's world. Authored once; gates everything downstream.
2. **[Bill of Materials](#2-bill-of-materials)** — a countable checklist, per milestone. Numbers, not vibes.
3. **[Backlog](#3-backlog)** — everything the slice does not need. Named so it is not lost; parked so it is not looming.
> ## The rule
>
> **Never author anything that is not in the Bill of Materials.**
>
> An idea that arrives goes to the Backlog, and is then no longer carried. The
> Backlog is not a debt. It is where ideas go to be safe.
---
## 1. The Spine
**Greywater Docks and one dungeon** — the smallest thing that is a *game*. Every
system (creation, combat, dialogue, inventory, shop, quest log, map) is exercised
exactly once, against real content. The cosmology touching ground for the first
time.
- **The town — Greywater Docks.** A rot-black wharf where the river meets the sea
trade; everyone owes someone. **~6 NPCs:** Fenn (promoted from M2 test fixture to
real NPC), the harbourmaster, a fence, an innkeeper, a dock rough, one more.
- **The chain — "The Missing Ledger,"** extended from the existing hook. Town → road
→ dungeon → back. **Four objectives.**
- **The turn.** The debt Fenn cannot cover is a **blood** debt. The harbourmaster is
named in the ledger because he is the one *collecting*. The dungeon is the harvest
site.
- **The patron — Ghaul the Yoke.** Already, by name, the king of debt and bondage; a
ledger of what men owe is his liturgy. This makes the quest chain and the cosmology
**the same object** rather than two things bolted together, and gives
`rule.blood-price` its first concrete instance.
- **The dungeon** — the harvest site. One enemy family (the yoked) + a boss.
**Adopting Greywater, not replacing it.** Fenn is the NPC the aliveness question was
actually answered with (M2, live-proven). The slice's job is to make Greywater
*deep*, not *new*.
Breadth afterwards is **repetition of a proven shape**, not invention.
### Already authored (status: canon)
- **`lore/cosmology.md`** — the overarching theme: the Warden (distant good god), the
Sent (his angels), the Seven (demonic host), and the two economies + possession
(`rule.the-pact`, `rule.blood-price`, `rule.possession`). The backdrop every future
piece of content reconciles against.
- **`lore/the-seven.md`** — the seven demon kings, one `person.*` each: Kareth (Red
King), Vael (Quiet Knife), Morren (Grey Mother), Ghaul (the Yoke), Ishri (Open
Mouth), Nuun (Hollow Choir), Draeth (Crowned Worm).
- **FTH-axis callings** (folded into the cosmology, from the races/classes spec): the
Bonesetter channels the Warden, the **Bloodsworn** pacts one of the Seven.
- **`lore/mechanics.md`** — hosts `rule.disposition-ladder` (the required-once gate
vocabulary).
- **`lore/specimen.md`** — a synthetic `status: candidate` disposition-gate specimen
(validated by `--check`, emitted nowhere); the world-building skill's structure
example. **Not game canon.**
### Still to author for the slice
Greywater itself — the town, its ~6 NPCs, the ledger chain's four objectives, and
Ghaul's harvest site — as `content/lore/greywater.md`. The hand-written JSON under
`content/world/{locations,quests,npcs,items}` gets **authored upward** into that
bible, and `python3 -m content_build` emits `world/` from it.
Per-town knowledge that grounds the cosmology (possessed NPCs, blood-harvest sites,
shrine fronts) is authored as gated `rumor`/`fact`/`secret`.
---
## 2. Bill of Materials
Countable. This is the anti-overwhelm device.
The **Fixed/Variable** column is the [seven-campaign
saga](../docs/superpowers/specs/2026-07-12-character-reuse-seven-arc-design.md)'s only
ask of this table. **Fixed substrate** is identical in every campaign of every saga —
authored once, reused verbatim. **Variable** is per-campaign: the part a second town
would have to re-author, and the part v2 generation would produce. The column costs
nothing and tells you exactly what town #2 does *not* pay for.
| Milestone | Content it consumes | Count | Fixed / Variable |
|---|---|---|---|
| **M4** creation | Calling blurbs; the Bloodsworn's patron choice | ~2 | **Fixed** |
| **M4** creation | Origins (they reference world ids) | ~2 | **Variable** |
| **M5** combat | Abilities for the 3 POC classes | ~10 | **Fixed** |
| **M5** combat | 1 enemy family (4 units) + boss — **tier 1** | ~5 | **Variable** (per tier) |
| **M6** dialogue | 6 NPCs — persona + `knowledge[]` + gated topics (§6) | ~6 | **Variable** |
| **M7** inventory | 6 weapons, 4 armour, 3 consumables, **1 cursed item** (+STR / LCK — §7 says day-one) | ~14 | **Fixed** |
| **M8** world | The **price table in copper** | ~1 | **Fixed** |
| **M8** world | 3 map locations + the road; shop stock; the quest chain | ~7 | **Variable** |
| **M9** flavour | Authored fallback per AI surface (§13); shrine + Luck-drift events; humiliation seeds | ~10 | **Fixed** |
**≈57 discrete authored pieces for a complete, playable game.** An estimate, and
likely optimistic — but an estimate with an end, which is the entire point.
Roughly **37 fixed / 20 variable**: a second town costs the ~20, not the ~57.
Explicitly **not** in the slice: rings, amulets, trinkets. A second town. The other
six kings and their cults. All parked in the Backlog.
### The economy is two systems (§18)
- **The loop is code** (§2: state) — purse, buy/sell, the ~40% fence rate, gritty
disabled-CTA reasons. Sequenced as M8's *Shop economy*.
- **The numbers are content** — what a shortsword costs, a night at the inn, an ale,
the quest's purse. The **price table**, above.
**Currency model:**
```
1 gold = 100 silver = 10,000 copper
1 silver = 100 copper
```
**Gold is scary.** A peasant's whole life is copper, a mercenary's fee is silver, and
a gold coin is a plot point. The party counting coppers for an inn bed is §3 grit,
and Brannoc has an opinion about it.
**This is a change, not a new thing — and it has a client consumer. It lands as its
own small item before M7**, cheapest now while `content/world/items/` holds exactly
two files:
- A `Currency` value type — **store everything in copper**, format only at the
display edge (`12g 40s 8c`).
- `coin.json` → real denominations; all prices re-denominated.
- The Main Window shell's command-bar purse (it currently shows a flat gold value).
*§2: state · depends on nothing · goal: money that means something.*
---
## 3. Backlog
Not a debt. Where ideas go to be safe.
- **Items:** rings, amulets, trinkets.
- **Places:** a second town. Regions of the Margreave beyond the slice.
- **The saga (tiers 27)** — from the [character-reuse
spec](../docs/superpowers/specs/2026-07-12-character-reuse-seven-arc-design.md): the
other six kings as campaign patrons; tiered enemy families (proximity to a king *is*
the power scale); the three story-shapes (cult cell → cult that owns a city → the
king himself).
- **The Barbarian ⚔ calling name** (`[CANON-TBD]`).
- **AI-generated worlds** (v2, charter §17). The saga spec guarantees the `Saga`
object is what will feed it. Nothing else about it is decided.
---
## Notes for whoever authors next
- Each BOM line is authored through the **`/world-building` skill** — it reconciles
against existing canon, writes the build tool's source format, and verifies the
build stays green.
- **The build tool needs two new namespaces: `item` and `quest`.** Its legal
namespaces today (`tools/content_build/model.py`) are `town`, `place`, `region`,
`faction`, `person`, `rule`, `rumor`, `fact`, `secret`, `npc` — so **locations are
already modeled** (`town`/`place`/`region`) and only items and quests fall outside
it. Adding those two (model, emit, resolve) is a prerequisite of the M7 and M8 BOM
lines. **Add `tier: 1..7` to the entry envelope at the same moment** — the saga
spec's only schema ask, free while the model is already open.
- **Moving or deleting generated content is a client change.** `content/world/**` has
a client consumer: `client/scripts/content/content_db.gd` (via
`client/scripts/harness/npc_harness.gd`) and its GUT tests in
`client/tests/unit/test_content_db.gd`. That is the Duncarrow lesson — check
`client/` before any content move.

View File

@@ -1 +0,0 @@
{ "id": "coin", "name": "coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "copper", "name": "copper coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "gold", "name": "gold coin", "slot": "currency" }

View File

@@ -0,0 +1 @@
{ "id": "silver", "name": "silver coin", "slot": "currency" }

View File

@@ -13,6 +13,12 @@ The engine is done and proven. What's left is the **game on top of it** — recr
- **Damage determinism** — §7/§10 (deterministic) vs the Combat HUD mock's damage ranges → combat brainstorm (M5).
- **Class names** — §8 names vs the mock's callings (same archetypes) → character-creation brainstorm (M4).
### Guarded direction — the seven-campaign saga ([spec](superpowers/specs/2026-07-12-character-reuse-seven-arc-design.md))
**One character, seven campaigns (one per demon king), one saga — then it ends.** The character, companions, deeds and level carry between campaigns; the world does not. Difficulty is chosen **once, at world-construction time**, from the campaign index (`tier` 1..7) — **never during play.**
The spec adds **no milestone and no content**. It exists because M5 and M9 are about to be built blank and would foreclose the shape. What it constrains is marked **⛨ saga** on the milestones below. World generation itself stays v2 — the spec only guarantees the `Saga` is the object that will feed it.
### Out of scope (never / later)
Multiplayer (never) · mobile (later) · AI-generated story skeletons (v2) · credits/payments/auth (retrofit onto the proxy, §4).
@@ -57,28 +63,34 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (
### M3 — Visual foundation & shell ✅
-**Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. Merged to `dev`. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
-**Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. **Established the editor-first UI-scene convention** (ADR [0001](adr/0001-editor-first-ui-scenes.md); how-to in `client/docs/README.md`) and added Button-base `DockButton`/`ParchmentButton` + a base `RichTextLabel` prose style to the theme. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
-**Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). *§2: n/a · depends on the Theme · goal: first impression, into the world.*
-**Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). **⛨ saga:** a fourth entry path (*begin the next campaign of an existing saga*) lands later — don't build it, just don't hardcode the flow as `new game → creation → world` so it can't be added. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
### M4 — Character creation
-**Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). **~4 pieces.***
-**Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). Emit `race_id` + `calling_id` into it. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
### M5 — Tactical combat (gridless)
-**Combat turn manager + HUD** — the Combat HUD `Component` state machine as GDScript: initiative order, the action economy (Action/Bonus/Move), ability resolution, click-to-target, victory/defeat; tokens on the iso board as **flavor only, no movement grid** (§10). Seeded per encounter (§10). **Reconcile damage determinism** here (§7/§10 vs the mock's ranges). Combat flavor is async optional Narrator calls (reuses M1). *§2: state (flavor text a thin AI layer) · depends on the pipeline for flavor · goal: real stakes.*
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***
-**Combat turn manager + HUD** — the Combat HUD `Component` state machine as GDScript: initiative order, the action economy (Action/Bonus/Move), ability resolution, click-to-target, victory/defeat; tokens on the iso board as **flavor only, no movement grid** (§10). Seeded per encounter (§10). **Reconcile damage determinism** here (§7/§10 vs the mock's ranges). Combat flavor is async optional Narrator calls (reuses M1). **⛨ saga — the level curve is no longer open:** design it to a **ceiling of L20 across 7 tiers** (tier N ≈ levels 3N2 … 3N; exact bands tunable, the ceiling is not). **Enemy stat blocks carry `tier: 1..7`; there is no scaling multiplier, and there never will be** — a tier-6 world has *different monsters* (proximity to a king is the power scale), not a rat with 40× HP. Class abilities are planned to L20. The M5 content BOM (1 enemy family + boss) is **tier 1**. *§2: state (flavor text a thin AI layer) · depends on the pipeline for flavor · goal: real stakes.*
### M6 — Dialogue screen
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): 6 NPCs — persona + `knowledge[]` + gated topics (§6), variable. **~6 pieces.***
-**Dialogue UI + reshaped Adjudicator** — wrap the *proven* NPC free-text loop (`/npc/speak`, M2) in the mock's conversation screen: NPC portrait, live disposition meter, the NPC's spoken line + DM narration beat, and the **hybrid** input (choice chips with skill/approach tags **plus** the free-text line, §15). The **Adjudicator** (`/dm/adjudicate`, strict JSON §12, one-retry) lands here as the interpreter for the free-text "describe your own action" escape hatch — most turns use buttons. *§2: both (text = NPC voice · state = validated moves/actions) · depends on the NPC role (M2) · goal: conversation inside the game's UI.*
### M7 — Inventory & character sheet
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): 6 weapons, 4 armour, 3 consumables, **1 cursed item** (+STR / LCK — §7 says day-one). All fixed. **~14 pieces.** Prerequisite: the build tool gains `item` + `quest` namespaces (and `tier: 1..7`).*
-**Inventory & equipment** — the paper-doll (12 slots) + Satchel grid, category tabs, item detail, encumbrance, derived stats; item seed-data from the mock's `items()` map as `Resource`s. *§2: state · depends on the Theme + item model · goal: gear the player manages.*
-**Character sheet** — attributes (modifier-forward, LCK still hidden), saving throws, the "in a fight" derived grid, skills, talents, afflictions/conditions, the DM's-notes bio. *§2: state (display) · depends on the stat model · goal: the character, legible.*
### M8 — World systems
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): the **price table in copper** (fixed); 3 map locations + the road, shop stock, the quest chain (variable). **~8 pieces.** The **currency change lands before M7** — store everything in copper, format at the display edge; it has a client consumer (the shell's purse).*
-**World-map travel** — region map, location markers (settlement/dungeon/fog), roads + planned route, danger ratings, travel time, set-out. *§2: state · depends on the shell · goal: move between places.*
-**Shop economy** — buy/sell with a fence: purses, stock/owned, the ~40%-sell loop, gritty disabled-CTA reasons, merchant reaction lines. *§2: state (merchant reaction = a thin authored/AI layer) · depends on inventory · goal: an economy loop.*
-**Quest log** — active/completed/failed tabs, objectives checklists, rewards, tracked quest; reads the canon log's `active_quests` (§11). *§2: state · depends on the quest model · goal: track the story.*
### M9 — Framing, AI-flavor & persistence
-**Save / load + pause / settings** — the pause overlay + data-driven settings; save/load over the canon log + game state (seeds preserved, §10). *§2: state · depends on the systems existing · goal: persistence + framing.*
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): authored fallback per AI surface (§13); shrine + Luck-drift events; humiliation seeds. All fixed. **~10 pieces.***
-**Save / load + pause / settings** — the pause overlay + data-driven settings; save/load over the canon log + game state (seeds preserved, §10). **⛨ saga — the save is two objects, not one:** a durable **`Saga`** (sheet, level, companions + approval, humiliations, deeds, `kings_slain`, `campaigns_completed`, signature item) and a per-campaign **`Campaign`** (`CanonLog` + `GameState` + world + seed). **Humiliations move to the `Saga`** and are *projected* into the canon log — today they live in the log and would die with the campaign. This is the §10 seed argument applied to persistence: cheap now, a rewrite once saves exist in the wild. *§2: state · depends on the systems existing · goal: persistence + framing.*
-**Luck fully wired** — generation exists (Plan B); this adds **drift events** (real bed, shrine blessing, killing something praying), **one shrine**, **one cursed item** (+STR / LCK). Visible only in prose (§7). Gates the Improviser. *§2: state · depends on the item + event systems · goal: the signature system, live.*
-**Improviser**`/dm/improvise`: minor off-script events, **§7-sandboxed by construction** — a validator that only applies whitelisted state changes (dignity, never progress; structurally incapable of ending a quest). Fires on Luck; hooks Cadwyn. *§2: both · depends on Luck being wired · goal: bad luck as a story, not a fine.*
-**Banter**`/party/banter`: companions comment on recent events; low stakes, high charm, **aggressively cacheable**; reads Brannoc's humiliation log (§9), stacking not overwriting. Prose only, no tags. *§2: text · depends on the humiliation log (M0) + events · goal: the comedy loop closes.*

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

View File

@@ -209,6 +209,10 @@ Nothing here silently pre-empts a later brainstorm.
- The **damage-determinism reconcile** (§7/§10 vs the Combat HUD mock's ranges).
- The **borderline band size** (DC bands).
- Leveling — hit-die progression, prof-bonus scaling, talent tiers, XP curve. (Creation is L1.)
**The ceiling is no longer open:** the seven-campaign saga
([spec](2026-07-12-character-reuse-seven-arc-design.md)) fixes it at **L20 across 7
tiers** (tier N ≈ levels 3N2 … 3N). M5 designs the curve *to* that ceiling; it does
not choose it. An open-ended curve is unsolvable and forecloses the saga.
**→ Canon session (lore):**
- The two **[CANON-TBD]** calling names (Barbarian ⚔ / Warlock 🔥).

View File

@@ -0,0 +1,323 @@
# Character reuse & the seven-campaign arc — design
**Date:** 2026-07-12
**Status:** approved (direction); implementation = guardrails inside existing milestones, no new milestone work
Commits the **shape** of character reuse across campaigns, the seven-king meta-arc,
and generation-time difficulty tiering — and names the cheap decisions that must
land inside **M3/M4/M5/M9** and the content build tool so the shape stays
reachable. It authors no content, adds no BOM line, and does not unpark v2
world generation.
---
## Problem
A character is born at level 1, plays one authored campaign, and stops. There is
no structure that lets a character grow *across* worlds, and nothing in the
roadmap forbids one — but several unbuilt milestones are about to make choices
that would quietly foreclose it.
Specifically: **M5 owes a level curve** (the races/classes design says only
"level curve → M5") and **M9 owes a save format**. Both are blank. If M5 picks an
open-ended curve and M9 picks a single save blob, character reuse becomes a
retrofit through the two most expensive systems in the game — the same class of
mistake §10 warns about with the RNG seed.
This is a **direction spec**. Its deliverable is a shape plus a short list of
constraints, not a feature.
## Approach
**One character, seven campaigns, one saga — then it ends.**
Each campaign faces one of the Seven (`content/lore/the-seven.md`). The character,
their companions, their deeds and their level carry from campaign to campaign; the
*world* does not. Difficulty is chosen once, at world-construction time, from the
campaign index — never during play.
Replayability lives at the **character** level, not the campaign level: a new
character is a new saga, a new sequence of worlds, potentially a different order
of kings. Seven campaigns is a finite, satisfying arc that a level curve can
actually be designed against.
Rejected alternatives:
- **Kings as milestones, not chapters** (campaigns not 1:1 with kings) — no natural
level ceiling; "am I making progress" goes fuzzy.
- **Kings unkillable, truly infinite** (canon-compatible: you only break a king's
grip) — makes the level curve unsolvable, and the running joke turns bleak.
- **Full carry of gear and gold** — the tier-N economy has nothing to sell a party
hauling four campaigns of loot; §3's "counting coppers for an inn bed" grit dies.
- **Event-sourced saga** (deeds are the log; the sheet is derived) — elegant,
overkill.
---
## 1. The state model
**The canon-log contract does not change.** No schema bump, no API change, the
proxy never notices. Every field this design needs already exists in the log —
`party[].disposition`, `humiliations[]`, `established_facts[]` — and `player` still
admits only `name` / `class_id` / `luck_descriptor`, so the AI never sees a level.
The §7 boundary holds for free.
What changes is **who fills those fields**.
### Two objects
**`Saga`** — durable, survives campaigns. Its own schema, its own save file.
```
schema_version, saga_id
character: name, race_id, calling_id, level (1..20),
stats {str, dex, con, fth, mag}, luck_base
companions: [{id, approval}] # Cadwyn, Brannoc
humiliations: [{id, text, weight, turn}] # STACKING across campaigns (§9)
deeds: ["…"] # what the world knows you did
kings_slain: ["ghaul_the_yoke", …] # subset of the Seven
campaigns_completed: 0..7 # tier = this + 1
signature_item_id: string | null # the one thing that survives the Interlude
```
**`Campaign`** — per-campaign, discarded between them: `CanonLog` + `GameState` +
the world content set + the RNG seed (§10). Exactly what M9's save/load was
already going to hold.
Rejected: **one save file with two sections** (the boundary becomes a naming
convention rather than a structure). The `Saga` is *also* the future input to
world generation — tier, deeds, kings already slain, party composition — so it
wants to be a standalone serializable object on its own merits.
### Saga invariants
- **One campaign, one king:** `len(kings_slain) == campaigns_completed`. The saga
advances only by killing a king.
- **The saga is complete at `campaigns_completed == 7`.** There is no campaign 8 and
no tier 8. A complete saga is read-only: it can be viewed, not continued. That
character is done, and the retirement finally takes.
### The thesis
> **The `Saga` is the source. The canon log is a projection of the `Saga` into the
> current campaign.**
This is §11's own rule one level up — the canon log is never the source of truth
for the `Saga`, exactly as AI prose is never the source of truth for state. It
gives every carried thing one uniform mechanism instead of five special cases:
| Carried | Projected into | Note |
|---|---|---|
| Companion approval | `party[].disposition` | The `Saga` overrides the origin's `disposition_overrides` |
| Humiliations | `humiliations[]` | **Move:** they currently live in the log and would die with it |
| Deeds | `established_facts[]` | Appended alongside the origin's `opening_facts` |
| Level, stats, `luck_base` | `GameState` | Never the log — the AI sees no number (§7) |
### The seam
`client/scripts/newgame/new_game.gd` already reads three immutable inputs and
writes two products. It gains **one optional fourth input**:
```gdscript
static func construct(origin, world, creation, rng, saga: Saga = null) -> Dictionary
```
- **`saga == null`** → campaign 1. Roll stats, roll Luck, read dispositions from the
origin. **Today's behaviour, byte-for-byte unchanged.**
- **`saga != null`** → campaign N. Stats / level / `luck_base` come from the `Saga`
instead of the dice; companion approval overrides the origin; deeds and
humiliations are injected; the signature item is the only grant that is not the
origin's.
One function, one new parameter, a null default that preserves every existing test.
The character-creation UI is simply not shown on the saga path.
*§2: state. The `Saga` is code-owned. The AI never reads or writes it.*
---
## 2. Tier
`tier = campaigns_completed + 1`, an integer **1..7** (there is no tier 8 — see
*Saga invariants*). It is an input to world construction, evaluated **exactly
once**, at campaign start.
> ### The hard rule
>
> **Difficulty is fixed at generation. Nothing scales during play.**
>
> This is the difference between the design and the Oblivion trap. Once the world
> is built at tier 5 it is *fixed* — this dungeon is deadly, that road is safe, and
> the gradient means something. A world that re-levels as the player levels is a
> treadmill, and it makes leveling meaningless. **This is a prohibition, not a
> preference.**
What tier selects:
- **Which enemies exist.** Deterministic damage (§7) forbids multiplying a stat
block, and that is a gift: a tier-6 world has *different monsters*, not a rat with
40× HP. The cosmology already supplies the ladder — **proximity to a king is the
power scale.** Ghaul's tax collector, then his yoked, then a possessed magistrate,
then one of the Sent, then Ghaul.
- **The level band.** Tier N ≈ levels 3N2 … 3N, capped at 20. The exact numbers are
M5's to tune; the *bands* are the guardrail.
- **Loot, shop stock, and the price table** (§18: the loop is code, the numbers are
content).
- **The king** — one of the Seven, drawn from those not in `kings_slain`.
- **The story-shape.** Tier 1 is a cult cell in a town. Tier 4 is a cult that owns a
city. Tier 7 is the king himself.
**Content-side cost: one field.** Content entries (`item`, `quest`, enemy units)
carry `tier: 1..7`, validated by the build tool. This is schema, not content — it
does not violate the content-track spec's rule ("never author anything that is not
in the Bill of Materials").
*§2: state.*
---
## 3. The Interlude
A state transition between campaigns: `Interlude(saga, completed_campaign) → saga'`.
In fiction: **they retired, and it did not take.** They thought campaign 1 was the
end. Here they are at campaign 5, still at it. Brannoc keeps expecting to die on a
job like this one and keeps not dying, and being dragged back out is both the joke
and the tragedy — §9 deepened, not undercut.
Per §18 this is **two systems**: the transition is *state* (code owns it), the
retirement-that-did-not-take is *text* (the DM narrates it, with an authored
fallback per §13).
What it does:
- **Gold:** mostly gone. **Inventory:** gone — except the one **signature item** the
player chooses to keep.
- **Luck drift** resets to `luck_base`. They slept in real beds for a year.
- **Deeds** harvested from the finished campaign's canon log; `kings_slain` appended.
- **Humiliations merged, never overwritten** (§9 is explicit) — now stacking across
*lives*, not just hours.
- `campaigns_completed += 1`.
- **Level, stats, companions, approval: untouched.**
Two things make this more than bookkeeping.
**It is an Improviser surface, already §7-compliant by construction.** *How* they
lost the money is a Luck-selected outcome. Bad luck: Cadwyn lost the purse at dice
and there was a fire. Good luck: you kept the good armour. The Interlude's validator
whitelist is exactly the §7 line — **gear and coin are dignity; level, deeds,
companions and the signature item are progress.** It is structurally incapable of
taking progress, which is the same guarantee §7 demands of the Improviser, reused
verbatim.
**The signature-item choice is the cursed-blade decision, seven times.** §7 asks
that "the decision to keep or discard a cursed item" be meaningful. *You may keep
exactly one thing* turns the +4 STR / 5 LCK blade into a genuinely bad night's
sleep.
*§2: both — the transition is state, the interlude prose is text.*
---
## 4. Guardrails — what lands in existing milestones
Nothing below adds a milestone or a BOM line. Each item constrains work already
sequenced.
### M3 — Title screen *(unbuilt, next — cheapest possible moment)*
A saga adds a third entry path alongside new-game / continue / load: **begin the
next campaign of an existing saga.** Do not build it. Only avoid hardcoding the flow
as `new game → creation → world` such that a fourth path cannot be added.
### M4 — Character creation
`NewGame.construct` already takes `creation` as a plain `Dictionary`. Keep it that
way: the creation *scene* produces that dict but must not become the only thing that
can. On a saga path the dict is synthesized from the `Saga` and the UI is skipped.
The guardrail is simply: **do not couple `construct` to the creation scene.**
M4 must emit `race_id` and `calling_id` into the creation dict (it will anyway) —
they are `Saga` fields.
### M5 — Tactical combat
- **Design the level curve to a ceiling of 20 across 7 tiers.** The races/classes
design currently says only "level curve → M5". It needs a number, and it needs to
be this one, or campaign 7 discovers the math ran out.
- **Enemy stat blocks carry `tier`. There is no scaling multiplier, and there never
will be.** The M5 BOM line (1 enemy family + boss) is **tier 1** — all the slice
needs.
- Class abilities are planned to L20, not open-ended.
- This design **depends on** the damage-determinism reconcile already flagged for M5
(§7/§10 vs the Combat HUD mock's ranges). Tiering by stat block is what makes
"no scaling" possible.
### M9 — Save / load
- **Two objects, not one:** `Saga` (durable) + `Campaign` (canon log + `GameState` +
world + seed). This is the retrofit that genuinely hurts later — the §10 RNG-seed
argument applied to persistence.
- **Humiliations move to the `Saga`**, projected into the canon log. Today they live
in the log and would die with the campaign.
### Content build tool
When the `item` and `quest` namespaces land (already a sequenced M7/M8 prerequisite
in the content-track design), add **`tier: 1..7`** to the entry envelope, validated.
One field, at a moment the model is already being opened.
### Content roadmap
Mark each BOM line **fixed substrate** (currency, price table, abilities, callings,
cosmology, the Seven, disposition ladder) vs **per-campaign variable** (town, NPCs,
gated knowledge, quest chain, king). The distinction now has a concrete consumer.
---
## 5. The vertical slice is already campaign 1
Greywater Docks, tier 1, patron **Ghaul the Yoke** — exactly what the content-track
design authored, before this spec existed. **Zero change to that spec, zero change to
the slice.** The saga is what happens *after* it.
---
## 6. Testing
- **Regression gate:** `construct(saga = null)` → every existing test passes
byte-for-byte.
- `construct(saga = …)` → level / stats / approval / humiliations / deeds project
correctly; the `Saga` overrides the origin's `disposition_overrides`; only the
signature item is granted.
- **The Interlude invariant, as a property test:** level, deeds, companions and the
signature item can never be reduced. This is the §7 Improviser whitelist expressed
as an assertion.
- **The anti-Oblivion test:** no code path reads `tier` after construction.
- **The saga invariants:** `len(kings_slain) == campaigns_completed`; a `Saga` at
`campaigns_completed == 7` cannot begin a campaign.
- `Saga` round-trips through save/load.
---
## Consequences
- **M5 gains a hard level ceiling (20) and a tier model** — it can no longer pick a
curve that forecloses the saga.
- **M9 gains a two-object save format** before any save exists in the wild.
- **Humiliations become durable** — Brannoc acquires an anthology across lives, not
just a repertoire across hours (§9).
- **The cosmology supplies the difficulty ladder for free** — proximity to a king is
the power scale, so no scaling multiplier is ever needed.
- **The `Saga` is the object v2 world generation will consume.** That is the whole
point of writing this now.
- Difficulty scaling is **structurally prohibited** from becoming a treadmill.
## Out of scope
- **AI world generation stays v2.** This spec does not unpark it. It only guarantees
the `Saga` is its input when it arrives.
- **Tiers 27 of content**, the **other six kings**, and the **seven story-shapes**
Backlog.
- Authoring any content at all. This spec builds a container and a set of
prohibitions.

View File

@@ -0,0 +1,214 @@
# Content track — design
**Date:** 2026-07-12
**Status:** approved (design); implementation = a docs restructure + one code/content item (currency)
Folds the authored content of the game — lore, economy, items, quests, the map —
into a tracked, bounded track alongside the systems roadmap, so the authoring
work is countable rather than open-ended.
---
## Problem
`docs/roadmap.md` sequences **systems** (M0M9). Content appears in it only
implicitly — "item seed-data", "the quest model", "shop economy" — never as work
with a size. `content/lore/canon-roadmap.md` tracks **canon** but is thin: an
"Authored" list and a three-bullet "Pending".
So the content needed for a finished game — lore, an economy, every weapon,
armour piece, consumable and trinket, quests, the map — is untracked and
unbounded. It *looks* infinite, because nothing bounds it. That is the actual
problem to solve: not "write more content", but "make the content finite".
## Approach
**Demand-driven content, gated by the milestone that consumes it.** Content is
not authored to completeness; it is authored to the requirements of the next
system milestone. The cure for overwhelm is not a longer list — it is a list
with an end.
Two roadmaps, cross-linked, with clear ownership. Rejected alternatives:
- **One roadmap** (fold content into `docs/roadmap.md`) — the roadmap becomes
huge, and content that serves no milestone (pure lore) has no home.
- **Content-first** (author the world broadly, then build systems against it) —
authors content no system has yet demanded; the volume is the problem.
- **Content-type tracks** (separate lanes for lore / items / quests / map) —
mirrors how the work *feels*, and is the structure most likely to read as four
infinite parallel lists.
## The slice
The demand comes from a **vertical slice: one town + one dungeon.** The smallest
thing that is a *game*. Every system (creation, combat, dialogue, inventory,
shop, quest log, map) is exercised exactly once, against real content.
Breadth afterwards is **repetition of a proven shape**, not invention. Adding a
second town becomes a known quantity of work against a known template. That is
what ends the overwhelm permanently — not this slice, but what the slice teaches
about the cost of the next one.
Rejected: **one region** (34× the authoring, before the shape is proven) and
**systems-first with placeholder content** (tunes an economy with fake items,
tests dialogue with fake NPCs; every system pays a second pass when real content
lands).
---
## 1. Doc structure
| Doc | Owns |
|---|---|
| `docs/roadmap.md` | **Systems.** M0M9, unchanged in shape. |
| `content/roadmap.md` | **Content.** Spine · Bill of Materials · Backlog. |
- `docs/roadmap.md` gains one line per milestone M4M9: **"Content it consumes →"**,
pointing into the content roadmap's BOM.
- `content/lore/canon-roadmap.md` **moves to `content/roadmap.md`**, leaving a
pointer behind. The move is load-bearing: an items-and-prices table does not
belong under `lore/`, and the file stops being only about canon.
The content roadmap has exactly three sections, and **only one of them is ever
the work**:
1. **Spine** — the slice's world. Authored once; gates everything downstream.
2. **Bill of Materials** — a countable checklist, per milestone, of what that
milestone consumes. Numbers, not vibes.
3. **Backlog** — everything the slice does not need. Named so it is not lost;
parked so it is not looming.
### The rule
> **Never author anything that is not in the Bill of Materials.**
An idea that arrives goes to the Backlog, and is then no longer carried. The
Backlog is not a debt. It is where ideas go to be safe.
---
## 2. The Spine
Greywater Docks and one dungeon — the cosmology (`content/lore/cosmology.md`)
touching ground for the first time.
- **The town** — **Greywater Docks.** A rot-black wharf where the river meets
the sea trade; everyone owes someone. **~6 NPCs:** Fenn (promoted from M2 test
fixture to real NPC), the harbourmaster, a fence, an innkeeper, a dock rough,
one more.
- **The chain** — **"The Missing Ledger,"** extended from the existing hook.
Town → road → dungeon → back. Four objectives.
- **The turn** — the debt Fenn cannot cover is a **blood** debt. The
harbourmaster is named in the ledger because he is the one *collecting*. The
dungeon is the harvest site.
- **The patron** — **Ghaul the Yoke.** He is already, by name, the king of debt
and bondage; a ledger of what men owe is his liturgy. This makes the quest
chain and the cosmology **the same object** rather than two things bolted
together, and gives `rule.blood-price` its first concrete instance.
- **The dungeon** — the harvest site. One enemy family (the yoked) + a boss.
**Adopting Greywater, not replacing it.** Fenn is the NPC the aliveness question
was actually answered with (M2, live-proven): frightened, aggrieved, covering it
with irritation. The ledger hook already has teeth. The slice's job is to make
Greywater *deep*, not to make it *new*.
---
## 3. Bill of Materials
Countable. This is the anti-overwhelm device.
| Milestone | Content it consumes | Count |
|---|---|---|
| **M4** creation | Origins, calling blurbs, the Bloodsworn's patron choice | ~4 |
| **M5** combat | 1 enemy family (4 units) + boss; abilities for the 3 POC classes | ~14 |
| **M6** dialogue | 6 NPCs — persona + `knowledge[]` + gated topics (§6) | ~6 |
| **M7** inventory | 6 weapons, 4 armour, 3 consumables, **1 cursed item** (+STR / LCK — §7 says day-one) | ~14 |
| **M8** world | 3 map locations + the road; shop stock + the **price table in copper**; the quest chain | ~8 |
| **M9** flavour | Authored fallback per AI surface (§13); shrine + Luck-drift events; humiliation seeds | ~10 |
**≈56 discrete authored pieces for a complete, playable game.** An estimate, and
likely optimistic — but an estimate with an end, which is the entire point.
Explicitly **not** in the slice: rings, amulets, trinkets. A second town. The
other six kings and their cults. All parked in the Backlog.
---
## 4. Economy — two systems (§18)
The economy is not one thing. Per charter §18 ("when adding a system, state which
side of §2 it falls on — if it is both, it is two systems"):
- **The loop is code** (§2: state) — purse, buy/sell, the ~40% fence rate,
gritty disabled-CTA reasons. Already sequenced as M8's *Shop economy*.
- **The numbers are content** — what a shortsword costs, a night at the inn, an
ale, the quest's purse. These live in the BOM as the **price table**.
### Currency model
```
1 gold = 100 silver = 10,000 copper
1 silver = 100 copper
```
**Gold is scary.** At 10,000 copper to the gold, a peasant's whole life is
copper, a mercenary's fee is silver, and a gold coin is a plot point. The party
counting coppers for an inn bed is §3 grit, and Brannoc has an opinion about it.
**This is a change, not a new thing — and it has a client consumer.** It lands as
its own small item **before M7**, cheapest now while `content/world/items/`
contains exactly two files:
- A `Currency` value type — **store everything in copper**, format only at the
display edge (`12g 40s 8c`).
- `coin.json` → real denominations; all prices re-denominated.
- The Main Window shell's command-bar purse updated (it currently shows a flat
gold value).
*§2: state · depends on nothing · goal: money that means something.*
---
## 5. Process
- Each BOM line is authored through the **`/world-building` skill** — it
reconciles against existing canon, writes the build tool's source format, and
verifies the build stays green.
- **Adopting Greywater means authoring it upward.** The hand-written JSON under
`content/world/{locations,quests,npcs,items}` becomes
`content/lore/greywater.md`, and `python -m content_build` emits `world/` from
it.
- **The build tool needs two new namespaces: `item` and `quest`.** Its legal
namespaces today (`tools/content_build/model.py`) are `town`, `place`,
`region`, `faction`, `person`, `rule`, `rumor`, `fact`, `secret`, `npc` — so
**locations are already modeled** (`town`/`place`/`region`) and only items and
quests fall outside it. Adding those two namespaces (model, emit, resolve) is a
prerequisite of the M7 and M8 BOM lines, and is done against content that
already has tests.
- **Moving or deleting generated content is a client change.** `content/world/**`
has a client consumer: `client/scripts/content/content_db.gd` (via
`client/scripts/harness/npc_harness.gd`) and its GUT tests in
`client/tests/unit/test_content_db.gd`. The Duncarrow lesson, applied
preemptively — check `client/` before any content move.
---
## Consequences
- Content becomes **countable and bounded**. "All the items" becomes fourteen.
- The cosmology stops being abstract — Ghaul, the blood-price, and possession all
land in the slice's quest chain.
- The build tool gains `item` and `quest` namespaces, completing the content
model (locations already exist as `town`/`place`/`region`).
- The currency change jumps the queue ahead of M4, because it is cheapest while
the item set is nearly empty.
- The Backlog absorbs everything the slice does not need, so it can be forgotten
without being lost.
## Out of scope
Authoring the slice's actual content — this spec builds the **container**. Each
BOM line is its own `/world-building` session. The Greywater canon session (town,
NPCs, the ledger chain's four objectives, Ghaul's harvest site) is the next
piece of work, not part of this one.

View File

@@ -0,0 +1,342 @@
# 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 exists for non-currency moves and predates this work**
(three `give_item(amulet)` tags yield three amulets; a repeated `offer_quest` can
double-add a quest). It is bounded *across* replies by `gifts_given` / `has_quest`, but
not *within* one. Deliberately left alone here — it is not a currency bug. **Open.**
**`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.