From 9de8ee40b302dd5952d8208bb9c46f441426e15a Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Sun, 12 Jul 2026 19:48:46 -0500 Subject: [PATCH] =?UTF-8?q?docs(spec):=20M4-a=20=E2=80=94=20the=20characte?= =?UTF-8?q?r=20creation=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rules a character is made of, with no screen attached. Races, callings and skills become static tables in code; blurbs stay content (hand-written, like items/), so the content BOM stops blocking the engine. The sheet stores only what was rolled or chosen and derives the rest — one source of truth, and the M5 level curve becomes a function change rather than a migration. Creation is seeded. The creation Dictionary carries a seed, not a stat block: construct reconstructs the RNG and rolls the attributes itself, so the same seed yields an identical character and the UI cannot inject a number. §10's seeding argument, applied where the races spec says it first matters. 3d6 with a hard floor of 8. Straight 3d6 puts a ~9% chance on a primary the +3 pool cannot rescue — a Hedge-Mage with a -1 MAG modifier is not grit, it is the "fine" §7 forbids, paid for twenty hours. Lands the two migrations the races spec said to do at M4 and not silently: class_id -> calling_id (the contract should say what the world says) and a required race_id, so api/app/prompts.py can render "a beastfolk cutpurse" instead of "a sellsword". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8 --- .../specs/2026-07-12-creation-model-design.md | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-creation-model-design.md diff --git a/docs/superpowers/specs/2026-07-12-creation-model-design.md b/docs/superpowers/specs/2026-07-12-creation-model-design.md new file mode 100644 index 0000000..0343232 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-creation-model-design.md @@ -0,0 +1,361 @@ +# M4-a — the character creation model + +**Date:** 2026-07-12 +**Status:** approved, ready for planning +**Charter:** §2 (code owns state), §7 (Luck), §8 (stats), §10 (seeding), §11 (canon log), §18 (git) +**Implements:** the M4 half of [races & classes](2026-07-11-races-and-classes-design.md) — its §5 *Character-creation integration*, and the two contract migrations it flagged. +**Roadmap:** M4, split into **M4-a (this: the model, no UI) → M4-b (the screen) → M4-c (title→creation→shell flow)**. + +--- + +## 1. What this is + +The rules a character is made of, in code, with no screen attached. + +Races, callings, and skills become **static tables**. The character sheet becomes a +**typed model that stores what was rolled or chosen and derives everything else**. +`NewGame.construct` grows from "roll five stats" into the real creation pipeline — and +becomes **seeded and reproducible**. + +It also lands the two contract migrations the races/classes spec said to do **at M4, not +silently**: the canon log gains `race_id`, and `class_id` becomes `calling_id`. + +**No UI.** The creation screen is M4-b; it consumes what this builds. Everything here is a +pure function or a dumb store, and the whole thing is testable headlessly. + +--- + +## 2. What is actually there today (verified at `198ea27`) + +- `NewGame.construct(origin, world, creation, rng)` exists and is tested. It validates the + origin's refs, checks `class_id` against `build_constraints.allowed_classes`, rolls Luck, + rolls five stats with a placeholder `_roll_stat` (plain 3d6, marked `TUNABLE`), and + assembles the `CanonLog` + `GameState`. +- **`GameState.stats`** is a flat `Dictionary` of five ints. There is no sheet, no HP, no + AC, no skills, no race. +- **`class_id` is hardcoded as `["sellsword", "assassin", "priest"]` in three places** — + all three name callings that no longer exist: + - `docs/schemas/canon-log.schema.json` (an `enum`, `additionalProperties: false`) + - `client/scripts/canon_log/log_player.gd` (`LogPlayer.CLASSES`) + - `content/origins/deserter.json` (`build_constraints.allowed_classes`) +- `docs/schemas/origin.schema.json` enumerates the same dead three under + `build_constraints.allowed_classes` (`additionalProperties: false`, `minItems: 1`). +- **The server renders the class into the prompt.** `api/app/prompts.py:53` emits + `Player: {name}, a {class_id}` and `:107` emits `The player is {name}, a {class_id}.` + This is *why* `race_id` must reach the log — so the AI can say "a beastfolk cutpurse". +- `content/world/items/` is **hand-written** JSON loaded by `ContentDB._load_dir`, not + emitted by `content_build`. `callings/` and `races/` will work the same way. + +--- + +## 3. Where the rules live + +**Mechanics in code; blurbs in content.** The same split the currency work settled: a rule +is a rule and belongs in code, and one fact gets one source of truth. + +### Code — three static tables + +New directory `client/scripts/rules/`. These are *rules*, not state; `Luck` and `Currency` +sit in `state/` because they are read *by* state, but a table of hit dice is neither. + +- **`Skills`** (`rules/skills.gd`) — the 9 skills and the attribute each is governed by. +- **`Races`** (`rules/races.gd`) — the 4 races as feature flags. +- **`Callings`** (`rules/callings.gd`) — the 7 callings. + +All static, all pure, shaped exactly like `Luck` / `Currency` (`class_name`, `extends +RefCounted`, consts at the top, no autoload). + +### Content — the words + +Hand-written JSON, loaded by `ContentDB` exactly like `items/`: + +``` +content/world/callings/reaver.json { "id": "reaver", "name": "Reaver", "blurb": "..." } +content/world/races/beastfolk.json { "id": "beastfolk", "name": "Beastfolk", "blurb": "..." } +``` + +`ContentDB` gains `callings` and `races` dicts (two more `_load_dir` calls). **No +build-tool change** — these are hand-written directories, so M4-a does not need the +`calling` namespace the roadmap books for M7. + +Blurbs ship as **placeholders** and are swapped for authored prose later without touching +code. That is the whole point of the split: the content BOM's calling blurbs stop blocking +the engine. + +**Parity test:** `Callings.IDS` ≡ the ids in `content/world/callings/`, and likewise for +races. Drift fails the suite. (Same guard as the currency parity test.) + +--- + +## 4. The tables + +### Skills (9) + +| Skill | Attribute | +|---|---| +| `athletics` · `intimidation` | STR | +| `stealth` · `sleight_of_hand` · `acrobatics` | DEX | +| `endurance` | CON | +| `perception` · `faith_lore` | FTH | +| `sorcery` | MAG | + +LCK owns no skill, no save, no row (§7). + +### Races (4) + +Features only — **no race touches an attribute score**, which keeps the roll + spend pure. + +| Race | Headline | Minor | +|---|---|---| +| `human` | **+1 to all 5 saves** | **+1 skill proficiency of choice** (any of the 9) at creation | +| `elf` | **`perception` proficiency** | **Nightsight** | +| `dwarf` | **+2 on CON saves vs poison / the affliction track** | **Nightsight** | +| `beastfolk` | **Claws** — innate unarmed attack, always available | **Keen scent** — situational bonus to detect/track | + +**Nightsight**, **claws**, and **keen scent** are **flags on the sheet**, not numbers on it. +They are consumed by combat (M5) and by the DM's exploration checks. M4-a stores and +exposes them; it does not act on them. + +The **dwarf's +2** is *situational* (vs poison and the affliction track), so it is **not** +folded into `save("con")` — it is a separate query, `poison_save_bonus()`. Baking a +conditional into a flat number is how a sheet starts lying. + +### Callings (7) + +| Calling | Primary | Hit die | Save profs | Skill pool | Picks | Resource | Armor | L1 talent | +|---|---|---|---|---|---|---|---|---| +| `sellsword` | STR | d10 | STR, CON | athletics · intimidation · endurance · perception | 2 | cooldowns | heavy | `second_wind` | +| `reaver` | STR | d12 | STR, CON | athletics · intimidation · endurance · acrobatics | 2 | cooldowns | medium | `blood_fury` | +| `cutpurse` | DEX | d8 | DEX, MAG | stealth · sleight_of_hand · acrobatics · perception · athletics · intimidation | **4** | cooldowns | light | `backstab` | +| `trapper` | DEX | d10 | DEX, CON | stealth · acrobatics · perception · endurance · athletics | **3** | cooldowns | light | `set_snare` | +| `hedge_mage` | MAG | d6 | MAG, FTH | sorcery · perception · sleight_of_hand · faith_lore | 2 | MP (MAG) | none | `hexbolt` | +| `bonesetter` | FTH | d8 | FTH, CON | faith_lore · perception · endurance · intimidation | 2 | MP (FTH) | medium | `mend` | +| `bloodsworn` | FTH | d8 | FTH, MAG | faith_lore · sorcery · intimidation · perception | 2 | MP (FTH) | light | `pact_mark` | + +The **skill pools are new here** — the races/classes spec said only "drawn from the class's +stat-appropriate slice of the 9-skill list" and never enumerated them. Each pool is the +calling's primary-stat skills plus a small adjacent slice, sized so the choice is real but +not wide open. Cutpurse gets 4 picks from 6 (breadth is the rogue's identity); Trapper 3; +everyone else 2. + +**L1 talents are id stubs.** They name a thing M5 will implement. Nothing here resolves them. + +--- + +## 5. The sheet + +`CharacterSheet` (`client/scripts/state/character_sheet.gd`) — state, so it lives with state. + +**Stored** (rolled, chosen, or genuinely mutable): + +```gdscript +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 proficiencies +var bonus_skill: String # human only, "" otherwise +var hp: int # CURRENT — combat writes this +var mp: int # CURRENT +``` + +**Derived** (functions, never stored): + +```gdscript +func modifier(stat) -> int # floor((score - 10) / 2) +func prof_bonus() -> int # +2 at L1 +func max_hp() -> int # hit_die_max + CON_mod +func max_mp() -> int # TUNABLE — see below +func ac() -> int # 10 + DEX_mod (armor is M7) +func save(stat) -> int # mod + prof_if_proficient + 1_if_human +func poison_save_bonus() -> int # +2 for dwarf, else 0 +func skill_bonus(skill) -> int # mod(governing attr) + prof_if_proficient +func spell_dc() -> int # 8 + prof + casting_mod (casters only) +func initiative() -> int # DEX_mod +func has_nightsight() -> bool # etc. — the race flags +``` + +**LCK is not on the sheet.** It stays in `GameState.luck` / `luck_base`, exactly where it is +today — no save, no skill, no row, no accessor. §7 holds by construction, not by discipline. + +`GameState.stats` (the flat five-int Dictionary) is **replaced** by `GameState.sheet`. + +### Two honest seams + +- **`ac()` is `10 + DEX_mod`.** Armor categories are proficiency categories; actual armor is + an item, and items are M7. The armor category is on the calling and unused until then. +- **`max_mp()` is a marked `TUNABLE` placeholder.** The races/classes spec **explicitly + defers the MP formula to M5**, so this must not pretend to be the answer: + + ```gdscript + # TUNABLE — M5 owns the real MP curve (races-and-classes spec §6). + # Martials have no pool at all; a caster gets a small one off the casting stat. + func max_mp() -> int: + if not Callings.is_caster(calling_id): + return 0 + return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id))) + ``` + + Flagging it beats quietly inventing it — the same courtesy `_roll_stat` was given. The + **shape** (martials 0, casters scale off the casting modifier, floor of 4) is what M4-b's + screen needs to render a bar; the **numbers** are M5's to replace, and no test asserts them + beyond "a martial has 0 and a caster has some." + +--- + +## 6. Creation is seeded, and `construct` re-derives + +Charter §10: *"Seed the RNG per encounter from save state… the difference between a bug you +can reproduce and a bug you cannot."* The races/classes spec says **"character creation is +the first place seeding matters."** So it is seeded. + +### The creation Dictionary + +`NewGame.construct` keeps taking a **plain `Dictionary`** — the saga guardrail: a later saga +synthesizes one and skips the UI entirely, so the creation *scene* must never become the +only thing that can produce this. + +```gdscript +{ + "name": "Aldric", + "race_id": "beastfolk", + "calling_id": "cutpurse", + "seed": 8675309, # the whole character, reproducibly + "spend": {"dex": 2, "con": 1}, # additive-only, ≤ 3 total + "skills": ["stealth", "sleight_of_hand", "acrobatics", "perception"], + "bonus_skill": "" # human only +} +``` + +**It carries a seed, not a stat block.** `construct` reconstructs the RNG from `seed`, +**rolls the base attributes itself**, and applies `spend`. It never trusts numbers handed to +it. Consequences: + +- **Same seed → identical character.** Every time. Directly asserted in test. +- `spend` is **validated**, not assumed — and it is the *only* thing the player adds. +- The UI cannot inject an attribute value, because it never passes one. **Code owns state.** +- The entire character is reproducible from four primitives: `seed`, `race_id`, + `calling_id`, `spend`. + +### Order of operations (fixed, because the RNG order is the determinism) + +1. Seed the RNG from `creation.seed`. +2. **Roll five attributes** in the fixed order `str · dex · con · fth · mag` — + **3d6, hard floor of 8** (`maxi(8, 3d6)`). +3. **Roll LCK hidden** — `Luck.roll_base(rng, origin.build_constraints.luck_modifier)`. Same + RNG, after the stats, so the order is part of the contract. Never shown, never on the + sheet (§7). +4. **Apply `spend`** — additive only. +5. **Apply race** — granted skills, save bonus, flags. +6. **Apply calling** — hit die, save profs, chosen skills, resource, armor category, talent. +7. `hp = max_hp()`, `mp = max_mp()` — the character starts whole. + +### The die: 3d6, floor 8 + +The pool caps at +3, and straight 3d6 rolls 3–6 on a given stat about 9% of the time. A +Hedge-Mage who rolls MAG 5, spends the entire pool, and lands at 8 has a **−1 modifier on +his primary** — bad at the one thing he is *for*, before the game starts. + +That is not grit. §3's grit is *the world does not care about you*; §7 is explicit that bad +luck costs **dignity, not progress**, and that punishing a player for a build choice "is not +comedy. It is a fine." A dead-on-arrival primary is a fine, and it is paid for twenty hours. + +Floor 8 clamps the **die**, not the player: the mean stays low and unheroic (~10.9), the +party is still ordinary people, and the worst case on a primary is 8 → 11 with the full pool +→ a **+0 modifier**, which with the +2 proficiency bonus is a character who is unremarkable +rather than broken. It also makes the +3 pool do what it was designed for — *recovering a bad +roll on the primary* — instead of failing to. + +### Validation (all of it, in `construct`, front-loaded) + +Rejects with `{"ok": false, "errors": [...]}` — never a half-built character: + +- `name` non-empty after trimming. +- `race_id` ∈ `Races.IDS`; `calling_id` ∈ `Callings.IDS`. +- `calling_id` ∈ the origin's `build_constraints.allowed_callings`. +- `spend`: keys ⊆ the five attributes (**`lck` is never spendable**), values non-negative + integers, **sum ≤ 3**. +- `skills`: exactly `Callings.skill_count(calling_id)` of them, all drawn from that calling's + pool, no duplicates, and **none already granted by the race** (an Elf cannot spend a pick + on `perception` — he has it). +- `bonus_skill`: required **iff** race is `human`, empty otherwise; one of the 9; not already + proficient. +- The origin's refs resolve (existing check, unchanged). + +--- + +## 7. The migrations + +`class_id` is the rulebook's word. The world has **callings** — that was the whole §17 +reconcile ("Priest is what a rulebook calls him; Bonesetter is what a village calls him"). +The contract the *Narrator* reads should say what the world says. + +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. + +| Where | Change | +|---|---| +| `docs/schemas/canon-log.schema.json` | `class_id` → **`calling_id`** (enum of the 7); **new required `race_id`** (enum of the 4) | +| `docs/schemas/origin.schema.json` | `build_constraints.allowed_classes` → **`allowed_callings`** (enum of the 7) | +| `client/scripts/canon_log/log_player.gd` | `class_id` → `calling_id`; add `race_id`; **`CLASSES` const deleted** — validate against `Callings.IDS` | +| `client/scripts/newgame/new_game.gd` | the pipeline of §6 | +| `client/scripts/state/game_state.gd` | `stats` Dictionary → `sheet: CharacterSheet` | +| `client/scripts/content/content_db.gd` | load `callings/` + `races/` | +| `api/app/prompts.py:53,107` | `a {class_id}` → **`a {race_id} {calling_id}`** — the AI can finally describe the player's race | +| `content/origins/deserter.json` | `allowed_classes` → `allowed_callings`, with live ids | +| api + client tests, `api/tests/fixtures/canon_log_valid.json` | the renamed fields | + +**The deserter allows all seven callings.** He is a mercenary-company deserter; a thematic +gate is a *content* decision that belongs with the Greywater authoring, not an engine change +invented here. `minItems: 1` is satisfied. + +**Schema/code parity test.** The roster now lives in two places — `Callings.IDS` and the +schema's `enum`. A client test reads `../docs/schemas/canon-log.schema.json` (the same reach +`ContentDB` already uses for `../content`) and asserts the enum **equals** `Callings.IDS`, +and likewise `race_id` ≡ `Races.IDS`. They cannot drift. + +--- + +## 8. Testing + +All headless — the model is pure functions over a dumb store. + +**Tables:** every calling's skill pool is a subset of the 9; every id in `Callings.IDS` has a +content blurb and vice versa (parity); same for races. + +**Derivation:** `modifier()` at the boundaries (7→−2, 8→−1, 10→0, 11→0, 12→+1); +`max_hp()` per calling (a d12 Reaver with CON 14 → 12 + 2 = 14); `save()` picks up calling +proficiency; a **human's +1 lands on all five saves**; a **dwarf's +2 does NOT appear in +`save("con")`** but does in `poison_save_bonus()`; `skill_bonus()` uses the governing +attribute; `spell_dc()` for each caster. + +**The roll:** the floor holds at the bottom of the die (a seed that would roll 3 yields **8**); +the distribution is still 3d6-shaped above the floor. + +**Determinism — the headline test:** the same `seed` + same choices produces a byte-identical +sheet **and** the same hidden LCK. Twice, from two fresh `construct` calls. + +**Validation:** `spend` rejects a negative, a >3 total, and **any attempt to spend on `lck`**; +skills reject a pick outside the pool, a duplicate, and an Elf re-picking `perception`; +`bonus_skill` is rejected when present on a non-human and when missing on a human; +`construct` rejects a calling the origin disallows. + +**§7 guard (extend the existing one):** no numeric Luck and no attribute block appears in +`log.to_dict()` — the log carries only `{name, race_id, calling_id, luck_descriptor}`. + +**Server:** the prompt digest renders `a beastfolk cutpurse`; the canon-log schema rejects a +dead `class_id` and an unknown `calling_id`. + +--- + +## 9. Out of scope + +- **The creation screen** — M4-b. This spec builds only what it will consume. +- **Title → creation → shell** — M4-c. `MainWindowShell._build_seed_log()` still hand-seeds a + `CanonLog`; retiring that placeholder is M4-c's job. +- **The MP formula and the level curve** — M5 (deferred by the races/classes spec). +- **Armor / AC beyond `10 + DEX`** — M7 (armor is an item). +- **The L1 talents' actual effects** — M5. They are id stubs here. +- **Calling and race blurb prose** — the content BOM. Placeholders ship; the split is what + stops that from blocking this. +- **The beastfolk social tax** — deferred to the canon session + M8's shop.