diff --git a/.claude/skills/world-building/references/schema.md b/.claude/skills/world-building/references/schema.md index 2ebea44..0bd49f7 100644 --- a/.claude/skills/world-building/references/schema.md +++ b/.claude/skills/world-building/references/schema.md @@ -252,7 +252,7 @@ construction fails loudly. "disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 }, "inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }], "start_quest_id": "find_the_ledger", - "build_constraints": { "allowed_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 } + "build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 } } ``` diff --git a/api/tests/test_canon_log_schema.py b/api/tests/test_canon_log_schema.py index c5b3908..24b248a 100644 --- a/api/tests/test_canon_log_schema.py +++ b/api/tests/test_canon_log_schema.py @@ -68,3 +68,20 @@ def test_negative_turn_is_rejected(): doc = _valid() doc["humiliations"][0]["turn"] = -1 assert validate_canon_log(doc) != [] + + +def test_dead_class_id_field_is_rejected(): + # The player block migrated from {name, class_id, luck_descriptor} to + # {name, race_id, calling_id, luck_descriptor}. class_id must not slip + # back in via additionalProperties: false. + doc = _valid() + doc["player"]["class_id"] = "sellsword" + assert validate_canon_log(doc) != [] + + +def test_unknown_calling_id_is_rejected(): + # assassin/priest are dead ids from the pre-migration class roster and + # must not validate as calling_id values. + doc = _valid() + doc["player"]["calling_id"] = "priest" + assert validate_canon_log(doc) != [] diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 8dbffd3..bc6f765 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -126,8 +126,19 @@ static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary if calling_id not in allowed: errors.append("calling not allowed by origin: %s" % calling_id) - errors.append_array(_validate_spend(creation.get("spend", {}))) - if Races.exists(race_id) and Callings.exists(calling_id): + # Guard container SHAPES before any statically-typed call below touches them. + # A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} — + # without this check, assigning that into a typed Dictionary/Array parameter + # throws a GDScript runtime error INSIDE construct() instead of landing in the + # front-loaded {"ok": false, "errors": [...]} the spec promises. + if typeof(creation.get("spend", {})) != TYPE_DICTIONARY: + errors.append("spend must be an object") + else: + errors.append_array(_validate_spend(creation.get("spend", {}))) + + if typeof(creation.get("skills", [])) != TYPE_ARRAY: + errors.append("skills must be an array") + elif Races.exists(race_id) and Callings.exists(calling_id): errors.append_array(_validate_skills(creation, race_id, calling_id)) return errors diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index c6a1963..f321786 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -125,3 +125,8 @@ func test_blurb_content_carries_no_mechanics(): for banned in ["hit_die", "saves", "skill_pool", "skill_count", "armor", "talent"]: assert_false(db.callings[id].has(banned), "%s.json leaks the mechanic '%s' — that lives in Callings" % [id, banned]) + for id in db.races: + for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags", + "nightsight", "claws", "keen_scent"]: + assert_false(db.races[id].has(banned), + "%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned]) diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 8fe8d83..c0d0f98 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -274,6 +274,21 @@ func test_empty_name_is_rejected(): assert_null(whitespace_name["log"]) +func test_a_null_spend_is_rejected_not_crashed(): + # A JSON round-trip can hand back {"spend": null}. Without a container-type + # guard, assigning null into a statically-typed Dictionary parameter throws + # a GDScript runtime error INSIDE construct() instead of a front-loaded error. + var res := _build(_creation({"spend": null})) + assert_false(res["ok"]) + assert_true("spend" in str(res["errors"]).to_lower()) + + +func test_non_array_skills_is_rejected_not_crashed(): + var res := _build(_creation({"skills": "athletics"})) + assert_false(res["ok"]) + assert_true("skills" in str(res["errors"]).to_lower()) + + func test_non_companion_override_goes_to_state_not_log(): world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"} var o := _deserter() diff --git a/docs/canon-log.md b/docs/canon-log.md index b090df6..b7c99f6 100644 --- a/docs/canon-log.md +++ b/docs/canon-log.md @@ -27,7 +27,7 @@ Full field rules live in `canon-log.schema.json`. Summary: ```json { "schema_version": 1, - "player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" }, + "player": { "name": "…", "race_id": "human|elf|dwarf|beastfolk", "calling_id": "sellsword|reaver|cutpurse|trapper|hedge_mage|bonesetter|bloodsworn", "luck_descriptor": "…" }, "location": { "id": "…", "name": "…" }, "party": [ { "id": "…", "name": "…", "disposition": -100..100 } ], "recent_events": ["… (≤5, rolling)"], @@ -39,9 +39,13 @@ Full field rules live in `canon-log.schema.json`. Summary: **The §7 boundary (enforced structurally).** The schema sets `additionalProperties: false` on the root and on `player`, and `player` admits -only `name`, `class_id`, `luck_descriptor`. **Numeric Luck, stats, HP/MP, and -inventory cannot be represented in the canon log** — the AI never sees a number -it could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on +only `name`, `race_id`, `calling_id`, `luck_descriptor`. `race_id` and +`calling_id` are there so the Narrator and NPCs can *describe* the player (a +"beastfolk cutpurse" reads different scene text than a "dwarf bonesetter") — +they are identity for prose, not mechanics. **Numeric Luck, stats, HP/MP, and +inventory cannot be represented in the canon log** — the mechanical sheet stays +in `GameState` and never reaches the log, and the AI never sees a number it +could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on you") crosses the boundary. All string ids match `^[a-z0-9_]+$`. @@ -59,7 +63,7 @@ Full rules in `origin.schema.json`. An origin seeds the initial log: "disposition_overrides": { "": -100..100 }, "inventory_grants": [ { "item_id": "…", "qty": 1.. } ], "start_quest_id": "…|null", - "build_constraints": { "allowed_classes": ["sellsword",…], "luck_modifier": 0 } + "build_constraints": { "allowed_callings": ["sellsword",…], "luck_modifier": 0 } } ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 8971edd..9972bb3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -75,7 +75,9 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups ( ### M4 — Character creation *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. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built). **Two contract migrations land here, not silently:** the canon log gains a `race` field (the Narrator needs it to describe the player), and `content/origins/deserter.json` still carries the dead `allowed_classes: ["sellsword","assassin","priest"]`. **⛨ 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.* +- ✅ **Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree. +- ○ **Character creation UI (M4-b)** — 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. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built, and already emits `race_id` + `calling_id` per the migration above). **⛨ 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). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.* +- ○ **Title → creation → shell flow (M4-c)** — wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.* ### M5 — Tactical combat (gridless) *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.***