Compare commits

...

12 Commits

Author SHA1 Message Date
b663703da7 Merge feat/creation-model into dev (M4-a)
The character-creation model: race/calling/skill mechanics as static tables in
code, their blurbs as content, a CharacterSheet that stores only what was rolled
or chosen and derives the rest, and a seeded NewGame.construct.

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 yields an identical character, hidden Luck included (§10). A golden-vector
test pins the RNG stream. 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 the 'fine' §7 forbids.

Migrates the cross-boundary contract: class_id -> calling_id (the contract should
say what the world says) and a required race_id, so the AI narrates 'a beastfolk
cutpurse' instead of 'a sellsword'.

No UI — the creation screen is M4-b, the title->creation->shell flow is M4-c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:59:36 -05:00
a9357fb787 fix(creation-model): migrate stray class_id/allowed_classes copies
The canon-log schema and NewGame code already migrated to race_id/
calling_id + allowed_callings, but two copies of the roster outside
that schema were missed, and no parity test guards them:

- docs/canon-log.md (the cross-boundary contract doc) still documented
  the OLD player shape and allowed_classes. Anyone building a player
  block from the doc would get a 422.
- .claude/skills/world-building's schema reference still emitted
  allowed_classes with two dead calling ids (assassin, priest). Author
  a new origin with that skill and it fails origin.schema.json
  (additionalProperties: false, allowed_callings required) AND, if that
  ever loosened, silently allows zero callings at runtime.

Also:
- add schema tests rejecting a dead class_id field and an unknown
  calling_id (priest)
- guard NewGame._validate's container types (spend/skills) so a
  malformed JSON round-trip (null spend, string skills) produces a
  front-loaded error list instead of a GDScript runtime crash
- extend the no-mechanics-in-content test to db.races, not just
  db.callings
- rewrite the roadmap's M4 bullet: the two contract migrations landed;
  only the creation screen and title->creation->shell flow remain

client: 250/250. api: 74 passed, 2 skipped. content_build --check: clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:58:49 -05:00
62b8113ffa fix(creation): pin RNG stream, tighten spend/seed validation
Finding 1 (important): test_same_seed_yields_an_identical_character only
compared two construct() runs against each other, so swapping dex/con in
Attributes.IDS still passed the whole suite while every persisted seed
would silently become a different character. Added a golden-vector test
that pins one fixed seed to its exact rolled attributes and hidden Luck,
so reordering Attributes.IDS or moving the Luck roll fails loudly instead
of silently rewriting every saved character. Proved it: swapped IDS,
watched the golden vector fail, restored, confirmed green.

Also: _validate_spend no longer lets a negative value mask the over-pool
check (continue after each error instead of falling through to `total +=
v`); non-integer spend values (float/bool/string) are now rejected instead
of coerced; a missing or non-integer seed is now a validation error instead
of silently defaulting to 0. GameState's header comment no longer
advertises "stats" it no longer holds.

243 -> 248 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:50:06 -05:00
bd49e118d5 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:43:51 -05:00
eba706b906 test: assert seed-log fields, not a fresh LogPlayer
The previous regression tests in test_entities.gd only constructed their
own LogPlayer and never touched the seed logs the real call sites build
— reverting main_window_shell.gd:124 to the broken 3-arg call still left
the whole suite green. Not a real regression guard.

Now assert against node._log.player in the shell test and against the
two dev-harness scenes' seed logs (narrate_harness, npc_harness — both
cheaply testable via instantiate + add_child_autofree, no scaffolding
needed). Proved the new shell assertion fails when the call site is
reverted to the 3-arg shape (226/227, one failure), then passes restored
(227/227). Removed a redundant ctor test from test_entities.gd; kept the
one that exercises the exact broken-shape call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:39:48 -05:00
5149e817f6 fix(contract): repair 3-arg LogPlayer callers + digest article bug
Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.

Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.

Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".

Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
  calling_id; a calling passed into the race_id slot (the exact shape
  of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
  digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
  allowed_callings enum matches Callings.IDS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:34:05 -05:00
a668cf98eb 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:27:56 -05:00
bb5d176262 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:21:45 -05:00
a7e153884f 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:18:19 -05:00
78e18107b2 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.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:14:44 -05:00
89d4c65a7a docs(plan): M4-a — the creation model, six tasks
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
2026-07-12 20:08:21 -05:00
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
57 changed files with 3275 additions and 119 deletions

View File

@@ -252,7 +252,7 @@ construction fails loudly.
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
"start_quest_id": "find_the_ledger",
"build_constraints": { "allowed_classes": ["sellsword","assassin","priest"], "luck_modifier": 0 }
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
}
```

View File

@@ -12,6 +12,29 @@ from pathlib import Path
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
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:
if not word:
return "a"
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", "")
parts = [p for p in (race, calling) if p]
if not parts:
return name
descriptor = " ".join(parts)
return f"{name}, {_article(parts[0])} {descriptor}".strip()
@lru_cache(maxsize=None)
def system_prompt(role: str) -> str:
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
@@ -50,7 +73,7 @@ def render_digest(canon_log: dict) -> str:
descriptor = player.get("luck_descriptor")
if descriptor:
lines.append(f"Fortune: {descriptor}")
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
lines.append(f"Player: {_describe_player(player)}")
lines.append("")
lines.append("Describe the scene as it is now.")
@@ -104,7 +127,7 @@ def render_npc_digest(
lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")
player = canon_log.get("player", {})
lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")
lines.append(f"The player is {_describe_player(player)}.")
lines.append("")
lines.append(f'The player says to you: "{utterance}"')

View File

@@ -2,7 +2,8 @@
"schema_version": 1,
"player": {
"name": "Aldric",
"class_id": "sellsword",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "Fortune spits on you"
},
"location": { "id": "greywater_docks", "name": "the Greywater docks" },

View File

@@ -68,3 +68,20 @@ def test_negative_turn_is_rejected():
doc = _valid()
doc["humiliations"][0]["turn"] = -1
assert validate_canon_log(doc) != []
def test_dead_class_id_field_is_rejected():
# The player block migrated from {name, class_id, luck_descriptor} to
# {name, race_id, calling_id, luck_descriptor}. class_id must not slip
# back in via additionalProperties: false.
doc = _valid()
doc["player"]["class_id"] = "sellsword"
assert validate_canon_log(doc) != []
def test_unknown_calling_id_is_rejected():
# assassin/priest are dead ids from the pre-migration class roster and
# must not validate as calling_id values.
doc = _valid()
doc["player"]["calling_id"] = "priest"
assert validate_canon_log(doc) != []

View File

@@ -30,7 +30,8 @@ def test_live_npc_speak_fenn():
log = {
"player": {
"name": "Aldric",
"class_id": "sellsword",
"race_id": "human",
"calling_id": "sellsword",
"luck_descriptor": "the dice are kind",
},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},

View File

@@ -4,7 +4,7 @@ import app.npc as npc
from app.ollama_client import ModelError
LOG = {
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": [], "established_facts": [],
"active_quests": [], "humiliations": [],

View File

@@ -14,7 +14,7 @@ VALID_ORIGIN = {
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
"luck_modifier": 0,
},
}
@@ -30,9 +30,9 @@ def test_null_start_quest_is_allowed():
assert validate_origin(doc) == []
def test_unknown_class_is_rejected():
def test_unknown_calling_is_rejected():
doc = copy.deepcopy(VALID_ORIGIN)
doc["build_constraints"]["allowed_classes"] = ["bard"]
doc["build_constraints"]["allowed_callings"] = ["bard"]
assert validate_origin(doc) != []

View File

@@ -22,7 +22,7 @@ def test_digest_has_the_narrative_context():
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
assert "Fortune: Fortune spits on you" in d
assert "Player: Aldric, a sellsword" in d
assert "Player: Aldric, a human sellsword" in d
assert d.rstrip().endswith("Describe the scene as it is now.")
@@ -42,10 +42,47 @@ def test_narrator_body_is_authored():
assert "second person" in body # the core task is present
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
def test_digest_omits_race_clause_when_race_is_empty():
# Regression: _article("") used to return "an" (Python's "" in "aeiou" is
# True), rendering "Player: Aldric, an " — wrong article, doubled space.
out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword"))
assert "Player: Aldric, a sellsword" in out
assert " " not in out
assert ", an " not in out
def test_digest_omits_both_clauses_when_race_and_calling_are_empty():
out = render_digest(_with_player(name="Aldric", race_id="", calling_id=""))
assert "\nPlayer: Aldric\n" in out
assert " " not in out
def test_render_npc_digest_includes_all_sections():
from app import prompts
log = {
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"},
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
"party": [], "recent_events": ["a gull screamed"],
"established_facts": ["the eastern bridge is out"],

View File

@@ -1,33 +1,46 @@
class_name LogPlayer
extends RefCounted
## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7):
## no numeric Luck, no stats — those live in GameState and never reach the AI.
const CLASSES := ["sellsword", "assassin", "priest"]
## 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 class_id: String
var race_id: String
var calling_id: String
var luck_descriptor: String
func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void:
func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
name = p_name
luck_descriptor = p_luck_descriptor
if p_class_id != "":
set_class_id(p_class_id)
if p_race_id != "":
set_race_id(p_race_id)
if p_calling_id != "":
set_calling_id(p_calling_id)
func set_class_id(v: String) -> bool:
if v not in CLASSES:
push_error("invalid class_id: %s" % v)
func set_race_id(v: String) -> bool:
if not Races.exists(v):
push_error("invalid race_id: %s" % v)
return false
class_id = v
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, "class_id": class_id, "luck_descriptor": luck_descriptor}
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("class_id", ""), d.get("luck_descriptor", ""))
return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
d.get("calling_id", ""), d.get("luck_descriptor", ""))

View File

@@ -8,6 +8,8 @@ var locations: Dictionary = {}
var npcs: Dictionary = {}
var quests: Dictionary = {}
var items: Dictionary = {}
var callings: Dictionary = {}
var races: Dictionary = {}
var canon_entities: Dictionary = {}
var topics: Dictionary = {}
@@ -31,6 +33,8 @@ func load_from(content_root: String) -> void:
npcs = _load_dir(world.path_join("npcs"))
quests = _load_dir(world.path_join("quests"))
items = _load_dir(world.path_join("items"))
callings = _load_dir(world.path_join("callings"))
races = _load_dir(world.path_join("races"))
canon_entities = _load_dir(world.path_join("canon"))
topics = _load_dir(world.path_join("topics"))
@@ -61,6 +65,10 @@ func has_location(id: String) -> bool: return locations.has(id)
func has_npc(id: String) -> bool: return npcs.has(id)
func has_quest(id: String) -> bool: return quests.has(id)
func has_item(id: String) -> bool: return items.has(id)
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)
func canon(id: String) -> Dictionary: return canon_entities.get(id, {})
func topic(id: String) -> Dictionary: return topics.get(id, {})
func has_canon(id: String) -> bool: return canon_entities.has(id)

View File

@@ -59,7 +59,7 @@ func _on_narrate_pressed() -> void:
func _build_scene_log() -> CanonLog:
var log := CanonLog.new()
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
log.player = LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you")
log.set_location("greywater_docks", "the Greywater docks")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))

View File

@@ -39,7 +39,7 @@ func _ready() -> void:
# Build the player through LogPlayer so luck_descriptor is non-empty — the
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
# the digest surfaces to the NPC. An empty string here 422s the request.
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
_canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today")
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
# can offer_quest it during the conversation.

View File

@@ -1,42 +1,66 @@
class_name NewGame
extends RefCounted
## New-game construction (spec "New-game construction"). Reads three immutable
## inputs (origin, world, creation) and writes two products (CanonLog, GameState).
## Nothing flows back up — charter §2 on the client. Validation is front-loaded:
## a broken origin or illegal class fails here, loudly, not three scenes later.
## 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.
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
var errors: Array = []
const SPEND_POOL := 3
# 1. content integrity — every referenced id must resolve in world content.
for ref in world.unresolved_refs(origin):
errors.append("unresolved ref: %s" % ref)
# 2. build constraints — chosen class must be allowed by the origin.
var bc: Dictionary = origin.get("build_constraints", {})
var allowed: Array = bc.get("allowed_classes", [])
var class_id: String = creation.get("class_id", "")
if class_id not in allowed:
errors.append("class not allowed by origin: %s" % class_id)
if creation.get("name", "").strip_edges() == "":
errors.append("player name is required")
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}
# 3. roll Luck (numeric -> state only) and 4. the stat block (data only).
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
state.stats = {
"str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng),
"fth": _roll_stat(rng), "mag": _roll_stat(rng),
}
# 5. assemble the canon log.
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(creation.get("name", ""), class_id, state.luck_descriptor())
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", ""))
@@ -46,7 +70,6 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
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))))
# non-companion overrides go to game state, never the log (spec §5 / charter §6).
for npc_id in overrides:
if not companion_ids.has(npc_id):
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
@@ -59,16 +82,118 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary
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", "")))
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
qd.get("objective", "")))
# inventory grants -> game state (NOT the log). Charter §2. grant() routes
# currency to the purse; everything else to the inventory dict.
for grant in origin.get("inventory_grants", []):
state.grant(grant.get("item_id", ""), int(grant.get("qty", 0)))
# 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_stat(rng: RandomNumberGenerator) -> int:
# 3d6 placeholder; the stat block is data-only in this plan. TUNABLE.
return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
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")
if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT:
errors.append("seed is required and must be an integer")
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)
# Guard container SHAPES before any statically-typed call below touches them.
# A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} —
# without this check, assigning that into a typed Dictionary/Array parameter
# throws a GDScript runtime error INSIDE construct() instead of landing in the
# front-loaded {"ok": false, "errors": [...]} the spec promises.
if typeof(creation.get("spend", {})) != TYPE_DICTIONARY:
errors.append("spend must be an object")
else:
errors.append_array(_validate_spend(creation.get("spend", {})))
if typeof(creation.get("skills", [])) != TYPE_ARRAY:
errors.append("skills must be an array")
elif 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
if typeof(spend[stat]) != TYPE_INT:
errors.append("spend value for '%s' must be an integer" % stat)
continue
var v := int(spend[stat])
if v < 0:
errors.append("spend is additive only: %s is negative" % stat)
continue
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

View File

@@ -0,0 +1,15 @@
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)

View File

@@ -0,0 +1 @@
uid://dnlwajfs3vysm

View File

@@ -0,0 +1,93 @@
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", ""))

View File

@@ -0,0 +1 @@
uid://cewlixhavh1h4

View File

@@ -0,0 +1,60 @@
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))

View File

@@ -0,0 +1 @@
uid://bmkoxq4eabfcq

View File

@@ -0,0 +1,28 @@
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, ""))

View File

@@ -0,0 +1 @@
uid://d2pvtvrrhc27b

View File

@@ -0,0 +1,94 @@
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")

View File

@@ -0,0 +1 @@
uid://mofimtmsxuui

View File

@@ -1,12 +1,13 @@
class_name GameState
extends RefCounted
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
## stats, world-NPC dispositions, and inventory — none of which enter the canon
## log (charter §7/§2). The log reads only luck_descriptor() from here.
## the character sheet, world-NPC dispositions, and inventory — none of which
## enter the canon log (charter §7/§2). The log reads only luck_descriptor()
## from here.
var luck: int = 0
var luck_base: int = 0
var stats: Dictionary = {} # {str, dex, con, fth, mag}
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
var npc_dispositions: Dictionary = {} # world-npc id -> int (100..100)
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).

View File

@@ -121,7 +121,7 @@ func _build_seed_log() -> CanonLog:
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
# number is rendered anywhere (§7); the descriptor is prose-only.
var log := CanonLog.new()
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
log.player = LogPlayer.new("Vexcca", "human", "sellsword", "Fortune spits on you")
log.set_location("lower_ward", "the Lower Ward")
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))

View File

@@ -0,0 +1,91 @@
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")

View File

@@ -0,0 +1 @@
uid://dgi5hu3y2fg2u

View File

@@ -91,3 +91,42 @@ func test_no_loaded_topic_has_a_body():
func test_legacy_npcs_still_load():
assert_true(db.has_npc("fenn"))
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])
for id in db.races:
for banned in ["save_bonus", "poison_save_bonus", "granted_skills", "flags",
"nightsight", "claws", "keen_scent"]:
assert_false(db.races[id].has(banned),
"%s.json leaks the mechanic '%s' — that lives in Races" % [id, banned])

View File

@@ -0,0 +1,25 @@
extends "res://addons/gut/test.gd"
## Guards the two dev-only proving-scene seed logs (narrate_harness,
## npc_harness) against the same LogPlayer arg-slot regression the shell test
## guards (see test_main_window_shell.gd). Both scenes build their seed log's
## LogPlayer in _ready() with no network call until a button is pressed, so
## instantiate + add_child_autofree is cheap and touches no network.
const NARRATE_SCENE := "res://scenes/narrate_harness.tscn"
const NPC_SCENE := "res://scenes/npc_harness.tscn"
func test_narrate_harness_seed_log_has_real_race_and_calling():
var node = load(NARRATE_SCENE).instantiate()
add_child_autofree(node)
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")
func test_npc_harness_seed_log_has_real_race_and_calling():
var node = load(NPC_SCENE).instantiate()
add_child_autofree(node)
assert_true(Races.exists(node._canon_log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._canon_log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._canon_log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")

View File

@@ -0,0 +1 @@
uid://bmaooitlx0ox4

View File

@@ -14,18 +14,36 @@ func test_ids_regex():
assert_false(Ids.is_valid("has-hyphen"))
func test_player_dict_has_only_three_keys():
var p = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d = p.to_dict()
assert_eq(d.keys().size(), 3)
assert_false("luck" in d)
assert_eq(d["class_id"], "sellsword")
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_player_rejects_unknown_class():
var p = LogPlayer.new("Aldric", "sellsword", "x")
assert_false(p.set_class_id("bard"))
assert_eq(p.class_id, "sellsword")
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"))
func test_log_player_ctor_rejects_calling_passed_as_race():
# The exact shape of the bug: calling three broken call sites used to pass —
# LogPlayer.new(name, class_id, luck_descriptor) against the NEW ctor
# (name, race_id, calling_id, luck_descriptor). "sellsword" lands in the
# race_id slot (rejected — not a race), the descriptor lands in the
# calling_id slot (rejected — not a calling), and luck_descriptor is left
# at its default. Every field ends up "", which 422s against the schema.
var p := LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
var d := p.to_dict()
assert_eq(d["race_id"], "", "a calling id is not a legal race_id")
assert_eq(d["calling_id"], "", "a luck descriptor is not a legal calling_id")
assert_eq(d["luck_descriptor"], "", "old 3-arg call shape must not produce a usable player row")
func test_party_member_clamps_disposition():

View File

@@ -3,6 +3,12 @@ extends "res://addons/gut/test.gd"
const GameState = preload("res://scripts/state/game_state.gd")
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")
func test_add_item_accumulates():
var s = GameState.new()
s.add_item("worn_shortsword", 3)

View File

@@ -35,3 +35,15 @@ func test_shell_builds_a_code_owned_seed_log():
var node := _shell()
assert_not_null(node._log, "a canon log exists to post (§11)")
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
func test_shell_seed_player_has_a_real_race_and_calling():
# Regression: the shell's _build_seed_log() once called LogPlayer.new() with
# the OLD 3-arg (name, class_id, luck_descriptor) shape against the NEW
# 4-arg ctor. Every param defaults, so it compiled silently and both
# race_id/calling_id came out "" — which 422s against the canon-log schema.
# Assert against the log the shell actually builds, not a fresh LogPlayer.
var node := _shell()
assert_true(Races.exists(node._log.player.race_id), "seed race must be a real race")
assert_true(Callings.exists(node._log.player.calling_id), "seed calling must be a real calling")
assert_ne(node._log.player.luck_descriptor, "", "the descriptor must not be lost to an arg-slot shift")

View File

@@ -15,31 +15,212 @@ func _deserter() -> Dictionary:
return ContentDB.load_json(ContentDB.origin_path("deserter"))
func _rng() -> RandomNumberGenerator:
var r := RandomNumberGenerator.new()
r.seed = 7
return r
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, _rng())
return NewGame.construct(_deserter(), world, creation)
func test_construct_succeeds():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
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_golden_vector_pins_the_rng_stream():
# The determinism test above (`test_same_seed_yields_an_identical_character`)
# compares two `construct` runs against EACH OTHER. Both would drift together
# if the roll order changed, so it proves nothing about the ORDER itself.
# Swap two entries in `Attributes.IDS` and that test still passes while every
# already-persisted seed silently becomes a different character.
#
# This test pins one seed to its EXACT rolled values — all five attributes and
# the hidden Luck, drawn from the same stream immediately after — so reordering
# `Attributes.IDS` or moving the Luck roll fails LOUDLY, here, instead of
# silently rewriting every saved character.
var res := _build(_creation({"seed": 8675309}))
assert_true(res["ok"], str(res["errors"]))
var attrs: Dictionary = res["state"].sheet.attributes
assert_eq(int(attrs["str"]), 8)
assert_eq(int(attrs["dex"]), 13)
assert_eq(int(attrs["con"]), 8)
assert_eq(int(attrs["fth"]), 14)
assert_eq(int(attrs["mag"]), 8)
assert_eq(int(res["state"].luck), 6)
func test_numeric_luck_absent_from_log():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
func test_a_missing_seed_is_rejected():
# A seed silently defaulting to 0 builds a perfectly valid, deterministic
# character — which is exactly the problem: a saga that forgets the key
# gets no signal at all. Require it explicitly.
var creation := _creation()
creation.erase("seed")
var res := NewGame.construct(_deserter(), world, creation)
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
func test_a_non_integer_seed_is_rejected():
var res := _build(_creation({"seed": "8675309"}))
assert_false(res["ok"])
assert_true("seed" in str(res["errors"]).to_lower())
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_a_negative_spend_does_not_mask_an_over_pool_spend():
# {"str": -1, "dex": 4} sums to 3 — at the pool limit — so without a
# `continue`/floor after the negative-value error, the over-pool check
# never independently fires; only the negative-value error does.
var res := _build(_creation({"spend": {"str": -1, "dex": 4}}))
assert_false(res["ok"])
var joined := str(res["errors"]).to_lower()
assert_true("negative" in joined, "the negative-value error should fire")
assert_true("exceeds the pool" in joined, "the over-pool error should ALSO fire")
func test_spend_rejects_non_integer_values():
var floats := _build(_creation({"spend": {"str": 2.9}}))
assert_false(floats["ok"], "a float must be rejected, not truncated to +2")
var bools := _build(_creation({"spend": {"str": true}}))
assert_false(bools["ok"], "a bool must be rejected, not coerced to +1")
var strings := _build(_creation({"spend": {"str": "abc"}}))
assert_false(strings["ok"], "a string must be rejected, not coerced to +0")
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_eq(pd.keys().size(), 3)
assert_false("attributes" in pd)
assert_true(res["state"].luck >= Luck.MIN and res["state"].luck <= Luck.MAX)
func test_inventory_and_dispo_land_in_state_not_log():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["state"].inventory.get("worn_shortsword", 0), 1)
assert_eq(res["state"].purse_copper, 47, "the deserter is down to his last coin")
assert_false("copper" in res["state"].inventory, "money is never an inventory row")
@@ -47,20 +228,20 @@ func test_inventory_and_dispo_land_in_state_not_log():
func test_companion_dispositions_from_overrides():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].party_member("brannoc_thane").disposition, 40)
assert_eq(res["log"].party_member("cadwyn_vell").disposition, 15)
func test_situation_and_facts_seeded():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].recent_events.size(), 2)
assert_true("the player deserted the Iron Kettle mercenary company" in res["log"].established_facts)
assert_eq(res["log"].humiliations.size(), 0)
func test_active_quest_resolved_from_world():
var res := _build({"name": "Aldric", "class_id": "sellsword"})
var res := _build(_creation())
assert_eq(res["log"].active_quests.size(), 1)
assert_eq(res["log"].active_quests[0].id, "find_the_ledger")
assert_eq(res["log"].active_quests[0].status, "active")
@@ -69,42 +250,50 @@ func test_active_quest_resolved_from_world():
func test_null_start_quest_yields_no_quest():
var o := _deserter()
o["start_quest_id"] = null
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_eq(res["log"].active_quests.size(), 0)
func test_class_not_allowed_is_rejected():
var res := _build({"name": "X", "class_id": "berserker"})
assert_false(res["ok"])
assert_true(res["errors"].size() > 0)
assert_null(res["log"])
func test_unresolved_origin_is_rejected():
var o := _deserter()
o["start_location_id"] = "nowhere"
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_false(res["ok"])
assert_true("unresolved ref: location:nowhere" in res["errors"])
func test_empty_name_is_rejected():
var missing_name := _build({"class_id": "sellsword"})
var missing_name := _build(_creation({"name": ""}))
assert_false(missing_name["ok"])
assert_true(missing_name["errors"].size() > 0)
assert_null(missing_name["log"])
var whitespace_name := _build({"name": " ", "class_id": "sellsword"})
var whitespace_name := _build(_creation({"name": " "}))
assert_false(whitespace_name["ok"])
assert_true(whitespace_name["errors"].size() > 0)
assert_null(whitespace_name["log"])
func test_a_null_spend_is_rejected_not_crashed():
# A JSON round-trip can hand back {"spend": null}. Without a container-type
# guard, assigning null into a statically-typed Dictionary parameter throws
# a GDScript runtime error INSIDE construct() instead of a front-loaded error.
var res := _build(_creation({"spend": null}))
assert_false(res["ok"])
assert_true("spend" in str(res["errors"]).to_lower())
func test_non_array_skills_is_rejected_not_crashed():
var res := _build(_creation({"skills": "athletics"}))
assert_false(res["ok"])
assert_true("skills" in str(res["errors"]).to_lower())
func test_non_companion_override_goes_to_state_not_log():
world.npcs["oda_fenn"] = {"id": "oda_fenn", "name": "Oda Fenn", "role": "npc"}
var o := _deserter()
o["disposition_overrides"] = {"brannoc_thane": 40, "oda_fenn": -25}
var res := NewGame.construct(o, world, {"name": "X", "class_id": "sellsword"}, _rng())
var res := NewGame.construct(o, world, _creation())
assert_true(res["ok"], str(res["errors"]))
assert_eq(res["state"].npc_dispositions["oda_fenn"], -25)
assert_null(res["log"].party_member("oda_fenn"))

View File

@@ -8,10 +8,17 @@ const CanonLog = preload("res://scripts/canon_log/canon_log.gd")
func _built_log():
var world = ContentDB.new()
world.load_from(ContentDB.default_content_root())
var rng := RandomNumberGenerator.new()
rng.seed = 3
var origin: Dictionary = ContentDB.load_json(ContentDB.origin_path("deserter"))
var res := NewGame.construct(origin, world, {"name": "Aldric", "class_id": "priest"}, rng)
var creation := {
"name": "Aldric",
"race_id": "human",
"calling_id": "bonesetter",
"seed": 3,
"spend": {},
"skills": ["faith_lore", "perception"],
"bonus_skill": "endurance",
}
var res := NewGame.construct(origin, world, creation)
assert_true(res["ok"], str(res["errors"]))
return res["log"]
@@ -28,9 +35,10 @@ func test_constructed_log_satisfies_contract_invariants():
var d: Dictionary = _built_log().to_dict()
assert_eq(d["schema_version"], 1)
assert_true(d["recent_events"].size() <= 5)
assert_eq(d["player"].keys().size(), 3) # exactly name/class_id/luck_descriptor
assert_eq(d["player"].keys().size(), 4) # exactly name/race_id/calling_id/luck_descriptor
assert_false("luck" in d["player"]) # §7: no numeric luck
assert_true(d["player"]["class_id"] in ["sellsword", "assassin", "priest"])
assert_true(d["player"]["calling_id"] in Callings.IDS)
assert_true(d["player"]["race_id"] in Races.IDS)
for m in d["party"]:
assert_true(m["disposition"] >= -100 and m["disposition"] <= 100)
for q in d["active_quests"]:

View File

@@ -0,0 +1,97 @@
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)

View File

@@ -0,0 +1 @@
uid://bjld6ctq8tqnk

View File

@@ -0,0 +1,53 @@
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")
func test_origin_schema_allowed_callings_matches_the_code():
# origin.schema.json's build_constraints.allowed_callings duplicates the
# same seven calling ids inline (a JSON Schema "enum" cannot $ref another
# file's enum), so nothing at runtime reconciles it with Callings.IDS
# either. This guards it the same way _player_schema() guards canon-log.
var path := ProjectSettings.globalize_path("res://") \
.path_join("../docs/schemas/origin.schema.json").simplify_path()
var doc: Variant = ContentDB.load_json(path)
assert_eq(typeof(doc), TYPE_DICTIONARY, "origin schema did not parse")
var enum_ids: Array = doc["properties"]["build_constraints"]["properties"]["allowed_callings"]["items"]["enum"]
enum_ids.sort()
var code_ids: Array = Callings.IDS.duplicate()
code_ids.sort()
assert_eq(enum_ids, code_ids)

View File

@@ -0,0 +1 @@
uid://dond21vwh46rt

View File

@@ -19,7 +19,8 @@
],
"start_quest_id": "find_the_ledger",
"build_constraints": {
"allowed_classes": ["sellsword", "assassin", "priest"],
"allowed_callings": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"],
"luck_modifier": 0
}
}

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "id": "sellsword", "name": "Sellsword", "blurb": "You fight for money. You are good at it, and the work has never once run out." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "id": "dwarf", "name": "Dwarf", "blurb": "You have drunk worse and survived worse. The rot that takes other men tends to think better of it." }

View File

@@ -0,0 +1 @@
{ "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." }

View File

@@ -0,0 +1 @@
{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." }

View File

@@ -27,7 +27,7 @@ Full field rules live in `canon-log.schema.json`. Summary:
```json
{
"schema_version": 1,
"player": { "name": "…", "class_id": "sellsword|assassin|priest", "luck_descriptor": "…" },
"player": { "name": "…", "race_id": "human|elf|dwarf|beastfolk", "calling_id": "sellsword|reaver|cutpurse|trapper|hedge_mage|bonesetter|bloodsworn", "luck_descriptor": "…" },
"location": { "id": "…", "name": "…" },
"party": [ { "id": "…", "name": "…", "disposition": -100..100 } ],
"recent_events": ["… (≤5, rolling)"],
@@ -39,9 +39,13 @@ Full field rules live in `canon-log.schema.json`. Summary:
**The §7 boundary (enforced structurally).** The schema sets
`additionalProperties: false` on the root and on `player`, and `player` admits
only `name`, `class_id`, `luck_descriptor`. **Numeric Luck, stats, HP/MP, and
inventory cannot be represented in the canon log** — the AI never sees a number
it could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
only `name`, `race_id`, `calling_id`, `luck_descriptor`. `race_id` and
`calling_id` are there so the Narrator and NPCs can *describe* the player (a
"beastfolk cutpurse" reads different scene text than a "dwarf bonesetter") —
they are identity for prose, not mechanics. **Numeric Luck, stats, HP/MP, and
inventory cannot be represented in the canon log** — the mechanical sheet stays
in `GameState` and never reaches the log, and the AI never sees a number it
could use to calculate Luck. Only `luck_descriptor` (e.g. "Fortune spits on
you") crosses the boundary.
All string ids match `^[a-z0-9_]+$`.
@@ -59,7 +63,7 @@ Full rules in `origin.schema.json`. An origin seeds the initial log:
"disposition_overrides": { "<npc_id>": -100..100 },
"inventory_grants": [ { "item_id": "…", "qty": 1.. } ],
"start_quest_id": "…|null",
"build_constraints": { "allowed_classes": ["sellsword",], "luck_modifier": 0 }
"build_constraints": { "allowed_callings": ["sellsword",], "luck_modifier": 0 }
}
```

View File

@@ -75,7 +75,9 @@ Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (
### M4 — Character creation
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): calling blurbs + the Bloodsworn's patron choice (fixed); origins (variable). **~4 pieces.***
- **Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built). **Two contract migrations land here, not silently:** the canon log gains a `race` field (the Narrator needs it to describe the player), and `content/origins/deserter.json` still carries the dead `allowed_classes: ["sellsword","assassin","priest"]`. **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). Emit `race_id` + `calling_id` into it. *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
- **Contract migration** — the two contract migrations this milestone owed have **landed**: the canon log's `player` block is now `{name, race_id, calling_id, luck_descriptor}` (the Narrator/NPCs describe the player as e.g. "a beastfolk cutpurse"), and an origin's build-constraints key is now `allowed_callings` (`content/origins/deserter.json` lists all seven real calling ids; the pre-migration key and its two dead class ids are gone from the codebase). Schemas, docs, fixtures, and the world-building skill's content template all agree.
-**Character creation UI (M4-b)** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **The class-name reconcile is settled** — all seven callings are named (Sellsword · **Reaver** · Cutpurse · Trapper · Hedge-Mage · Bonesetter · **Bloodsworn**; [races/classes spec](superpowers/specs/2026-07-11-races-and-classes-design.md) §4). Feeds new-game canon-log construction (already built, and already emits `race_id` + `calling_id` per the migration above). **⛨ saga:** `NewGame.construct` takes `creation` as a plain `Dictionary` — keep it that way. The creation *scene* produces that dict but must not become the only thing that can (a saga synthesizes it and skips the UI). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
-**Title → creation → shell flow (M4-c)** — wire the title screen's *new game* path into character creation and on into the M3 shell, without hardcoding a `new game → creation → world` linear flow (the saga's fourth entry path, §M3, must still be addable later). *§2: n/a (flow) · depends on M4-b + the Title screen (M3) · goal: a playable start-to-shell loop.*
### M5 — Tactical combat (gridless)
*Content it consumes → [BOM](../content/roadmap.md#2-bill-of-materials): abilities for the 3 POC classes (fixed); 1 enemy family (4 units) + boss, **tier 1** (variable per tier). **~15 pieces.***

View File

@@ -13,10 +13,14 @@
"player": {
"type": "object",
"additionalProperties": false,
"required": ["name", "class_id", "luck_descriptor"],
"required": ["name", "race_id", "calling_id", "luck_descriptor"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"class_id": { "enum": ["sellsword", "assassin", "priest"] },
"race_id": { "enum": ["human", "elf", "dwarf", "beastfolk"] },
"calling_id": {
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"]
},
"luck_descriptor": { "type": "string", "minLength": 1 }
}
},

View File

@@ -39,12 +39,15 @@
"build_constraints": {
"type": "object",
"additionalProperties": false,
"required": ["allowed_classes", "luck_modifier"],
"required": ["allowed_callings", "luck_modifier"],
"properties": {
"allowed_classes": {
"allowed_callings": {
"type": "array",
"minItems": 1,
"items": { "enum": ["sellsword", "assassin", "priest"] }
"items": {
"enum": ["sellsword", "reaver", "cutpurse", "trapper",
"hedge_mage", "bonesetter", "bloodsworn"]
}
},
"luck_modifier": { "type": "integer" }
}

File diff suppressed because it is too large Load Diff

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.