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:
2026-07-12 19:48:46 -05:00
parent 198ea27d1a
commit 9de8ee40b3

View 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 36 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.