class_id is the rulebook's word; the world has callings — that was the whole §17
reconcile. The contract the Narrator reads should say what the world says. There
are no saves (M9) and no deployed clients, so this is as cheap as it will ever be
and strictly more expensive every milestone after.
race_id reaches the log because the AI must be able to describe the player:
api/app/prompts.py rendered 'a sellsword' and now renders 'a beastfolk cutpurse'.
It humanizes the id (the model must never read 'hedge_mage') and picks the right
article ('an elf', not 'a elf').
LogPlayer's hardcoded CLASSES const is deleted — the roster is Callings.IDS. A
parity test reads the JSON Schema from the client and asserts its enum equals the
code, so the two sides of the HTTP boundary cannot drift.
The deserter allowed three callings that no longer exist. He now allows all seven;
a thematic gate is a content decision for the Greywater authoring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
88 lines
3.5 KiB
Python
88 lines
3.5 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_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
|