Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.
Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.
Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".
Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
calling_id; a calling passed into the race_id slot (the exact shape
of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
allowed_callings enum matches Callings.IDS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
103 lines
4.1 KiB
Python
103 lines
4.1 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
from app.prompts import render_digest, system_prompt
|
|
|
|
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
|
|
|
|
|
|
def test_system_prompt_strips_the_header():
|
|
body = system_prompt("narrator")
|
|
assert isinstance(body, str) and body.strip() != ""
|
|
# The human-facing markdown title above the '---' is not part of the prompt.
|
|
assert "# Narrator prompt" not in body
|
|
|
|
|
|
def test_digest_has_the_narrative_context():
|
|
d = render_digest(VALID)
|
|
assert "Location: the Greywater docks" in d
|
|
assert "Arrived at Greywater by barge before dawn" in d
|
|
assert "Established facts:" in d
|
|
assert "the harbourmaster is named Oda Fenn" in d
|
|
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 human sellsword" in d
|
|
assert d.rstrip().endswith("Describe the scene as it is now.")
|
|
|
|
|
|
def test_digest_omits_raw_numbers_and_ids():
|
|
d = render_digest(VALID)
|
|
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
|
|
assert "disposition" not in d
|
|
assert "40" not in d and "15" not in d # the deserter dispositions
|
|
assert "brannoc_thane" not in d # ids stay out; names only
|
|
assert "schema_version" not in d
|
|
|
|
|
|
def test_narrator_body_is_authored():
|
|
body = system_prompt.__wrapped__("narrator") # bypass lru_cache in case of prior load
|
|
assert "TBD" not in body
|
|
assert "[FACT:" in body # the tag instruction is present
|
|
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", "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"],
|
|
"active_quests": [], "humiliations": [],
|
|
}
|
|
out = prompts.render_npc_digest(
|
|
log, persona="A harried clerk.", knowledge=["The ledger is gone."],
|
|
disposition=-10, available_moves=["reveal(varrell_twins)", "refuse"],
|
|
utterance="What happened here?",
|
|
)
|
|
assert "A harried clerk." in out
|
|
assert "-10" in out
|
|
assert "The ledger is gone." in out
|
|
assert "reveal(varrell_twins)" in out
|
|
assert "Greywater Docks" in out
|
|
assert "the eastern bridge is out" in out
|
|
assert out.rstrip().endswith('What happened here?"') # utterance last
|