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
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
"""Load a role's system prompt (§16 — prompts are source code) and render the
|
|
canon log into a curated digest for the user message (charter §11).
|
|
|
|
The digest is a projection, not a dump: it surfaces narrative context and
|
|
deliberately omits raw integers — companion dispositions render as names only,
|
|
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
|
|
"""
|
|
|
|
from functools import lru_cache
|
|
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")
|
|
# Everything below the first '---' line is the model-facing prompt; the
|
|
# header above it is human/dev front-matter.
|
|
parts = text.split("\n---\n", 1)
|
|
body = parts[1] if len(parts) == 2 else text
|
|
return body.strip()
|
|
|
|
|
|
def render_digest(canon_log: dict) -> str:
|
|
lines: list[str] = []
|
|
|
|
location = canon_log.get("location", {})
|
|
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
|
|
|
events = canon_log.get("recent_events", [])
|
|
if events:
|
|
lines.append("Recent events:")
|
|
lines += [f"- {e}" for e in events]
|
|
|
|
facts = canon_log.get("established_facts", [])
|
|
if facts:
|
|
lines.append("Established facts:")
|
|
lines += [f"- {f}" for f in facts]
|
|
|
|
party = canon_log.get("party", [])
|
|
if party:
|
|
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
|
|
|
|
for quest in canon_log.get("active_quests", []):
|
|
if quest.get("status") == "active":
|
|
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
|
|
|
player = canon_log.get("player", {})
|
|
descriptor = player.get("luck_descriptor")
|
|
if descriptor:
|
|
lines.append(f"Fortune: {descriptor}")
|
|
lines.append(f"Player: {_describe_player(player)}")
|
|
|
|
lines.append("")
|
|
lines.append("Describe the scene as it is now.")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_npc_digest(
|
|
canon_log: dict,
|
|
*,
|
|
persona: str,
|
|
knowledge: list[str],
|
|
disposition: int,
|
|
available_moves: list[str],
|
|
utterance: str,
|
|
) -> str:
|
|
"""User-message digest for the NPC role (§6). Unlike the narrator digest,
|
|
this deliberately carries the disposition integer (§6 permits it; §7 hides
|
|
Luck, not disposition) and the closed move vocabulary the NPC may use."""
|
|
lines: list[str] = []
|
|
lines.append(f"You are: {persona}")
|
|
lines.append(
|
|
f"Your disposition toward the player: {disposition} "
|
|
f"(-100 hostile, 0 neutral, 100 devoted)."
|
|
)
|
|
|
|
if knowledge:
|
|
lines.append("")
|
|
lines.append("What you know (this is ALL you know — do not invent beyond it):")
|
|
lines += [f"- {k}" for k in knowledge]
|
|
|
|
lines.append("")
|
|
lines.append("Moves you may perform this turn (use ONLY these, as [MOVE: name(args)]):")
|
|
lines += [f"- {m}" for m in available_moves]
|
|
|
|
location = canon_log.get("location", {})
|
|
lines.append("")
|
|
lines.append(f"Location: {location.get('name', 'an unknown place')}")
|
|
|
|
events = canon_log.get("recent_events", [])
|
|
if events:
|
|
lines.append("Recent events:")
|
|
lines += [f"- {e}" for e in events]
|
|
|
|
facts = canon_log.get("established_facts", [])
|
|
if facts:
|
|
lines.append("Established facts:")
|
|
lines += [f"- {f}" for f in facts]
|
|
|
|
for quest in canon_log.get("active_quests", []):
|
|
if quest.get("status") == "active":
|
|
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
|
|
|
player = canon_log.get("player", {})
|
|
lines.append(f"The player is {_describe_player(player)}.")
|
|
|
|
lines.append("")
|
|
lines.append(f'The player says to you: "{utterance}"')
|
|
return "\n".join(lines)
|