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