Files
code_of_conquest_dnd/docs/superpowers/specs/2026-07-12-creation-model-design.md
Phillip Tarrant 9de8ee40b3 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
2026-07-12 19:48:46 -05:00

18 KiB
Raw Blame History

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 — 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):

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):

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:

    # 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.

{
  "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 · mag3d6, hard floor of 8 (maxi(8, 3d6)).
  3. Roll LCK hiddenLuck.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_idRaces.IDS; calling_idCallings.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_idcalling_id (enum of the 7); new required race_id (enum of the 4)
docs/schemas/origin.schema.json build_constraints.allowed_classesallowed_callings (enum of the 7)
client/scripts/canon_log/log_player.gd class_idcalling_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_classesallowed_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_idRaces.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.