diff --git a/.claude/skills/world-building/references/schema.md b/.claude/skills/world-building/references/schema.md index 2ebea44..0bd49f7 100644 --- a/.claude/skills/world-building/references/schema.md +++ b/.claude/skills/world-building/references/schema.md @@ -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 } } ``` diff --git a/api/app/prompts.py b/api/app/prompts.py index 094f3ab..80f1580 100644 --- a/api/app/prompts.py +++ b/api/app/prompts.py @@ -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}"') diff --git a/api/tests/fixtures/canon_log_valid.json b/api/tests/fixtures/canon_log_valid.json index b0587c7..a4e8996 100644 --- a/api/tests/fixtures/canon_log_valid.json +++ b/api/tests/fixtures/canon_log_valid.json @@ -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" }, diff --git a/api/tests/test_canon_log_schema.py b/api/tests/test_canon_log_schema.py index c5b3908..24b248a 100644 --- a/api/tests/test_canon_log_schema.py +++ b/api/tests/test_canon_log_schema.py @@ -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) != [] diff --git a/api/tests/test_live_smoke.py b/api/tests/test_live_smoke.py index 887741b..2b4a681 100644 --- a/api/tests/test_live_smoke.py +++ b/api/tests/test_live_smoke.py @@ -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"}, diff --git a/api/tests/test_npc.py b/api/tests/test_npc.py index c4d4893..a547482 100644 --- a/api/tests/test_npc.py +++ b/api/tests/test_npc.py @@ -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": [], diff --git a/api/tests/test_origin_schema.py b/api/tests/test_origin_schema.py index 036b58e..1a947f1 100644 --- a/api/tests/test_origin_schema.py +++ b/api/tests/test_origin_schema.py @@ -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) != [] diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py index d1fceb1..2cde12d 100644 --- a/api/tests/test_prompts.py +++ b/api/tests/test_prompts.py @@ -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"], diff --git a/client/scripts/canon_log/log_player.gd b/client/scripts/canon_log/log_player.gd index d0620df..d0a268c 100644 --- a/client/scripts/canon_log/log_player.gd +++ b/client/scripts/canon_log/log_player.gd @@ -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", "")) diff --git a/client/scripts/content/content_db.gd b/client/scripts/content/content_db.gd index 6752e4c..732281c 100644 --- a/client/scripts/content/content_db.gd +++ b/client/scripts/content/content_db.gd @@ -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) diff --git a/client/scripts/harness/narrate_harness.gd b/client/scripts/harness/narrate_harness.gd index 0a6c862..aaccb91 100644 --- a/client/scripts/harness/narrate_harness.gd +++ b/client/scripts/harness/narrate_harness.gd @@ -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)) diff --git a/client/scripts/harness/npc_harness.gd b/client/scripts/harness/npc_harness.gd index 2166bd2..d71bfa6 100644 --- a/client/scripts/harness/npc_harness.gd +++ b/client/scripts/harness/npc_harness.gd @@ -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. diff --git a/client/scripts/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 3f52e46..bc6f765 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -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 diff --git a/client/scripts/rules/attributes.gd b/client/scripts/rules/attributes.gd new file mode 100644 index 0000000..64b4001 --- /dev/null +++ b/client/scripts/rules/attributes.gd @@ -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) diff --git a/client/scripts/rules/attributes.gd.uid b/client/scripts/rules/attributes.gd.uid new file mode 100644 index 0000000..881146b --- /dev/null +++ b/client/scripts/rules/attributes.gd.uid @@ -0,0 +1 @@ +uid://dnlwajfs3vysm diff --git a/client/scripts/rules/callings.gd b/client/scripts/rules/callings.gd new file mode 100644 index 0000000..cb1b863 --- /dev/null +++ b/client/scripts/rules/callings.gd @@ -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", "")) diff --git a/client/scripts/rules/callings.gd.uid b/client/scripts/rules/callings.gd.uid new file mode 100644 index 0000000..dba102c --- /dev/null +++ b/client/scripts/rules/callings.gd.uid @@ -0,0 +1 @@ +uid://cewlixhavh1h4 diff --git a/client/scripts/rules/races.gd b/client/scripts/rules/races.gd new file mode 100644 index 0000000..aa7e702 --- /dev/null +++ b/client/scripts/rules/races.gd @@ -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)) diff --git a/client/scripts/rules/races.gd.uid b/client/scripts/rules/races.gd.uid new file mode 100644 index 0000000..743d943 --- /dev/null +++ b/client/scripts/rules/races.gd.uid @@ -0,0 +1 @@ +uid://bmkoxq4eabfcq diff --git a/client/scripts/rules/skills.gd b/client/scripts/rules/skills.gd new file mode 100644 index 0000000..a318305 --- /dev/null +++ b/client/scripts/rules/skills.gd @@ -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, "")) diff --git a/client/scripts/rules/skills.gd.uid b/client/scripts/rules/skills.gd.uid new file mode 100644 index 0000000..c5bc53a --- /dev/null +++ b/client/scripts/rules/skills.gd.uid @@ -0,0 +1 @@ +uid://d2pvtvrrhc27b diff --git a/client/scripts/state/character_sheet.gd b/client/scripts/state/character_sheet.gd new file mode 100644 index 0000000..eebcbf0 --- /dev/null +++ b/client/scripts/state/character_sheet.gd @@ -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") diff --git a/client/scripts/state/character_sheet.gd.uid b/client/scripts/state/character_sheet.gd.uid new file mode 100644 index 0000000..9a628db --- /dev/null +++ b/client/scripts/state/character_sheet.gd.uid @@ -0,0 +1 @@ +uid://mofimtmsxuui diff --git a/client/scripts/state/game_state.gd b/client/scripts/state/game_state.gd index e7e820d..ce6df82 100644 --- a/client/scripts/state/game_state.gd +++ b/client/scripts/state/game_state.gd @@ -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). diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd index 1e0920c..a5d314b 100644 --- a/client/scripts/ui/shell/main_window_shell.gd +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -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)) diff --git a/client/tests/unit/test_character_sheet.gd b/client/tests/unit/test_character_sheet.gd new file mode 100644 index 0000000..dc2993c --- /dev/null +++ b/client/tests/unit/test_character_sheet.gd @@ -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") diff --git a/client/tests/unit/test_character_sheet.gd.uid b/client/tests/unit/test_character_sheet.gd.uid new file mode 100644 index 0000000..96a9701 --- /dev/null +++ b/client/tests/unit/test_character_sheet.gd.uid @@ -0,0 +1 @@ +uid://dgi5hu3y2fg2u diff --git a/client/tests/unit/test_content_db.gd b/client/tests/unit/test_content_db.gd index a7955b3..f321786 100644 --- a/client/tests/unit/test_content_db.gd +++ b/client/tests/unit/test_content_db.gd @@ -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]) diff --git a/client/tests/unit/test_dev_harness_seed_logs.gd b/client/tests/unit/test_dev_harness_seed_logs.gd new file mode 100644 index 0000000..d8fb985 --- /dev/null +++ b/client/tests/unit/test_dev_harness_seed_logs.gd @@ -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") diff --git a/client/tests/unit/test_dev_harness_seed_logs.gd.uid b/client/tests/unit/test_dev_harness_seed_logs.gd.uid new file mode 100644 index 0000000..3adeb1a --- /dev/null +++ b/client/tests/unit/test_dev_harness_seed_logs.gd.uid @@ -0,0 +1 @@ +uid://bmaooitlx0ox4 diff --git a/client/tests/unit/test_entities.gd b/client/tests/unit/test_entities.gd index e18fa1a..77e6347 100644 --- a/client/tests/unit/test_entities.gd +++ b/client/tests/unit/test_entities.gd @@ -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(): diff --git a/client/tests/unit/test_game_state.gd b/client/tests/unit/test_game_state.gd index d3bd06f..d385df1 100644 --- a/client/tests/unit/test_game_state.gd +++ b/client/tests/unit/test_game_state.gd @@ -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) diff --git a/client/tests/unit/test_main_window_shell.gd b/client/tests/unit/test_main_window_shell.gd index 44413ab..13527d6 100644 --- a/client/tests/unit/test_main_window_shell.gd +++ b/client/tests/unit/test_main_window_shell.gd @@ -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") diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 18049f4..c0d0f98 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -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")) diff --git a/client/tests/unit/test_round_trip.gd b/client/tests/unit/test_round_trip.gd index 3fb1e3c..502653a 100644 --- a/client/tests/unit/test_round_trip.gd +++ b/client/tests/unit/test_round_trip.gd @@ -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"]: diff --git a/client/tests/unit/test_rules_tables.gd b/client/tests/unit/test_rules_tables.gd new file mode 100644 index 0000000..e6a5468 --- /dev/null +++ b/client/tests/unit/test_rules_tables.gd @@ -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) diff --git a/client/tests/unit/test_rules_tables.gd.uid b/client/tests/unit/test_rules_tables.gd.uid new file mode 100644 index 0000000..d8c3f77 --- /dev/null +++ b/client/tests/unit/test_rules_tables.gd.uid @@ -0,0 +1 @@ +uid://bjld6ctq8tqnk diff --git a/client/tests/unit/test_schema_parity.gd b/client/tests/unit/test_schema_parity.gd new file mode 100644 index 0000000..547350d --- /dev/null +++ b/client/tests/unit/test_schema_parity.gd @@ -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) diff --git a/client/tests/unit/test_schema_parity.gd.uid b/client/tests/unit/test_schema_parity.gd.uid new file mode 100644 index 0000000..ec2cb5a --- /dev/null +++ b/client/tests/unit/test_schema_parity.gd.uid @@ -0,0 +1 @@ +uid://dond21vwh46rt diff --git a/content/origins/deserter.json b/content/origins/deserter.json index f68cc1a..03add65 100644 --- a/content/origins/deserter.json +++ b/content/origins/deserter.json @@ -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 } } diff --git a/content/world/callings/bloodsworn.json b/content/world/callings/bloodsworn.json new file mode 100644 index 0000000..ff30160 --- /dev/null +++ b/content/world/callings/bloodsworn.json @@ -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." } diff --git a/content/world/callings/bonesetter.json b/content/world/callings/bonesetter.json new file mode 100644 index 0000000..0ad877f --- /dev/null +++ b/content/world/callings/bonesetter.json @@ -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." } diff --git a/content/world/callings/cutpurse.json b/content/world/callings/cutpurse.json new file mode 100644 index 0000000..697a3f6 --- /dev/null +++ b/content/world/callings/cutpurse.json @@ -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." } diff --git a/content/world/callings/hedge_mage.json b/content/world/callings/hedge_mage.json new file mode 100644 index 0000000..26ff24b --- /dev/null +++ b/content/world/callings/hedge_mage.json @@ -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." } diff --git a/content/world/callings/reaver.json b/content/world/callings/reaver.json new file mode 100644 index 0000000..24b3fc2 --- /dev/null +++ b/content/world/callings/reaver.json @@ -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." } diff --git a/content/world/callings/sellsword.json b/content/world/callings/sellsword.json new file mode 100644 index 0000000..2aff2e5 --- /dev/null +++ b/content/world/callings/sellsword.json @@ -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." } diff --git a/content/world/callings/trapper.json b/content/world/callings/trapper.json new file mode 100644 index 0000000..3031d0a --- /dev/null +++ b/content/world/callings/trapper.json @@ -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." } diff --git a/content/world/races/beastfolk.json b/content/world/races/beastfolk.json new file mode 100644 index 0000000..4dde59a --- /dev/null +++ b/content/world/races/beastfolk.json @@ -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." } diff --git a/content/world/races/dwarf.json b/content/world/races/dwarf.json new file mode 100644 index 0000000..1a5bc4b --- /dev/null +++ b/content/world/races/dwarf.json @@ -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." } diff --git a/content/world/races/elf.json b/content/world/races/elf.json new file mode 100644 index 0000000..6755cb9 --- /dev/null +++ b/content/world/races/elf.json @@ -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." } diff --git a/content/world/races/human.json b/content/world/races/human.json new file mode 100644 index 0000000..373054e --- /dev/null +++ b/content/world/races/human.json @@ -0,0 +1 @@ +{ "id": "human", "name": "Human", "blurb": "Short-lived, adaptable, everywhere. The Margreave is mostly yours, for whatever that has been worth." } diff --git a/docs/canon-log.md b/docs/canon-log.md index b090df6..b7c99f6 100644 --- a/docs/canon-log.md +++ b/docs/canon-log.md @@ -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": { "": -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 } } ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 8971edd..9972bb3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.*** diff --git a/docs/schemas/canon-log.schema.json b/docs/schemas/canon-log.schema.json index 9853159..8ecf4a5 100644 --- a/docs/schemas/canon-log.schema.json +++ b/docs/schemas/canon-log.schema.json @@ -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 } } }, diff --git a/docs/schemas/origin.schema.json b/docs/schemas/origin.schema.json index 0485da9..7f6a445 100644 --- a/docs/schemas/origin.schema.json +++ b/docs/schemas/origin.schema.json @@ -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" } }