TDD, green suite at every boundary. Task 4 is deliberately one cross-cutting commit: the origin's key and the log's player field are read on both sides of the HTTP boundary, so renaming them in separate tasks would leave a suite red at the seam. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
1708 lines
64 KiB
Markdown
1708 lines
64 KiB
Markdown
# M4-a — Character Creation Model 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:** Build the rules a character is made of — races, callings, skills, and a derived character sheet — and make `NewGame.construct` a seeded, reproducible creation pipeline. No UI.
|
||
|
||
**Architecture:** Race/calling/skill **mechanics** become static tables in code (`client/scripts/rules/`), shaped exactly like the existing `Luck` and `Currency`. Their **blurbs** are hand-written content (`content/world/callings/`, `races/`), loaded by `ContentDB` like `items/`, guarded by a parity test. `CharacterSheet` stores only what was rolled or chosen and **derives** everything else. The creation `Dictionary` carries a **seed, not a stat block** — `construct` reconstructs the RNG and rolls the attributes itself, so the same seed always yields the same character and the UI cannot inject a number.
|
||
|
||
**Tech Stack:** Godot 4.7 / GDScript, GUT (headless, `client/run_tests.sh`). Python / FastAPI / pytest for the API. Hand-written JSON content. JSON Schema for the cross-boundary contract.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-12-creation-model-design.md` — read it before starting. Everything below implements it.
|
||
|
||
## Global Constraints
|
||
|
||
- **Charter §2 — code owns state.** `construct` never trusts numbers handed to it. It re-derives them from the seed.
|
||
- **Charter §7 — LCK is not on the sheet.** No save, no skill, no row, no accessor. It stays in `GameState.luck` / `luck_base`. LCK is **never spendable** at creation. Numeric Luck must never reach the canon log.
|
||
- **Charter §10 — seeded.** Same `seed` + same choices → identical character, including the hidden LCK. The RNG order (**five attributes in the order `str · dex · con · fth · mag`, then LCK**) is part of the contract, not an implementation detail.
|
||
- **The roll is 3d6 with a hard floor of 8** — `maxi(8, 3d6)`.
|
||
- **The spend pool is +3 total, additive only.** Non-negative integers, sum ≤ 3, spread across any of the five attributes.
|
||
- **Rules live in code; blurbs live in content.** The item JSONs carry `id` + `name` + `blurb` and **no mechanics**. A second copy of a hit die in content would be a second source of truth.
|
||
- **The saga guardrail:** `NewGame.construct` takes `creation` as a **plain `Dictionary`**. A later saga synthesizes one and skips the UI, so the creation *scene* must never become the only thing that can produce it.
|
||
- **The 7 callings:** `sellsword` · `reaver` · `cutpurse` · `trapper` · `hedge_mage` · `bonesetter` · `bloodsworn`. **The 4 races:** `human` · `elf` · `dwarf` · `beastfolk`.
|
||
- **Charter §18 — git.** Code, so it lands on `feat/creation-model` off `dev`. Never `master`. Merge only after the human confirms.
|
||
- **The client suite must be green at the end of every task.** From `client/`: `./run_tests.sh` (188 tests before this plan; the `-gtest=` flag is ignored — the repo's `.gutconfig.json` always runs the whole suite).
|
||
- **The API suite must be green at the end of every task that touches `api/`.** From `api/`: `venv/bin/pytest`.
|
||
- **The content build must stay green.** From the repo root: `PYTHONPATH=tools python3 -m content_build --check`.
|
||
- Godot writes `.uid` sidecars for new scripts and this repo tracks them — stage any that appear.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `client/scripts/rules/attributes.gd` | **new.** The five attribute ids + `modifier(score)`. The one place `floor((score−10)/2)` lives. |
|
||
| `client/scripts/rules/skills.gd` | **new.** The 9 skills → governing attribute. |
|
||
| `client/scripts/rules/races.gd` | **new.** The 4 races as feature flags. No race touches an attribute score. |
|
||
| `client/scripts/rules/callings.gd` | **new.** The 7 callings: hit die, saves, skill pool + count, resource, armor, talent. |
|
||
| `content/world/callings/*.json`, `content/world/races/*.json` | **new.** Blurbs only — `{id, name, blurb}`. Placeholders now; authored prose later. |
|
||
| `client/scripts/content/content_db.gd` | Loads `callings/` + `races/`. |
|
||
| `client/scripts/state/character_sheet.gd` | **new.** Stores rolled/chosen inputs + current hp/mp; derives everything else. |
|
||
| `client/scripts/state/game_state.gd` | `stats` Dictionary → `sheet: CharacterSheet`. |
|
||
| `client/scripts/canon_log/log_player.gd` | `class_id` → `calling_id`; adds `race_id`; validates against `Callings.IDS` / `Races.IDS`. |
|
||
| `client/scripts/newgame/new_game.gd` | The seeded creation pipeline. |
|
||
| `docs/schemas/canon-log.schema.json` | `calling_id` (enum of 7) + required `race_id` (enum of 4). |
|
||
| `docs/schemas/origin.schema.json` | `allowed_classes` → `allowed_callings` (enum of 7). |
|
||
| `content/origins/deserter.json` | `allowed_callings`, all seven. |
|
||
| `api/app/prompts.py` | Renders the player's **race and calling** into the Narrator/NPC digests. |
|
||
|
||
**Task order and why:** the tables (T1) and their content (T2) come first because everything reads them. The sheet (T3) needs the tables. **T4 is a single cross-cutting rename** — the schema, the origin JSON, the client, and the API must move *together*, because the origin's key and the log's field are read on both sides of the HTTP boundary; splitting them leaves a suite red at the boundary. T5 then swaps the creation pipeline in behind the already-renamed contract.
|
||
|
||
---
|
||
|
||
## Task 0: Branch
|
||
|
||
- [ ] **Step 1: Cut the branch off `dev`**
|
||
|
||
```bash
|
||
git checkout dev
|
||
git status --porcelain # expect: clean
|
||
git checkout -b feat/creation-model
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: The rules tables
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/rules/attributes.gd`, `client/scripts/rules/skills.gd`, `client/scripts/rules/races.gd`, `client/scripts/rules/callings.gd`
|
||
- Test: `client/tests/unit/test_rules_tables.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces — every later task depends on these exact names:
|
||
- `Attributes.IDS` → `["str","dex","con","fth","mag"]`; `Attributes.modifier(score: int) -> int`
|
||
- `Skills.exists(skill: String) -> bool`; `Skills.attribute(skill: String) -> String`; `Skills.IDS -> Array`
|
||
- `Races.IDS -> Array`; `Races.exists(id) -> bool`; `Races.save_bonus(id) -> int`; `Races.granted_skills(id) -> Array`; `Races.wants_bonus_skill(id) -> bool`; `Races.poison_save_bonus(id) -> int`; `Races.flag(id, name: String) -> bool`
|
||
- `Callings.IDS -> Array`; `Callings.exists(id) -> bool`; `Callings.hit_die(id) -> int`; `Callings.saves(id) -> Array`; `Callings.skill_pool(id) -> Array`; `Callings.skill_count(id) -> int`; `Callings.is_caster(id) -> bool`; `Callings.casting_stat(id) -> String`; `Callings.primary(id) -> String`; `Callings.armor(id) -> String`; `Callings.talent(id) -> String`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_rules_tables.gd`. These classes are reached by global `class_name` (the project has **no autoloads**); `run_tests.sh` runs `godot --headless --import` first, which is what makes a brand-new `class_name` resolve.
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
|
||
func test_modifier_at_the_boundaries():
|
||
assert_eq(Attributes.modifier(7), -2)
|
||
assert_eq(Attributes.modifier(8), -1)
|
||
assert_eq(Attributes.modifier(9), -1)
|
||
assert_eq(Attributes.modifier(10), 0)
|
||
assert_eq(Attributes.modifier(11), 0)
|
||
assert_eq(Attributes.modifier(12), 1)
|
||
assert_eq(Attributes.modifier(18), 4)
|
||
|
||
|
||
func test_the_five_attributes_exclude_luck():
|
||
assert_eq(Attributes.IDS, ["str", "dex", "con", "fth", "mag"])
|
||
assert_false("lck" in Attributes.IDS, "LCK is never an attribute row (§7)")
|
||
|
||
|
||
func test_nine_skills_each_governed_by_an_attribute():
|
||
assert_eq(Skills.IDS.size(), 9)
|
||
for s in Skills.IDS:
|
||
assert_true(Skills.attribute(s) in Attributes.IDS, "%s has no governing attribute" % s)
|
||
|
||
|
||
func test_perception_is_faith_and_sorcery_is_magic():
|
||
assert_eq(Skills.attribute("perception"), "fth")
|
||
assert_eq(Skills.attribute("sorcery"), "mag")
|
||
assert_false(Skills.exists("lockpicking"))
|
||
|
||
|
||
func test_four_races():
|
||
assert_eq(Races.IDS, ["human", "elf", "dwarf", "beastfolk"])
|
||
|
||
|
||
func test_human_gets_a_save_bonus_and_a_skill_choice():
|
||
assert_eq(Races.save_bonus("human"), 1)
|
||
assert_true(Races.wants_bonus_skill("human"))
|
||
assert_eq(Races.granted_skills("human"), [])
|
||
|
||
|
||
func test_elf_is_granted_perception_and_nightsight():
|
||
assert_eq(Races.granted_skills("elf"), ["perception"])
|
||
assert_true(Races.flag("elf", "nightsight"))
|
||
assert_false(Races.wants_bonus_skill("elf"))
|
||
|
||
|
||
func test_dwarf_poison_bonus_is_situational_not_a_save_bonus():
|
||
assert_eq(Races.poison_save_bonus("dwarf"), 2)
|
||
assert_eq(Races.save_bonus("dwarf"), 0, "the +2 is vs poison only — it is NOT a flat save bonus")
|
||
|
||
|
||
func test_beastfolk_has_claws_and_scent():
|
||
assert_true(Races.flag("beastfolk", "claws"))
|
||
assert_true(Races.flag("beastfolk", "keen_scent"))
|
||
|
||
|
||
func test_seven_callings():
|
||
assert_eq(Callings.IDS,
|
||
["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"])
|
||
|
||
|
||
func test_hit_dice():
|
||
assert_eq(Callings.hit_die("reaver"), 12)
|
||
assert_eq(Callings.hit_die("sellsword"), 10)
|
||
assert_eq(Callings.hit_die("hedge_mage"), 6)
|
||
|
||
|
||
func test_casters_have_a_casting_stat_and_martials_do_not():
|
||
for id in ["hedge_mage", "bonesetter", "bloodsworn"]:
|
||
assert_true(Callings.is_caster(id), "%s is a caster" % id)
|
||
assert_true(Callings.casting_stat(id) in Attributes.IDS)
|
||
for id in ["sellsword", "reaver", "cutpurse", "trapper"]:
|
||
assert_false(Callings.is_caster(id), "%s lives on cooldowns" % id)
|
||
assert_eq(Callings.casting_stat(id), "")
|
||
|
||
|
||
func test_cutpurse_picks_four_skills_trapper_three_rest_two():
|
||
assert_eq(Callings.skill_count("cutpurse"), 4)
|
||
assert_eq(Callings.skill_count("trapper"), 3)
|
||
for id in ["sellsword", "reaver", "hedge_mage", "bonesetter", "bloodsworn"]:
|
||
assert_eq(Callings.skill_count(id), 2, "%s picks 2" % id)
|
||
|
||
|
||
func test_every_skill_pool_is_real_skills_and_big_enough_to_choose_from():
|
||
for id in Callings.IDS:
|
||
var pool: Array = Callings.skill_pool(id)
|
||
for s in pool:
|
||
assert_true(Skills.exists(s), "%s pool has a bogus skill: %s" % [id, s])
|
||
assert_true(pool.size() > Callings.skill_count(id),
|
||
"%s must have more pool than picks or the choice is fake" % id)
|
||
|
||
|
||
func test_every_calling_saves_are_real_attributes():
|
||
for id in Callings.IDS:
|
||
assert_eq(Callings.saves(id).size(), 2)
|
||
for s in Callings.saves(id):
|
||
assert_true(s in Attributes.IDS)
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
Run from `client/`:
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: FAIL — the parser cannot resolve `Attributes` / `Skills` / `Races` / `Callings`.
|
||
|
||
- [ ] **Step 3: Write `attributes.gd`**
|
||
|
||
```gdscript
|
||
class_name Attributes
|
||
extends RefCounted
|
||
## The five attribute rows and the one modifier formula. LCK is deliberately NOT
|
||
## here — it is never an attribute row, never a save, never a skill (§7). It lives
|
||
## in GameState.luck.
|
||
|
||
const IDS := ["str", "dex", "con", "fth", "mag"]
|
||
|
||
|
||
static func exists(id: String) -> bool:
|
||
return id in IDS
|
||
|
||
|
||
static func modifier(score: int) -> int:
|
||
return floori((score - 10) / 2.0)
|
||
```
|
||
|
||
- [ ] **Step 4: Write `skills.gd`**
|
||
|
||
```gdscript
|
||
class_name Skills
|
||
extends RefCounted
|
||
## The 9 skills and the attribute each is governed by (races-and-classes spec §2).
|
||
## Perception sits under FTH, not a standalone WIS — §8's FTH/MAG replace WIS/INT,
|
||
## so a Bonesetter is also the party's best lookout. LCK owns no skill (§7).
|
||
|
||
const BY_ATTRIBUTE := {
|
||
"athletics": "str",
|
||
"intimidation": "str",
|
||
"stealth": "dex",
|
||
"sleight_of_hand": "dex",
|
||
"acrobatics": "dex",
|
||
"endurance": "con",
|
||
"perception": "fth",
|
||
"faith_lore": "fth",
|
||
"sorcery": "mag",
|
||
}
|
||
|
||
const IDS := ["athletics", "intimidation", "stealth", "sleight_of_hand", "acrobatics",
|
||
"endurance", "perception", "faith_lore", "sorcery"]
|
||
|
||
|
||
static func exists(skill: String) -> bool:
|
||
return BY_ATTRIBUTE.has(skill)
|
||
|
||
|
||
static func attribute(skill: String) -> String:
|
||
return str(BY_ATTRIBUTE.get(skill, ""))
|
||
```
|
||
|
||
- [ ] **Step 5: Write `races.gd`**
|
||
|
||
```gdscript
|
||
class_name Races
|
||
extends RefCounted
|
||
## The 4 races (races-and-classes spec §3). FEATURES ONLY — no race touches an
|
||
## attribute score, which is what keeps the roll + spend pure.
|
||
##
|
||
## nightsight / claws / keen_scent are FLAGS, not numbers. Combat (M5) and the DM's
|
||
## exploration checks consume them; creation only stores them.
|
||
##
|
||
## The dwarf's +2 is SITUATIONAL (vs poison and the affliction track), so it is NOT
|
||
## a flat save bonus — baking a conditional into a flat number is how a sheet starts
|
||
## lying. It is exposed separately as poison_save_bonus().
|
||
|
||
const IDS := ["human", "elf", "dwarf", "beastfolk"]
|
||
|
||
const TABLE := {
|
||
"human": {
|
||
"save_bonus": 1, "granted_skills": [], "bonus_skill_choice": true,
|
||
"poison_save_bonus": 0,
|
||
"flags": {"nightsight": false, "claws": false, "keen_scent": false},
|
||
},
|
||
"elf": {
|
||
"save_bonus": 0, "granted_skills": ["perception"], "bonus_skill_choice": false,
|
||
"poison_save_bonus": 0,
|
||
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
|
||
},
|
||
"dwarf": {
|
||
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
|
||
"poison_save_bonus": 2,
|
||
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
|
||
},
|
||
"beastfolk": {
|
||
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
|
||
"poison_save_bonus": 0,
|
||
"flags": {"nightsight": false, "claws": true, "keen_scent": true},
|
||
},
|
||
}
|
||
|
||
|
||
static func exists(id: String) -> bool:
|
||
return TABLE.has(id)
|
||
|
||
|
||
static func save_bonus(id: String) -> int:
|
||
return int(TABLE.get(id, {}).get("save_bonus", 0))
|
||
|
||
|
||
static func granted_skills(id: String) -> Array:
|
||
return TABLE.get(id, {}).get("granted_skills", [])
|
||
|
||
|
||
static func wants_bonus_skill(id: String) -> bool:
|
||
return bool(TABLE.get(id, {}).get("bonus_skill_choice", false))
|
||
|
||
|
||
static func poison_save_bonus(id: String) -> int:
|
||
return int(TABLE.get(id, {}).get("poison_save_bonus", 0))
|
||
|
||
|
||
static func flag(id: String, name: String) -> bool:
|
||
return bool(TABLE.get(id, {}).get("flags", {}).get(name, false))
|
||
```
|
||
|
||
- [ ] **Step 6: Write `callings.gd`**
|
||
|
||
```gdscript
|
||
class_name Callings
|
||
extends RefCounted
|
||
## The 7 callings (races-and-classes spec §4). A calling is what a VILLAGE calls you,
|
||
## not what a rulebook calls you — Cutpurse, not Assassin; Bonesetter, not Priest.
|
||
##
|
||
## The skill POOLS are this spec's addition: the races/classes spec said only "drawn
|
||
## from the class's stat-appropriate slice" and never enumerated them. Each pool is the
|
||
## calling's primary-stat skills plus a small adjacent slice, always larger than the
|
||
## pick count so the choice is real.
|
||
##
|
||
## L1 talents are ID STUBS. M5 implements them. armor is a proficiency CATEGORY; real
|
||
## armor is an item (M7).
|
||
|
||
const IDS := ["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"]
|
||
|
||
const TABLE := {
|
||
"sellsword": {
|
||
"primary": "str", "hit_die": 10, "saves": ["str", "con"],
|
||
"skill_pool": ["athletics", "intimidation", "endurance", "perception"], "skill_count": 2,
|
||
"casting_stat": "", "armor": "heavy", "talent": "second_wind",
|
||
},
|
||
"reaver": {
|
||
"primary": "str", "hit_die": 12, "saves": ["str", "con"],
|
||
"skill_pool": ["athletics", "intimidation", "endurance", "acrobatics"], "skill_count": 2,
|
||
"casting_stat": "", "armor": "medium", "talent": "blood_fury",
|
||
},
|
||
"cutpurse": {
|
||
"primary": "dex", "hit_die": 8, "saves": ["dex", "mag"],
|
||
"skill_pool": ["stealth", "sleight_of_hand", "acrobatics", "perception", "athletics", "intimidation"],
|
||
"skill_count": 4,
|
||
"casting_stat": "", "armor": "light", "talent": "backstab",
|
||
},
|
||
"trapper": {
|
||
"primary": "dex", "hit_die": 10, "saves": ["dex", "con"],
|
||
"skill_pool": ["stealth", "acrobatics", "perception", "endurance", "athletics"], "skill_count": 3,
|
||
"casting_stat": "", "armor": "light", "talent": "set_snare",
|
||
},
|
||
"hedge_mage": {
|
||
"primary": "mag", "hit_die": 6, "saves": ["mag", "fth"],
|
||
"skill_pool": ["sorcery", "perception", "sleight_of_hand", "faith_lore"], "skill_count": 2,
|
||
"casting_stat": "mag", "armor": "none", "talent": "hexbolt",
|
||
},
|
||
"bonesetter": {
|
||
"primary": "fth", "hit_die": 8, "saves": ["fth", "con"],
|
||
"skill_pool": ["faith_lore", "perception", "endurance", "intimidation"], "skill_count": 2,
|
||
"casting_stat": "fth", "armor": "medium", "talent": "mend",
|
||
},
|
||
"bloodsworn": {
|
||
"primary": "fth", "hit_die": 8, "saves": ["fth", "mag"],
|
||
"skill_pool": ["faith_lore", "sorcery", "intimidation", "perception"], "skill_count": 2,
|
||
"casting_stat": "fth", "armor": "light", "talent": "pact_mark",
|
||
},
|
||
}
|
||
|
||
|
||
static func exists(id: String) -> bool:
|
||
return TABLE.has(id)
|
||
|
||
|
||
static func primary(id: String) -> String:
|
||
return str(TABLE.get(id, {}).get("primary", ""))
|
||
|
||
|
||
static func hit_die(id: String) -> int:
|
||
return int(TABLE.get(id, {}).get("hit_die", 0))
|
||
|
||
|
||
static func saves(id: String) -> Array:
|
||
return TABLE.get(id, {}).get("saves", [])
|
||
|
||
|
||
static func skill_pool(id: String) -> Array:
|
||
return TABLE.get(id, {}).get("skill_pool", [])
|
||
|
||
|
||
static func skill_count(id: String) -> int:
|
||
return int(TABLE.get(id, {}).get("skill_count", 0))
|
||
|
||
|
||
static func casting_stat(id: String) -> String:
|
||
return str(TABLE.get(id, {}).get("casting_stat", ""))
|
||
|
||
|
||
static func is_caster(id: String) -> bool:
|
||
return casting_stat(id) != ""
|
||
|
||
|
||
static func armor(id: String) -> String:
|
||
return str(TABLE.get(id, {}).get("armor", ""))
|
||
|
||
|
||
static func talent(id: String) -> String:
|
||
return str(TABLE.get(id, {}).get("talent", ""))
|
||
```
|
||
|
||
- [ ] **Step 7: Run the tests to verify they pass**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: all green (188 prior + 14 new).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add client/scripts/rules/ client/tests/unit/test_rules_tables.gd
|
||
git commit -m "feat(rules): the race, calling, skill and attribute tables
|
||
|
||
Mechanics are rules and live in code, beside Luck and Currency. The skill pools
|
||
are new — the races/classes spec said only 'the stat-appropriate slice' and never
|
||
enumerated them; each pool is larger than its pick count so the choice is real.
|
||
|
||
The dwarf's +2 vs poison is exposed separately from save() rather than folded
|
||
into it: baking a conditional into a flat number is how a sheet starts lying."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: The blurbs (content) + `ContentDB`
|
||
|
||
**Files:**
|
||
- Create: `content/world/callings/{sellsword,reaver,cutpurse,trapper,hedge_mage,bonesetter,bloodsworn}.json`
|
||
- Create: `content/world/races/{human,elf,dwarf,beastfolk}.json`
|
||
- Modify: `client/scripts/content/content_db.gd`
|
||
- Test: `client/tests/unit/test_content_db.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Callings.IDS`, `Races.IDS` (Task 1).
|
||
- Produces: `ContentDB.callings` / `ContentDB.races` (Dictionaries keyed by id); `ContentDB.calling(id) -> Dictionary`, `ContentDB.race(id) -> Dictionary`, `ContentDB.has_calling(id) -> bool`, `ContentDB.has_race(id) -> bool`.
|
||
|
||
These are **hand-written** directories, like `content/world/items/` — **not** emitted by `content_build`, so no build-tool namespace is needed. The blurbs are **placeholders**; authored prose replaces them later without touching code. That split is exactly what stops the content BOM from blocking this milestone.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `client/tests/unit/test_content_db.gd`:
|
||
|
||
```gdscript
|
||
func test_loads_calling_and_race_content():
|
||
assert_true(db.has_calling("reaver"))
|
||
assert_true(db.has_calling("bloodsworn"))
|
||
assert_true(db.has_race("beastfolk"))
|
||
assert_eq(db.calling("reaver")["name"], "Reaver")
|
||
assert_eq(db.race("beastfolk")["name"], "Beastfolk")
|
||
|
||
|
||
func test_calling_content_matches_the_code_table():
|
||
# The roster exists in code (Callings.IDS — mechanics are rules) and in content
|
||
# (the blurbs). Nothing at runtime reconciles them, so guard against drift.
|
||
var content_ids: Array = db.callings.keys()
|
||
content_ids.sort()
|
||
var code_ids: Array = Callings.IDS.duplicate()
|
||
code_ids.sort()
|
||
assert_eq(content_ids, code_ids)
|
||
|
||
|
||
func test_race_content_matches_the_code_table():
|
||
var content_ids: Array = db.races.keys()
|
||
content_ids.sort()
|
||
var code_ids: Array = Races.IDS.duplicate()
|
||
code_ids.sort()
|
||
assert_eq(content_ids, code_ids)
|
||
|
||
|
||
func test_blurb_content_carries_no_mechanics():
|
||
# Mechanics are code. A hit_die in a content file is a second source of truth.
|
||
for id in db.callings:
|
||
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])
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: FAIL — `has_calling` does not exist on `ContentDB`.
|
||
|
||
- [ ] **Step 3: Write the seven calling blurbs**
|
||
|
||
Placeholder prose in the project's register (§3: gritty, dry, no winking). Each file is exactly `{id, name, blurb}` — **no mechanics**.
|
||
|
||
`content/world/callings/sellsword.json`:
|
||
```json
|
||
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." }
|
||
```
|
||
|
||
`content/world/callings/reaver.json`:
|
||
```json
|
||
{ "id": "reaver", "name": "Reaver", "blurb": "They hire you when they want a door opened and do not care about the door. You do not wear plate. You have never needed it." }
|
||
```
|
||
|
||
`content/world/callings/cutpurse.json`:
|
||
```json
|
||
{ "id": "cutpurse", "name": "Cutpurse", "blurb": "Purses, locks, confidences — you have taken all three. The trick was never the hands. It was knowing which pocket was worth it." }
|
||
```
|
||
|
||
`content/world/callings/trapper.json`:
|
||
```json
|
||
{ "id": "trapper", "name": "Trapper", "blurb": "You worked the treelines until the treelines ran out of anything worth taking. A snare does the waiting for you, and you have learned to wait." }
|
||
```
|
||
|
||
`content/world/callings/hedge_mage.json`:
|
||
```json
|
||
{ "id": "hedge_mage", "name": "Hedge-Mage", "blurb": "No tower took you. What you know, you got from a book you should not have had and a teacher who is not alive to confirm it." }
|
||
```
|
||
|
||
`content/world/callings/bonesetter.json`:
|
||
```json
|
||
{ "id": "bonesetter", "name": "Bonesetter", "blurb": "The Warden is distant and does not explain himself. Still — you set the bone, you say the words, and often enough the rot does not take. Often enough." }
|
||
```
|
||
|
||
`content/world/callings/bloodsworn.json`:
|
||
```json
|
||
{ "id": "bloodsworn", "name": "Bloodsworn", "blurb": "You made a bargain with one of the Seven and it was accepted. The power is real. So is the ledger, and it is not settled." }
|
||
```
|
||
|
||
- [ ] **Step 4: Write the four race blurbs**
|
||
|
||
`content/world/races/human.json`:
|
||
```json
|
||
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." }
|
||
```
|
||
|
||
`content/world/races/elf.json`:
|
||
```json
|
||
{ "id": "elf", "name": "Elf", "blurb": "You see in the dark and you notice things. Neither has made you popular in a town that would rather not be noticed." }
|
||
```
|
||
|
||
`content/world/races/dwarf.json`:
|
||
```json
|
||
{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." }
|
||
```
|
||
|
||
`content/world/races/beastfolk.json`:
|
||
```json
|
||
{ "id": "beastfolk", "name": "Beastfolk", "blurb": "Claws you did not ask for and a nose you cannot switch off. Every gate you pass, someone decides what you are before you speak." }
|
||
```
|
||
|
||
- [ ] **Step 5: Load them in `ContentDB`**
|
||
|
||
In `client/scripts/content/content_db.gd`, add the two fields beside the existing ones:
|
||
|
||
```gdscript
|
||
var items: Dictionary = {}
|
||
var callings: Dictionary = {}
|
||
var races: Dictionary = {}
|
||
```
|
||
|
||
In `load_from`, add the two `_load_dir` calls beside the others:
|
||
|
||
```gdscript
|
||
items = _load_dir(world.path_join("items"))
|
||
callings = _load_dir(world.path_join("callings"))
|
||
races = _load_dir(world.path_join("races"))
|
||
```
|
||
|
||
And the accessors, beside the existing one-liners:
|
||
|
||
```gdscript
|
||
func calling(id: String) -> Dictionary: return callings.get(id, {})
|
||
func race(id: String) -> Dictionary: return races.get(id, {})
|
||
func has_calling(id: String) -> bool: return callings.has(id)
|
||
func has_race(id: String) -> bool: return races.has(id)
|
||
```
|
||
|
||
- [ ] **Step 6: Run the tests + the content build**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
Expected: green.
|
||
|
||
From the repo root:
|
||
```bash
|
||
PYTHONPATH=tools python3 -m content_build --check
|
||
git status --porcelain content/
|
||
```
|
||
Expected: the build check passes, and `git status` shows **only** the eleven new files under `content/world/callings/` and `content/world/races/`. If any *other* file under `content/world/` or `content/server/` appears as modified, **STOP** — the generated tree has drifted and that is a client-visible change.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add content/world/callings/ content/world/races/ \
|
||
client/scripts/content/content_db.gd client/tests/unit/test_content_db.gd
|
||
git commit -m "feat(content): calling + race blurbs, loaded by ContentDB
|
||
|
||
Hand-written like items/ — no build-tool namespace needed. Blurbs are content;
|
||
mechanics are code, and a test asserts the blurb files carry no hit dice. The
|
||
prose is placeholder; swapping it later touches no code, which is what stops the
|
||
content BOM from blocking the engine."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `CharacterSheet`
|
||
|
||
**Files:**
|
||
- Create: `client/scripts/state/character_sheet.gd`
|
||
- Test: `client/tests/unit/test_character_sheet.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Attributes.modifier`, `Skills.attribute`, `Races.*`, `Callings.*` (Task 1).
|
||
- Produces: `CharacterSheet` with stored `attributes: Dictionary`, `race_id: String`, `calling_id: String`, `level: int`, `skills: Array`, `bonus_skill: String`, `hp: int`, `mp: int`; and derived `modifier(stat)`, `prof_bonus()`, `max_hp()`, `max_mp()`, `ac()`, `is_proficient(skill)`, `save(stat)`, `poison_save_bonus()`, `skill_bonus(skill)`, `spell_dc()`, `initiative()`, `has_nightsight()`, `has_claws()`, `has_keen_scent()`. Task 5 constructs it.
|
||
|
||
**`bonus_skill` is stored for display, but the human's chosen skill is ALSO appended to `skills` at construction** (Task 5). So `is_proficient` only ever checks `skills` — one list, no double-counting.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `client/tests/unit/test_character_sheet.gd`:
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
|
||
const CharacterSheet = preload("res://scripts/state/character_sheet.gd")
|
||
|
||
|
||
func _sheet(race := "human", calling := "sellsword", attrs := {}) -> CharacterSheet:
|
||
var s = CharacterSheet.new()
|
||
s.race_id = race
|
||
s.calling_id = calling
|
||
s.attributes = {"str": 14, "dex": 12, "con": 14, "fth": 10, "mag": 8}
|
||
for k in attrs:
|
||
s.attributes[k] = attrs[k]
|
||
return s
|
||
|
||
|
||
func test_max_hp_is_hit_die_plus_con_mod():
|
||
var s = _sheet("human", "reaver") # d12, CON 14 -> +2
|
||
assert_eq(s.max_hp(), 14)
|
||
var frail = _sheet("human", "hedge_mage", {"con": 8}) # d6, CON 8 -> -1
|
||
assert_eq(frail.max_hp(), 5)
|
||
|
||
|
||
func test_ac_is_ten_plus_dex():
|
||
assert_eq(_sheet().ac(), 11) # DEX 12 -> +1. Armor is an item (M7).
|
||
|
||
|
||
func test_initiative_is_dex():
|
||
assert_eq(_sheet().initiative(), 1)
|
||
|
||
|
||
func test_save_picks_up_calling_proficiency():
|
||
var s = _sheet("elf", "sellsword") # saves STR, CON; prof +2
|
||
assert_eq(s.save("str"), 4) # STR 14 -> +2, +2 prof
|
||
assert_eq(s.save("dex"), 1) # DEX 12 -> +1, not proficient
|
||
|
||
|
||
func test_human_plus_one_lands_on_all_five_saves():
|
||
var human = _sheet("human", "sellsword")
|
||
var elf = _sheet("elf", "sellsword")
|
||
for stat in ["str", "dex", "con", "fth", "mag"]:
|
||
assert_eq(human.save(stat), elf.save(stat) + 1, "human +1 on %s" % stat)
|
||
|
||
|
||
func test_dwarf_poison_bonus_is_not_in_the_con_save():
|
||
var dwarf = _sheet("dwarf", "sellsword")
|
||
var elf = _sheet("elf", "sellsword")
|
||
assert_eq(dwarf.save("con"), elf.save("con"), "the +2 is situational, not a flat CON save")
|
||
assert_eq(dwarf.poison_save_bonus(), 2)
|
||
assert_eq(elf.poison_save_bonus(), 0)
|
||
|
||
|
||
func test_skill_bonus_uses_the_governing_attribute():
|
||
var s = _sheet() # STR 14 -> +2
|
||
assert_eq(s.skill_bonus("athletics"), 2, "not proficient: attribute only")
|
||
s.skills = ["athletics"]
|
||
assert_eq(s.skill_bonus("athletics"), 4, "proficient: +2 prof")
|
||
|
||
|
||
func test_bonus_skill_confers_proficiency_via_the_skills_list():
|
||
var s = _sheet()
|
||
s.skills = ["athletics", "endurance"] # Task 5 appends the human's pick here
|
||
s.bonus_skill = "endurance"
|
||
assert_true(s.is_proficient("endurance"))
|
||
assert_false(s.is_proficient("stealth"))
|
||
|
||
|
||
func test_martials_have_no_mp_and_no_spell_dc():
|
||
var s = _sheet("human", "sellsword")
|
||
assert_eq(s.max_mp(), 0)
|
||
assert_eq(s.spell_dc(), 0)
|
||
|
||
|
||
func test_casters_have_mp_and_a_spell_dc():
|
||
var s = _sheet("human", "bonesetter", {"fth": 16}) # FTH 16 -> +3
|
||
assert_true(s.max_mp() > 0, "a caster has a pool")
|
||
assert_eq(s.spell_dc(), 13) # 8 + 2 prof + 3
|
||
|
||
|
||
func test_race_flags_are_exposed():
|
||
assert_true(_sheet("elf").has_nightsight())
|
||
assert_false(_sheet("human").has_nightsight())
|
||
assert_true(_sheet("beastfolk").has_claws())
|
||
assert_true(_sheet("beastfolk").has_keen_scent())
|
||
assert_false(_sheet("dwarf").has_claws())
|
||
|
||
|
||
func test_luck_is_nowhere_on_the_sheet():
|
||
# §7: LCK has no save, no skill, no row, no accessor. It lives in GameState.
|
||
var s = _sheet()
|
||
assert_false("lck" in s.attributes, "LCK is never an attribute row")
|
||
assert_false(s.has_method("luck"), "the sheet must not expose Luck")
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: FAIL — `character_sheet.gd` does not exist.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `client/scripts/state/character_sheet.gd`:
|
||
|
||
```gdscript
|
||
class_name CharacterSheet
|
||
extends RefCounted
|
||
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
|
||
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
|
||
## so a derived number can never drift from the inputs that produced it, and M5's level
|
||
## curve becomes a function change rather than a stored-field migration.
|
||
##
|
||
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
|
||
## save, no skill, no row (§7). That holds by construction, not by discipline.
|
||
|
||
const PROF_BONUS_L1 := 2
|
||
|
||
# --- stored: rolled, chosen, or genuinely mutable -------------------------------
|
||
var attributes: Dictionary = {} # {str,dex,con,fth,mag} — final, post-spend
|
||
var race_id: String = ""
|
||
var calling_id: String = ""
|
||
var level: int = 1
|
||
var skills: Array = [] # chosen + race-granted + the human's bonus pick
|
||
var bonus_skill: String = "" # human only; also present in `skills`
|
||
var hp: int = 0 # CURRENT hp — combat writes this
|
||
var mp: int = 0 # CURRENT mp
|
||
|
||
|
||
# --- derived: functions, never stored -------------------------------------------
|
||
func modifier(stat: String) -> int:
|
||
return Attributes.modifier(int(attributes.get(stat, 10)))
|
||
|
||
|
||
func prof_bonus() -> int:
|
||
return PROF_BONUS_L1 # the level curve is M5's; creation is L1
|
||
|
||
|
||
func max_hp() -> int:
|
||
return Callings.hit_die(calling_id) + modifier("con")
|
||
|
||
|
||
func max_mp() -> int:
|
||
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
|
||
# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
|
||
# caster's scales off the casting stat, with a floor so a bad roll still casts.
|
||
if not Callings.is_caster(calling_id):
|
||
return 0
|
||
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
|
||
|
||
|
||
func ac() -> int:
|
||
return 10 + modifier("dex") # armor is an item -> M7
|
||
|
||
|
||
func initiative() -> int:
|
||
return modifier("dex")
|
||
|
||
|
||
func is_proficient(skill: String) -> bool:
|
||
return skill in skills
|
||
|
||
|
||
func save(stat: String) -> int:
|
||
var v := modifier(stat)
|
||
if stat in Callings.saves(calling_id):
|
||
v += prof_bonus()
|
||
v += Races.save_bonus(race_id) # human's +1 to all five
|
||
return v
|
||
|
||
|
||
func poison_save_bonus() -> int:
|
||
# Situational (vs poison + the affliction track) — deliberately NOT folded into
|
||
# save("con"), because a conditional inside a flat number is a sheet that lies.
|
||
return Races.poison_save_bonus(race_id)
|
||
|
||
|
||
func skill_bonus(skill: String) -> int:
|
||
var v := modifier(Skills.attribute(skill))
|
||
if is_proficient(skill):
|
||
v += prof_bonus()
|
||
return v
|
||
|
||
|
||
func spell_dc() -> int:
|
||
if not Callings.is_caster(calling_id):
|
||
return 0
|
||
return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))
|
||
|
||
|
||
func has_nightsight() -> bool:
|
||
return Races.flag(race_id, "nightsight")
|
||
|
||
|
||
func has_claws() -> bool:
|
||
return Races.flag(race_id, "claws")
|
||
|
||
|
||
func has_keen_scent() -> bool:
|
||
return Races.flag(race_id, "keen_scent")
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add client/scripts/state/character_sheet.gd client/tests/unit/test_character_sheet.gd
|
||
git commit -m "feat(state): CharacterSheet — store the inputs, derive the rest
|
||
|
||
Stores only what was rolled or chosen, plus current hp/mp (the only two numbers
|
||
that genuinely mutate). Everything else is a function, so no derived number can
|
||
drift from its inputs and M5's level curve is a function change, not a migration.
|
||
|
||
LCK has no row and no accessor here — §7 holds by construction."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: The contract migration — `calling_id` + `race_id`
|
||
|
||
**Files:**
|
||
- Modify: `docs/schemas/canon-log.schema.json`, `docs/schemas/origin.schema.json`
|
||
- Modify: `content/origins/deserter.json`
|
||
- Modify: `client/scripts/canon_log/log_player.gd`
|
||
- Modify: `client/scripts/newgame/new_game.gd` (minimal adaptation only)
|
||
- Modify: `api/app/prompts.py`
|
||
- Modify: `api/tests/fixtures/canon_log_valid.json`, and any api/client test referencing `class_id` / `allowed_classes`
|
||
- Test: `client/tests/unit/test_schema_parity.gd` (new)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Callings.IDS`, `Races.IDS` (Task 1).
|
||
- Produces: `LogPlayer.new(name, race_id, calling_id, luck_descriptor)`; `LogPlayer.to_dict() -> {name, race_id, calling_id, luck_descriptor}`; the origin key `build_constraints.allowed_callings`.
|
||
|
||
**This is one task on purpose.** The origin's key and the log's field are read on **both sides of the HTTP boundary** — the client validates `allowed_callings` and the server renders `calling_id` into the prompt. Renaming them in separate tasks leaves one suite red at the boundary. Do it as one commit.
|
||
|
||
`class_id` is the rulebook's word; the world has **callings**. There are no saves (persistence is M9) and no deployed clients, so this rename is as cheap now as it will ever be — and strictly more expensive every milestone after.
|
||
|
||
**Do not build the new creation pipeline here.** `construct` keeps rolling its old flat stat block; Task 5 replaces that. This task only makes the *contract* say the right words.
|
||
|
||
- [ ] **Step 1: Find every reference**
|
||
|
||
```bash
|
||
grep -rn "class_id\|allowed_classes\|CLASSES" client api docs content --include=*.gd --include=*.py --include=*.json
|
||
```
|
||
|
||
Every hit is in scope for this task. Expect: both schemas, `log_player.gd`, `new_game.gd`, `deserter.json`, `api/app/prompts.py`, `api/tests/{test_npc,test_prompts,test_origin_schema,test_live_smoke}.py`, `api/tests/fixtures/canon_log_valid.json`, and the client tests `test_new_game.gd` / `test_round_trip.gd`.
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Create `client/tests/unit/test_schema_parity.gd`. The roster now lives in **two** places — `Callings.IDS` and the schema's `enum` — so this is the guard that they cannot drift. `ContentDB` already reaches `../content` from `res://`, so the same trick reaches `../docs`.
|
||
|
||
```gdscript
|
||
extends "res://addons/gut/test.gd"
|
||
## The canon log crosses the HTTP boundary, so its roster exists twice: in code
|
||
## (Callings.IDS / Races.IDS) and in the JSON Schema the API validates against.
|
||
## Nothing at runtime reconciles them. This does.
|
||
|
||
const ContentDB = preload("res://scripts/content/content_db.gd")
|
||
|
||
|
||
func _player_schema() -> Dictionary:
|
||
var path := ProjectSettings.globalize_path("res://") \
|
||
.path_join("../docs/schemas/canon-log.schema.json").simplify_path()
|
||
var doc: Variant = ContentDB.load_json(path)
|
||
assert_eq(typeof(doc), TYPE_DICTIONARY, "canon-log schema did not parse")
|
||
return doc["properties"]["player"]
|
||
|
||
|
||
func test_schema_calling_enum_matches_the_code():
|
||
var enum_ids: Array = _player_schema()["properties"]["calling_id"]["enum"]
|
||
enum_ids.sort()
|
||
var code_ids: Array = Callings.IDS.duplicate()
|
||
code_ids.sort()
|
||
assert_eq(enum_ids, code_ids)
|
||
|
||
|
||
func test_schema_race_enum_matches_the_code():
|
||
var enum_ids: Array = _player_schema()["properties"]["race_id"]["enum"]
|
||
enum_ids.sort()
|
||
var code_ids: Array = Races.IDS.duplicate()
|
||
code_ids.sort()
|
||
assert_eq(enum_ids, code_ids)
|
||
|
||
|
||
func test_schema_requires_race_and_calling():
|
||
var required: Array = _player_schema()["required"]
|
||
assert_true("race_id" in required)
|
||
assert_true("calling_id" in required)
|
||
assert_false("class_id" in required, "class_id is the rulebook's word — it is gone")
|
||
```
|
||
|
||
Add to `client/tests/unit/test_canon_log.gd` (or wherever `LogPlayer` is exercised — find it with `grep -rln LogPlayer client/tests`):
|
||
|
||
```gdscript
|
||
func test_log_player_carries_race_and_calling_and_no_luck_number():
|
||
var p := LogPlayer.new("Aldric", "beastfolk", "cutpurse", "Fortune spits on you")
|
||
var d := p.to_dict()
|
||
assert_eq(d["race_id"], "beastfolk")
|
||
assert_eq(d["calling_id"], "cutpurse")
|
||
assert_false("luck" in d, "numeric Luck never reaches the log (§7)")
|
||
assert_false("class_id" in d)
|
||
assert_eq(d.keys().size(), 4)
|
||
|
||
|
||
func test_log_player_rejects_a_dead_calling():
|
||
var p := LogPlayer.new()
|
||
assert_false(p.set_calling_id("priest"), "priest is not a calling")
|
||
assert_true(p.set_calling_id("bonesetter"))
|
||
assert_false(p.set_race_id("orc"))
|
||
assert_true(p.set_race_id("dwarf"))
|
||
```
|
||
|
||
In `api/tests/test_prompts.py`, note the existing shape before you edit: the renderer is
|
||
**`render_digest`** (not `render_narrate_digest`), and the fixture is a **module-level
|
||
`VALID`** loaded from `fixtures/canon_log_valid.json` — there is no `_valid_log()` helper.
|
||
|
||
First, **an existing assertion breaks and must be updated**, at
|
||
`test_digest_has_the_narrative_context`:
|
||
|
||
```python
|
||
assert "Player: Aldric, a human sellsword" in d # was: "Player: Aldric, a sellsword"
|
||
```
|
||
|
||
(That matches the fixture you update in Step 10, which gains `race_id: "human"`.)
|
||
|
||
Then add:
|
||
|
||
```python
|
||
def _with_player(**player) -> dict:
|
||
log = json.loads(json.dumps(VALID)) # deep copy — VALID is module-level and shared
|
||
log["player"] = {"luck_descriptor": "the dice are kind", **player}
|
||
return log
|
||
|
||
|
||
def test_digest_names_the_race_and_the_calling():
|
||
out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
|
||
assert "Aldric, a beastfolk cutpurse" in out
|
||
|
||
|
||
def test_digest_says_an_elf_not_a_elf():
|
||
out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
|
||
assert "an elf sellsword" in out
|
||
|
||
|
||
def test_digest_does_not_leak_a_snake_case_id():
|
||
out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
|
||
assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
|
||
assert "hedge-mage" in out
|
||
```
|
||
|
||
- [ ] **Step 3: Run the tests to verify they fail**
|
||
|
||
```bash
|
||
cd client && ./run_tests.sh # FAIL: schema has no calling_id/race_id
|
||
cd ../api && venv/bin/pytest # FAIL: digest still renders class_id
|
||
```
|
||
|
||
- [ ] **Step 4: Migrate `docs/schemas/canon-log.schema.json`**
|
||
|
||
Replace the `player` block's `properties` and `required`:
|
||
|
||
```json
|
||
"player": {
|
||
"type": "object",
|
||
"additionalProperties": false,
|
||
"required": ["name", "race_id", "calling_id", "luck_descriptor"],
|
||
"properties": {
|
||
"name": { "type": "string", "minLength": 1 },
|
||
"race_id": { "enum": ["human", "elf", "dwarf", "beastfolk"] },
|
||
"calling_id": {
|
||
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
|
||
"hedge_mage", "bonesetter", "bloodsworn"]
|
||
},
|
||
"luck_descriptor": { "type": "string", "minLength": 1 }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Migrate `docs/schemas/origin.schema.json`**
|
||
|
||
In `build_constraints`, rename the key and update the enum. It stays `additionalProperties: false` and `minItems: 1`:
|
||
|
||
```json
|
||
"build_constraints": {
|
||
"type": "object",
|
||
"additionalProperties": false,
|
||
"required": ["allowed_callings", "luck_modifier"],
|
||
"properties": {
|
||
"allowed_callings": {
|
||
"type": "array",
|
||
"minItems": 1,
|
||
"items": {
|
||
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
|
||
"hedge_mage", "bonesetter", "bloodsworn"]
|
||
}
|
||
},
|
||
"luck_modifier": { "type": "integer" }
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Migrate `content/origins/deserter.json`**
|
||
|
||
`allowed_classes: ["sellsword", "assassin", "priest"]` names three callings that no longer exist. He is a mercenary-company deserter; a thematic gate is a **content** decision for the Greywater authoring, not an engine change invented here — so he allows all seven:
|
||
|
||
```json
|
||
"build_constraints": {
|
||
"allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper",
|
||
"hedge_mage", "bonesetter", "bloodsworn"],
|
||
"luck_modifier": 0
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Migrate `LogPlayer`**
|
||
|
||
Rewrite `client/scripts/canon_log/log_player.gd`. The hardcoded `CLASSES` const is **deleted** — the roster is `Callings.IDS`, one source of truth:
|
||
|
||
```gdscript
|
||
class_name LogPlayer
|
||
extends RefCounted
|
||
## A canon-log player row. Carries ONLY name, race_id, calling_id, luck_descriptor
|
||
## (§7): no numeric Luck, no attributes — those live in GameState and never reach the
|
||
## AI. race_id is here because the Narrator and NPCs must be able to describe the
|
||
## player ("a beastfolk cutpurse"); the mechanical sheet must not.
|
||
|
||
var name: String
|
||
var race_id: String
|
||
var calling_id: String
|
||
var luck_descriptor: String
|
||
|
||
|
||
func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
|
||
name = p_name
|
||
luck_descriptor = p_luck_descriptor
|
||
if p_race_id != "":
|
||
set_race_id(p_race_id)
|
||
if p_calling_id != "":
|
||
set_calling_id(p_calling_id)
|
||
|
||
|
||
func set_race_id(v: String) -> bool:
|
||
if not Races.exists(v):
|
||
push_error("invalid race_id: %s" % v)
|
||
return false
|
||
race_id = v
|
||
return true
|
||
|
||
|
||
func set_calling_id(v: String) -> bool:
|
||
if not Callings.exists(v):
|
||
push_error("invalid calling_id: %s" % v)
|
||
return false
|
||
calling_id = v
|
||
return true
|
||
|
||
|
||
func to_dict() -> Dictionary:
|
||
return {"name": name, "race_id": race_id, "calling_id": calling_id,
|
||
"luck_descriptor": luck_descriptor}
|
||
|
||
|
||
static func from_dict(d: Dictionary) -> LogPlayer:
|
||
return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
|
||
d.get("calling_id", ""), d.get("luck_descriptor", ""))
|
||
```
|
||
|
||
- [ ] **Step 8: Adapt `NewGame` minimally**
|
||
|
||
**Not** the new pipeline — that is Task 5. Only make the existing code speak the new contract. In `client/scripts/newgame/new_game.gd`:
|
||
|
||
```gdscript
|
||
# 2. build constraints — chosen calling must be allowed by the origin.
|
||
var bc: Dictionary = origin.get("build_constraints", {})
|
||
var allowed: Array = bc.get("allowed_callings", [])
|
||
var calling_id: String = creation.get("calling_id", "")
|
||
if calling_id not in allowed:
|
||
errors.append("calling not allowed by origin: %s" % calling_id)
|
||
|
||
var race_id: String = creation.get("race_id", "")
|
||
if not Races.exists(race_id):
|
||
errors.append("unknown race: %s" % race_id)
|
||
```
|
||
|
||
and where the log player is built:
|
||
|
||
```gdscript
|
||
log.player = LogPlayer.new(creation.get("name", ""), race_id, calling_id,
|
||
state.luck_descriptor())
|
||
```
|
||
|
||
Update `client/tests/unit/test_new_game.gd` and `client/tests/unit/test_round_trip.gd`: every `{"name": "...", "class_id": "sellsword"}` creation dict becomes `{"name": "...", "race_id": "human", "calling_id": "sellsword"}` (and `"class_id": "priest"` → `"calling_id": "bonesetter"`, since `priest` no longer exists).
|
||
|
||
- [ ] **Step 9: Render race + calling in the API prompts**
|
||
|
||
`api/app/prompts.py` currently emits `a {class_id}`, which would now read `a hedge_mage` — a raw snake_case id going to the model — and `a elf`, which is broken English. Fix both at the render edge. Add near the top of the module:
|
||
|
||
```python
|
||
def _humanize(id_: str) -> str:
|
||
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
|
||
return id_.replace("_", "-")
|
||
|
||
|
||
def _article(word: str) -> str:
|
||
return "an" if word[:1].lower() in "aeiou" else "a"
|
||
|
||
|
||
def _describe_player(player: dict) -> str:
|
||
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
|
||
race = _humanize(player.get("race_id", ""))
|
||
calling = _humanize(player.get("calling_id", ""))
|
||
name = player.get("name", "")
|
||
return f"{name}, {_article(race)} {race} {calling}".strip()
|
||
```
|
||
|
||
Then line 53 becomes:
|
||
|
||
```python
|
||
lines.append(f"Player: {_describe_player(player)}")
|
||
```
|
||
|
||
and line 107 becomes:
|
||
|
||
```python
|
||
lines.append(f"The player is {_describe_player(player)}.")
|
||
```
|
||
|
||
- [ ] **Step 10: Update the API fixtures and tests**
|
||
|
||
In `api/tests/fixtures/canon_log_valid.json`, `api/tests/test_npc.py`, `api/tests/test_prompts.py`, and `api/tests/test_live_smoke.py`, every player block becomes:
|
||
|
||
```json
|
||
{"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"}
|
||
```
|
||
|
||
In `api/tests/test_origin_schema.py`, `"allowed_classes": ["sellsword", "assassin", "priest"]` becomes `"allowed_callings": ["sellsword", "cutpurse", "bonesetter"]`, and the negative case (`= ["bard"]`) keeps testing that an unknown id is rejected — `bard` is still not a calling (§8: Bard is NPC-only).
|
||
|
||
- [ ] **Step 11: Run both suites + the content build**
|
||
|
||
```bash
|
||
cd client && ./run_tests.sh
|
||
cd ../api && venv/bin/pytest
|
||
cd .. && PYTHONPATH=tools python3 -m content_build --check
|
||
```
|
||
|
||
Expected: all green. Then confirm no `class_id` survives:
|
||
|
||
```bash
|
||
grep -rn "class_id\|allowed_classes" client api docs content --include=*.gd --include=*.py --include=*.json
|
||
```
|
||
Expected: **no matches.**
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "refactor(contract)!: class_id -> calling_id, and the log gains race_id
|
||
|
||
class_id is the rulebook's word; the world has callings — that was the whole §17
|
||
reconcile. The contract the Narrator reads should say what the world says. There
|
||
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
|
||
and strictly more expensive every milestone after.
|
||
|
||
race_id reaches the log because the AI must be able to describe the player:
|
||
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
|
||
It humanizes the id (the model must never read 'hedge_mage') and picks the right
|
||
article ('an elf', not 'a elf').
|
||
|
||
LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
|
||
parity test reads the JSON Schema from the client and asserts its enum equals the
|
||
code, so the two sides of the HTTP boundary cannot drift.
|
||
|
||
The deserter allowed three callings that no longer exist. He now allows all seven;
|
||
a thematic gate is a content decision for the Greywater authoring."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: The seeded creation pipeline
|
||
|
||
**Files:**
|
||
- Modify: `client/scripts/newgame/new_game.gd`
|
||
- Modify: `client/scripts/state/game_state.gd`
|
||
- Test: `client/tests/unit/test_new_game.gd`, `client/tests/unit/test_game_state.gd`, `client/tests/unit/test_round_trip.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `CharacterSheet` (Task 3); `Attributes.IDS`, `Races.*`, `Callings.*`, `Skills.*` (Task 1); `LogPlayer.new(name, race_id, calling_id, luck_descriptor)` (Task 4).
|
||
- Produces: `NewGame.construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary` — **the `rng` parameter is dropped**; the seed inside `creation` is now the only source of randomness. Returns `{"ok": bool, "errors": Array, "log": CanonLog, "state": GameState}`. `GameState.sheet: CharacterSheet` replaces `GameState.stats`.
|
||
|
||
The creation Dictionary:
|
||
|
||
```gdscript
|
||
{
|
||
"name": "Aldric",
|
||
"race_id": "beastfolk",
|
||
"calling_id": "cutpurse",
|
||
"seed": 8675309,
|
||
"spend": {"dex": 2, "con": 1},
|
||
"skills": ["stealth", "sleight_of_hand", "acrobatics", "athletics"],
|
||
"bonus_skill": "" # human only
|
||
}
|
||
```
|
||
|
||
It carries a **seed, not a stat block**. `construct` rebuilds the RNG and rolls the attributes itself, so it never trusts a number handed to it (§2), and the same seed always yields the same character (§10).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Rewrite the helpers at the top of `client/tests/unit/test_new_game.gd` (the `_rng()` helper is deleted — `construct` no longer takes one):
|
||
|
||
```gdscript
|
||
func _creation(overrides := {}) -> Dictionary:
|
||
var c := {
|
||
"name": "Aldric",
|
||
"race_id": "human",
|
||
"calling_id": "sellsword",
|
||
"seed": 8675309,
|
||
"spend": {},
|
||
"skills": ["athletics", "endurance"],
|
||
"bonus_skill": "perception",
|
||
}
|
||
for k in overrides:
|
||
c[k] = overrides[k]
|
||
return c
|
||
|
||
|
||
func _build(creation: Dictionary) -> Dictionary:
|
||
return NewGame.construct(_deserter(), world, creation)
|
||
```
|
||
|
||
Then the new tests:
|
||
|
||
```gdscript
|
||
func test_same_seed_yields_an_identical_character():
|
||
# The determinism guarantee (§10). Two independent constructs, one seed.
|
||
var a := _build(_creation())
|
||
var b := _build(_creation())
|
||
assert_true(a["ok"] and b["ok"])
|
||
assert_eq(a["state"].sheet.attributes, b["state"].sheet.attributes)
|
||
assert_eq(a["state"].luck, b["state"].luck, "even the hidden Luck is reproducible")
|
||
|
||
|
||
func test_a_different_seed_yields_a_different_character():
|
||
var a := _build(_creation())
|
||
var b := _build(_creation({"seed": 1234567}))
|
||
assert_ne(a["state"].sheet.attributes, b["state"].sheet.attributes)
|
||
|
||
|
||
func test_every_attribute_respects_the_floor_of_eight():
|
||
# 3d6 can roll 3. The floor is a design guarantee, not a dice accident.
|
||
for s in range(40):
|
||
var res := _build(_creation({"seed": s}))
|
||
assert_true(res["ok"], str(res["errors"]))
|
||
for stat in Attributes.IDS:
|
||
assert_true(int(res["state"].sheet.attributes[stat]) >= 8,
|
||
"seed %d rolled %s below the floor" % [s, stat])
|
||
|
||
|
||
func test_spend_is_additive_on_top_of_the_roll():
|
||
var base := _build(_creation())
|
||
var spent := _build(_creation({"spend": {"str": 2, "con": 1}}))
|
||
assert_eq(int(spent["state"].sheet.attributes["str"]),
|
||
int(base["state"].sheet.attributes["str"]) + 2)
|
||
assert_eq(int(spent["state"].sheet.attributes["con"]),
|
||
int(base["state"].sheet.attributes["con"]) + 1)
|
||
assert_eq(int(spent["state"].sheet.attributes["dex"]),
|
||
int(base["state"].sheet.attributes["dex"]), "unspent stats are untouched")
|
||
|
||
|
||
func test_spend_over_the_pool_is_rejected():
|
||
var res := _build(_creation({"spend": {"str": 2, "con": 2}}))
|
||
assert_false(res["ok"])
|
||
assert_true("spend" in str(res["errors"]).to_lower())
|
||
|
||
|
||
func test_spend_cannot_be_negative():
|
||
var res := _build(_creation({"spend": {"str": -2, "con": 1}}))
|
||
assert_false(res["ok"], "additive only — there is no [-]")
|
||
|
||
|
||
func test_luck_can_never_be_spent():
|
||
# §7: LCK is untouchable at creation. Not hidden — impossible.
|
||
var res := _build(_creation({"spend": {"lck": 3}}))
|
||
assert_false(res["ok"])
|
||
|
||
|
||
func test_skills_must_come_from_the_callings_pool():
|
||
var res := _build(_creation({"skills": ["athletics", "sorcery"]}))
|
||
assert_false(res["ok"], "sorcery is not in the sellsword's pool")
|
||
|
||
|
||
func test_skills_must_be_the_right_count():
|
||
var res := _build(_creation({"skills": ["athletics"]}))
|
||
assert_false(res["ok"], "a sellsword picks 2")
|
||
|
||
|
||
func test_skills_reject_a_duplicate():
|
||
var res := _build(_creation({"skills": ["athletics", "athletics"]}))
|
||
assert_false(res["ok"])
|
||
|
||
|
||
func test_an_elf_cannot_spend_a_pick_on_perception():
|
||
# He already has it. A pick that buys nothing is a trap, not a choice.
|
||
var res := _build(_creation({
|
||
"race_id": "elf", "bonus_skill": "",
|
||
"skills": ["athletics", "perception"]}))
|
||
assert_false(res["ok"])
|
||
|
||
|
||
func test_the_elf_is_granted_perception_anyway():
|
||
var res := _build(_creation({
|
||
"race_id": "elf", "bonus_skill": "", "skills": ["athletics", "endurance"]}))
|
||
assert_true(res["ok"], str(res["errors"]))
|
||
assert_true(res["state"].sheet.is_proficient("perception"))
|
||
|
||
|
||
func test_a_human_must_choose_a_bonus_skill():
|
||
var res := _build(_creation({"bonus_skill": ""}))
|
||
assert_false(res["ok"])
|
||
|
||
|
||
func test_a_non_human_must_not_have_one():
|
||
var res := _build(_creation({"race_id": "dwarf", "bonus_skill": "stealth"}))
|
||
assert_false(res["ok"])
|
||
|
||
|
||
func test_the_humans_bonus_skill_confers_proficiency():
|
||
var res := _build(_creation()) # human, bonus_skill perception
|
||
assert_true(res["ok"], str(res["errors"]))
|
||
assert_true(res["state"].sheet.is_proficient("perception"))
|
||
|
||
|
||
func test_the_character_starts_whole():
|
||
var res := _build(_creation())
|
||
var sheet = res["state"].sheet
|
||
assert_eq(sheet.hp, sheet.max_hp())
|
||
assert_eq(sheet.mp, sheet.max_mp())
|
||
|
||
|
||
func test_a_calling_the_origin_forbids_is_rejected():
|
||
var o := _deserter()
|
||
o["build_constraints"] = o["build_constraints"].duplicate(true)
|
||
o["build_constraints"]["allowed_callings"] = ["bonesetter"]
|
||
var res := NewGame.construct(o, world, _creation())
|
||
assert_false(res["ok"])
|
||
|
||
|
||
func test_no_luck_number_and_no_attributes_reach_the_log():
|
||
# §7 + §11: the log carries only what the AI narrates from.
|
||
var res := _build(_creation())
|
||
var pd: Dictionary = res["log"].player.to_dict()
|
||
assert_eq(pd.keys().size(), 4)
|
||
assert_false("luck" in pd)
|
||
assert_false("attributes" in pd)
|
||
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
|
||
```
|
||
|
||
In `client/tests/unit/test_game_state.gd`, replace any assertion on the old `stats` Dictionary with the sheet:
|
||
|
||
```gdscript
|
||
func test_state_holds_a_sheet_not_a_stat_bag():
|
||
var gs = GameState.new()
|
||
assert_null(gs.sheet, "the sheet is built by NewGame.construct")
|
||
assert_false("stats" in gs, "the flat stats Dictionary is gone")
|
||
```
|
||
|
||
Update `client/tests/unit/test_round_trip.gd` to the new `construct` signature and creation dict.
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: FAIL — `construct` still takes an `rng`, `GameState` has no `sheet`, and none of the validation exists.
|
||
|
||
- [ ] **Step 3: Give `GameState` a sheet**
|
||
|
||
In `client/scripts/state/game_state.gd`, replace the `stats` field:
|
||
|
||
```gdscript
|
||
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
|
||
```
|
||
|
||
Delete `var stats: Dictionary = {}`. `luck` and `luck_base` stay exactly where they are — LCK is not on the sheet (§7).
|
||
|
||
- [ ] **Step 4: Write the pipeline**
|
||
|
||
Rewrite `client/scripts/newgame/new_game.gd`:
|
||
|
||
```gdscript
|
||
class_name NewGame
|
||
extends RefCounted
|
||
## New-game construction. Reads three immutable inputs (origin, world, creation) and
|
||
## writes two products (CanonLog, GameState). Nothing flows back up — charter §2.
|
||
##
|
||
## Creation carries a SEED, not a stat block. construct() rebuilds the RNG and rolls
|
||
## the attributes ITSELF, so it never trusts a number handed to it (§2) and the same
|
||
## seed always yields the same character (§10 — "the difference between a bug you can
|
||
## reproduce and a bug you cannot"). The whole character is reproducible from four
|
||
## primitives: seed, race_id, calling_id, spend.
|
||
##
|
||
## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the
|
||
## UI entirely, so the creation SCENE must never become the only thing that can make it.
|
||
|
||
const SPEND_POOL := 3
|
||
|
||
|
||
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary:
|
||
var errors := _validate(origin, world, creation)
|
||
if not errors.is_empty():
|
||
return {"ok": false, "errors": errors, "log": null, "state": null}
|
||
|
||
var rng := RandomNumberGenerator.new()
|
||
rng.seed = int(creation.get("seed", 0))
|
||
|
||
var state := GameState.new()
|
||
var sheet := CharacterSheet.new()
|
||
sheet.race_id = str(creation.get("race_id", ""))
|
||
sheet.calling_id = str(creation.get("calling_id", ""))
|
||
|
||
# The RNG ORDER is part of the contract: five attributes in a fixed order, then
|
||
# Luck. Change the order and every existing seed produces a different character.
|
||
var attrs: Dictionary = {}
|
||
for stat in Attributes.IDS:
|
||
attrs[stat] = _roll_attribute(rng)
|
||
|
||
var bc: Dictionary = origin.get("build_constraints", {})
|
||
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
|
||
state.luck = state.luck_base
|
||
|
||
var spend: Dictionary = creation.get("spend", {})
|
||
for stat in spend:
|
||
attrs[stat] = int(attrs[stat]) + int(spend[stat])
|
||
sheet.attributes = attrs
|
||
|
||
# Proficiencies: what the race grants + what the player picked + the human's bonus.
|
||
# All land in one list, so is_proficient() has exactly one thing to read.
|
||
var skills: Array = []
|
||
skills.append_array(Races.granted_skills(sheet.race_id))
|
||
skills.append_array(creation.get("skills", []))
|
||
var bonus := str(creation.get("bonus_skill", ""))
|
||
if bonus != "":
|
||
skills.append(bonus)
|
||
sheet.bonus_skill = bonus
|
||
sheet.skills = skills
|
||
|
||
sheet.hp = sheet.max_hp() # the character starts whole
|
||
sheet.mp = sheet.max_mp()
|
||
state.sheet = sheet
|
||
|
||
var log := CanonLog.new()
|
||
log.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
|
||
sheet.calling_id, state.luck_descriptor())
|
||
|
||
var loc: Dictionary = world.location(origin["start_location_id"])
|
||
log.set_location(loc.get("id", ""), loc.get("name", ""))
|
||
|
||
var overrides: Dictionary = origin.get("disposition_overrides", {})
|
||
var companion_ids := {}
|
||
for c in world.companions():
|
||
companion_ids[c["id"]] = true
|
||
log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
|
||
for npc_id in overrides:
|
||
if not companion_ids.has(npc_id):
|
||
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
|
||
|
||
for line in origin.get("situation", []):
|
||
log.push_event(line)
|
||
for fact in origin.get("opening_facts", []):
|
||
log.add_fact(fact)
|
||
|
||
var quest_id: Variant = origin.get("start_quest_id", null)
|
||
if quest_id != null:
|
||
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). §2. grant() routes currency to the
|
||
# purse; everything else to the inventory dict.
|
||
for g in origin.get("inventory_grants", []):
|
||
state.grant(g.get("item_id", ""), int(g.get("qty", 0)))
|
||
|
||
return {"ok": true, "errors": [], "log": log, "state": state}
|
||
|
||
|
||
static func _roll_attribute(rng: RandomNumberGenerator) -> int:
|
||
# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
|
||
# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
|
||
# at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for
|
||
# twenty hours.
|
||
var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
|
||
return maxi(8, roll)
|
||
|
||
|
||
static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
|
||
var errors: Array = []
|
||
|
||
for ref in world.unresolved_refs(origin):
|
||
errors.append("unresolved ref: %s" % ref)
|
||
|
||
if str(creation.get("name", "")).strip_edges() == "":
|
||
errors.append("player name is required")
|
||
|
||
var race_id: String = str(creation.get("race_id", ""))
|
||
if not Races.exists(race_id):
|
||
errors.append("unknown race: %s" % race_id)
|
||
|
||
var calling_id: String = str(creation.get("calling_id", ""))
|
||
if not Callings.exists(calling_id):
|
||
errors.append("unknown calling: %s" % calling_id)
|
||
else:
|
||
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
|
||
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):
|
||
errors.append_array(_validate_skills(creation, race_id, calling_id))
|
||
|
||
return errors
|
||
|
||
|
||
static func _validate_spend(spend: Dictionary) -> Array:
|
||
var errors: Array = []
|
||
var total := 0
|
||
for stat in spend:
|
||
# LCK is not merely hidden at creation — it is unspendable (§7).
|
||
if not Attributes.exists(str(stat)):
|
||
errors.append("cannot spend on '%s' — not an attribute" % stat)
|
||
continue
|
||
var v := int(spend[stat])
|
||
if v < 0:
|
||
errors.append("spend is additive only: %s is negative" % stat)
|
||
total += v
|
||
if total > SPEND_POOL:
|
||
errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total])
|
||
return errors
|
||
|
||
|
||
static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array:
|
||
var errors: Array = []
|
||
var picks: Array = creation.get("skills", [])
|
||
var granted: Array = Races.granted_skills(race_id)
|
||
var pool: Array = Callings.skill_pool(calling_id)
|
||
var want := Callings.skill_count(calling_id)
|
||
|
||
if picks.size() != want:
|
||
errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()])
|
||
|
||
var seen := {}
|
||
for s in picks:
|
||
if s in seen:
|
||
errors.append("duplicate skill pick: %s" % s)
|
||
seen[s] = true
|
||
if s not in pool:
|
||
errors.append("%s is not in the %s pool" % [s, calling_id])
|
||
if s in granted:
|
||
errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id])
|
||
|
||
var bonus := str(creation.get("bonus_skill", ""))
|
||
if Races.wants_bonus_skill(race_id):
|
||
if bonus == "":
|
||
errors.append("%s must choose a bonus skill" % race_id)
|
||
elif not Skills.exists(bonus):
|
||
errors.append("unknown bonus skill: %s" % bonus)
|
||
elif bonus in picks or bonus in granted:
|
||
errors.append("bonus skill %s is already proficient" % bonus)
|
||
elif bonus != "":
|
||
errors.append("%s does not get a bonus skill" % race_id)
|
||
|
||
return errors
|
||
```
|
||
|
||
- [ ] **Step 5: Run the tests to verify they pass**
|
||
|
||
```bash
|
||
./run_tests.sh
|
||
```
|
||
|
||
Expected: all green.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add client/scripts/newgame/new_game.gd client/scripts/state/game_state.gd \
|
||
client/tests/unit/test_new_game.gd client/tests/unit/test_game_state.gd \
|
||
client/tests/unit/test_round_trip.gd
|
||
git commit -m "feat(newgame): the seeded creation pipeline
|
||
|
||
construct() takes a seed, not a stat block. It rebuilds the RNG and rolls the
|
||
attributes itself, so it never trusts a number handed to it (§2) and the same seed
|
||
yields an identical character, hidden Luck included (§10). The whole character is
|
||
reproducible from four primitives: seed, race_id, calling_id, spend. The rng
|
||
parameter is dropped — the seed is now the only source of randomness.
|
||
|
||
The roll is 3d6 with a hard floor of 8. Straight 3d6 puts ~9% on a primary the +3
|
||
pool cannot rescue, and a character bad at the one thing he is FOR is not grit —
|
||
it is the fine §7 forbids, paid for twenty hours.
|
||
|
||
LCK is unspendable, not merely hidden: spending on it is a validation error.
|
||
GameState.stats becomes GameState.sheet."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Final verification
|
||
|
||
**Files:** none — this task only runs things.
|
||
|
||
- [ ] **Step 1: The client suite**
|
||
|
||
```bash
|
||
cd client && ./run_tests.sh
|
||
```
|
||
Expected: green, zero failures. Paste the summary line into the report — do not claim green without it.
|
||
|
||
- [ ] **Step 2: The API suite**
|
||
|
||
```bash
|
||
cd api && venv/bin/pytest
|
||
```
|
||
Expected: green.
|
||
|
||
- [ ] **Step 3: The content build**
|
||
|
||
```bash
|
||
PYTHONPATH=tools python3 -m content_build --check
|
||
```
|
||
Expected: exit 0.
|
||
|
||
- [ ] **Step 4: The generated tree is untouched**
|
||
|
||
```bash
|
||
git diff --stat dev...HEAD -- content/world content/server
|
||
```
|
||
Expected: **only** the eleven new files under `content/world/callings/` and `content/world/races/`. Any other path under `content/world/` or `content/server/` means the generated tree drifted and must be explained before merging.
|
||
|
||
- [ ] **Step 5: The dead words are gone**
|
||
|
||
```bash
|
||
grep -rn "class_id\|allowed_classes\|\"assassin\"\|\"priest\"" client api docs/schemas content --include=*.gd --include=*.py --include=*.json
|
||
```
|
||
Expected: **no matches.** (Prose mentions of "priest" in `content/lore/*.md` or the charter are fine — this greps code, schemas, and content JSON only.)
|
||
|
||
- [ ] **Step 6: Report, and stop**
|
||
|
||
Report the results. **Do not merge.** §18: `feat/creation-model` merges into `dev` with `--no-ff` only after the human confirms, and `master` is never touched.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
|
||
| Spec section | Task |
|
||
|---|---|
|
||
| §3 rules in code, blurbs in content | 1 (tables), 2 (blurbs + ContentDB + parity) |
|
||
| §4 the three tables (skills, races, callings) | 1 |
|
||
| §5 the sheet — store inputs, derive the rest | 3 |
|
||
| §5 LCK nowhere on the sheet | 3 (asserted), 5 (unspendable) |
|
||
| §5 the two seams (AC = 10+DEX, `max_mp` TUNABLE) | 3 |
|
||
| §6 creation carries a seed; construct re-derives | 5 |
|
||
| §6 order of operations (attrs then LCK) | 5 |
|
||
| §6 3d6 floor 8 | 5 |
|
||
| §6 validation (spend, skills, bonus_skill, origin gate) | 5 |
|
||
| §7 the migrations (both schemas, LogPlayer, origin, api prompts) | 4 |
|
||
| §7 schema/code parity test | 4 |
|
||
| §8 testing | every task, tests first |
|
||
| §9 out of scope (screen, flow, MP curve, armor, talents, blurb prose) | not built; stated in Global Constraints |
|
||
|
||
**Placeholder scan:** none. `max_mp()`'s `TUNABLE` is a *deliberate, spec-mandated* placeholder with a concrete implementation and a test that asserts only its shape — not a plan gap.
|
||
|
||
**Type consistency:** `race_id`, `calling_id`, `seed`, `spend`, `skills`, `bonus_skill`, `sheet`, `attributes`, `is_proficient`, `max_hp`, `max_mp`, `save`, `poison_save_bonus`, `skill_bonus`, `spell_dc`, `Callings.IDS`, `Races.IDS`, `Attributes.IDS`, `Skills.exists` are spelled identically in every task that defines or consumes them. `construct` drops `rng` in Task 5 and every call site in that task is updated to the three-argument form.
|