diff --git a/api/app/prompts.py b/api/app/prompts.py index 094f3ab..9225f70 100644 --- a/api/app/prompts.py +++ b/api/app/prompts.py @@ -12,6 +12,23 @@ 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: + 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", "") + return f"{name}, {_article(race)} {race} {calling}".strip() + + @lru_cache(maxsize=None) def system_prompt(role: str) -> str: text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8") @@ -50,7 +67,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 +121,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_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..e6f9025 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, }, } @@ -32,7 +32,7 @@ def test_null_start_quest_is_allowed(): def test_unknown_class_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..4831c56 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,32 @@ 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_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/newgame/new_game.gd b/client/scripts/newgame/new_game.gd index 3f52e46..a07e219 100644 --- a/client/scripts/newgame/new_game.gd +++ b/client/scripts/newgame/new_game.gd @@ -12,12 +12,16 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary for ref in world.unresolved_refs(origin): errors.append("unresolved ref: %s" % ref) - # 2. build constraints — chosen class must be allowed by the origin. + # 2. build constraints — chosen calling 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) + var allowed: Array = bc.get("allowed_callings", []) + var calling_id: String = creation.get("calling_id", "") + if calling_id not in allowed: + errors.append("calling not allowed by origin: %s" % calling_id) + + var race_id: String = creation.get("race_id", "") + if not Races.exists(race_id): + errors.append("unknown race: %s" % race_id) if creation.get("name", "").strip_edges() == "": errors.append("player name is required") @@ -36,7 +40,8 @@ static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary # 5. assemble the canon log. var log := CanonLog.new() - log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor()) + log.player = LogPlayer.new(creation.get("name", ""), race_id, calling_id, + state.luck_descriptor()) var loc: Dictionary = world.location(origin["start_location_id"]) log.set_location(loc.get("id", ""), loc.get("name", "")) diff --git a/client/tests/unit/test_entities.gd b/client/tests/unit/test_entities.gd index e18fa1a..c405c39 100644 --- a/client/tests/unit/test_entities.gd +++ b/client/tests/unit/test_entities.gd @@ -14,18 +14,22 @@ 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_party_member_clamps_disposition(): diff --git a/client/tests/unit/test_new_game.gd b/client/tests/unit/test_new_game.gd index 18049f4..dcef883 100644 --- a/client/tests/unit/test_new_game.gd +++ b/client/tests/unit/test_new_game.gd @@ -26,20 +26,20 @@ func _build(creation: Dictionary) -> Dictionary: func test_construct_succeeds(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) assert_true(res["ok"], str(res["errors"])) func test_numeric_luck_absent_from_log(): - var res := _build({"name": "Aldric", "class_id": "sellsword"}) + var res := _build({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) var pd: Dictionary = res["log"].player.to_dict() assert_false("luck" in pd) - assert_eq(pd.keys().size(), 3) + assert_eq(pd.keys().size(), 4) 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({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) 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 +47,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({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) 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({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) 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({"name": "Aldric", "race_id": "human", "calling_id": "sellsword"}) 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,12 +69,12 @@ 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, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) assert_eq(res["log"].active_quests.size(), 0) func test_class_not_allowed_is_rejected(): - var res := _build({"name": "X", "class_id": "berserker"}) + var res := _build({"name": "X", "race_id": "human", "calling_id": "berserker"}) assert_false(res["ok"]) assert_true(res["errors"].size() > 0) assert_null(res["log"]) @@ -83,18 +83,18 @@ func test_class_not_allowed_is_rejected(): 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, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) 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({"race_id": "human", "calling_id": "sellsword"}) 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({"name": " ", "race_id": "human", "calling_id": "sellsword"}) assert_false(whitespace_name["ok"]) assert_true(whitespace_name["errors"].size() > 0) assert_null(whitespace_name["log"]) @@ -104,7 +104,7 @@ 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, {"name": "X", "race_id": "human", "calling_id": "sellsword"}, _rng()) 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..5108147 100644 --- a/client/tests/unit/test_round_trip.gd +++ b/client/tests/unit/test_round_trip.gd @@ -11,7 +11,7 @@ func _built_log(): 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 res := NewGame.construct(origin, world, {"name": "Aldric", "race_id": "human", "calling_id": "bonesetter"}, rng) assert_true(res["ok"], str(res["errors"])) return res["log"] @@ -28,9 +28,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_schema_parity.gd b/client/tests/unit/test_schema_parity.gd new file mode 100644 index 0000000..42f8512 --- /dev/null +++ b/client/tests/unit/test_schema_parity.gd @@ -0,0 +1,37 @@ +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") 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/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" } }