"""The Voices — the actor-context layer. This isn't a separate model call; it's the slice of context that tells the shared LLM *who* is on stage and how they speak. We only ever inject the sheets of the **present** characters (never the whole cast) to respect the token budget and to keep NPCs non-omniscient. """ from __future__ import annotations from .schemas import Character, GameState from .utils import clip_words def present_characters(state: GameState) -> list[Character]: return [state.characters[cid] for cid in state.scene.present if cid in state.characters] def actor_roster(state: GameState) -> str: """One-liner list of who can speak this turn.""" rows = [f"{c.id} = {c.name} ({c.one_line})" for c in present_characters(state)] return "; ".join(rows) if rows else "narrator" def present_sheets_block(state: GameState) -> str: """Compact, prompt-friendly sheets for the spirits currently on stage.""" blocks: list[str] = [] for c in present_characters(state): facts = "; ".join(c.known_facts) if c.known_facts else "—" rel = c.relationship if rel <= -100: rel_note = " ⚠️ DESPISES the wanderer — refuses all contact, storms off if addressed" elif rel <= -60: rel_note = " ⚠️ Very hostile — any unsolicited touch or flirt will make them leave" elif rel <= -20: rel_note = " — cold and resentful; physical contact is unwanted (negative delta)" elif rel < 20: rel_note = " — barely acquainted; unsolicited touch or flirt feels uncomfortable (negative delta)" else: rel_note = "" # NPC↔NPC bonds — only mention others who are in the cast npc_bond_lines: list[str] = [] for other_id, bond_val in c.npc_relations.items(): if other_id in state.characters: other = state.characters[other_id] note = c.npc_relation_notes.get(other_id, "") label = f" ({note})" if note else "" npc_bond_lines.append(f"{other.name}: {bond_val:+d}{label}") npc_bonds_str = "; ".join(npc_bond_lines) if npc_bond_lines else "—" blocks.append( # clip_words guards the token budget against runaway generated fields # (characters created before the creation-time caps can carry huge goals) f"### {c.name} (id: {c.id})\n" f"- one_line: {clip_words(c.one_line, 30)}\n" f"- traits: {', '.join(c.traits) or '—'}\n" f"- voice: {clip_words(c.voice, 25) or '—'}\n" f"- goal: {clip_words(c.goals, 50) or '—'}\n" f"- mood: {c.mood} | feeling toward wanderer: {rel}/100{rel_note}\n" f"- bonds with others: {npc_bonds_str}\n" f"- knows: {facts}" ) return "\n\n".join(blocks) if blocks else "(no spirits on stage — the narrator may speak)"