docs(spec): M4-a — the character creation model
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
This commit is contained in:
361
docs/superpowers/specs/2026-07-12-creation-model-design.md
Normal file
361
docs/superpowers/specs/2026-07-12-creation-model-design.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user