| """The Weaver — director / game master. |
| |
| Three jobs: |
| - init_world(setup) -> (GameState, opening DirectorOutput) |
| - direct_turn(state, input) -> DirectorOutput (one grammar-constrained call per turn) |
| - compact_memory(state) -> None (fold old turns into the rolling summary) |
| |
| In MOCK mode (config.USE_MOCK) these build nice, deterministic, themed output WITHOUT a model, |
| so the whole loop runs offline. Flip VN_MOCK=0 to use the real LLM via `llm.py`. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import random |
|
|
| from . import config, memory, prompts |
| from .llm import LLMBackend |
| from .schemas import ( |
| Character, |
| Directives, |
| DirectorOutput, |
| GameState, |
| InitOutput, |
| NewCharacter, |
| Scene, |
| SceneChange, |
| SetupForm, |
| ) |
| from .utils import clip_words, strip_think |
|
|
| |
| _MOVE_WORDS = ( |
| "go", |
| "leave", |
| "enter", |
| "into", |
| "toward", |
| "outside", |
| "deeper", |
| "follow", |
| "walk", |
| "open", |
| ) |
|
|
|
|
| |
| |
| |
| def init_world(llm: LLMBackend, setup: SetupForm) -> tuple[GameState, DirectorOutput]: |
| seed = setup.seed if setup.seed is not None else random.randint(1, 2**31 - 1) |
| vibe = prompts.build_vibe(setup) |
|
|
| if config.USE_MOCK: |
| init = _mock_init(setup) |
| else: |
| raw = llm.complete_json( |
| messages=[ |
| {"role": "system", "content": prompts.INIT_SYSTEM}, |
| {"role": "user", "content": prompts.init_user_prompt(setup)}, |
| ], |
| schema=prompts.init_schema(), |
| temperature=0.9, |
| top_p=0.95, |
| max_tokens=1024, |
| ) |
| raw = _repair_init(raw) |
| init = InitOutput.model_validate(raw) |
|
|
| first = init.first_character |
| first.id = _slug(first.id) or _slug(first.name) |
| scene = Scene( |
| id=_slug(init.scene.place), |
| place=init.scene.place, |
| description=init.scene.description, |
| mood=init.scene.mood, |
| present=[first.id], |
| backdrop_seed=_seed_for(seed, init.scene.place), |
| ) |
| flags: dict[str, str] = {} |
| if init.situation_intro: |
| flags["situation_intro"] = init.situation_intro |
| if setup.player_name and setup.player_name != "the wanderer": |
| flags["player_name"] = setup.player_name |
|
|
| state = GameState( |
| seed=seed, |
| vibe=vibe, |
| style_guide=init.style_guide, |
| scene=scene, |
| characters={ |
| first.id: Character( |
| id=first.id, |
| name=first.name, |
| one_line=first.one_line, |
| appearance=first.appearance, |
| voice=first.voice, |
| traits=first.traits, |
| goals=first.goals, |
| sprite_seed=_seed_for(seed, first.id), |
| tts_voice_description=first.voice, |
| ) |
| }, |
| flags=flags, |
| ) |
| opening = DirectorOutput(speaker=first.id, dialogue=init.opening_line, emotion="tender") |
| return state, opening |
|
|
|
|
| |
| |
| |
| def direct_turn( |
| llm: LLMBackend, |
| state: GameState, |
| player_input: str, |
| action: str = "talk", |
| target: str = "", |
| ) -> DirectorOutput: |
| if config.USE_MOCK: |
| return _mock_turn(state, player_input) |
|
|
| |
| |
| if action == "scout" and len(state.scene.present) >= config.MAX_PRESENT: |
| action = "scout_full" |
| schema = prompts.scout_schema() if action == "scout" else prompts.directive_schema() |
|
|
| note = prompts.action_note(action, target) |
| context = memory.assemble_context(state, player_input, action_note=note) |
| try: |
| raw = llm.complete_json( |
| messages=[ |
| {"role": "system", "content": prompts.DIRECTOR_SYSTEM}, |
| {"role": "user", "content": context}, |
| ], |
| schema=schema, |
| temperature=0.7, |
| top_p=0.9, |
| max_tokens=1536, |
| ) |
| out = _repair(state, DirectorOutput.model_validate(raw)) |
| except Exception as exc: |
| |
| print(f"[director] turn failed ({type(exc).__name__}: {exc}) - using fallback line") |
| speaker = state.scene.present[0] if state.scene.present else "narrator" |
| return DirectorOutput( |
| speaker=speaker, |
| dialogue="Hm? Sorry - I lost my train of thought for a moment. What were you saying?", |
| emotion="thoughtful", |
| ) |
|
|
| |
| |
| if _is_repeat(out.dialogue, state): |
| try: |
| raw = llm.complete_json( |
| messages=[ |
| {"role": "system", "content": prompts.DIRECTOR_SYSTEM}, |
| { |
| "role": "user", |
| "content": context |
| + f'\n\nNOTE: You already said this line: "{out.dialogue}"\n' |
| "Repeating it is FORBIDDEN. Do not reuse any sentence from " |
| "RECENT EXCHANGE - react freshly to the player's latest input " |
| "and to whatever just changed on stage.", |
| }, |
| ], |
| schema=schema, |
| temperature=0.9, |
| top_p=0.9, |
| max_tokens=1536, |
| presence_penalty=0.8, |
| ) |
| out = _repair(state, DirectorOutput.model_validate(raw)) |
| except Exception: |
| pass |
| return out |
|
|
|
|
| |
| |
| |
| def compact_memory(llm: LLMBackend, state: GameState) -> None: |
| keep = config.RECENT_TURNS_K |
| old = state.recent_turns[:-keep] if keep else state.recent_turns |
| if not old: |
| return |
| if config.USE_MOCK: |
| new_summary = (state.summary + " " + " ".join(t.dialogue for t in old)).strip() |
| state.summary = new_summary[-800:] |
| else: |
| who = state.flags.get("player_name", "wanderer") |
| recent_blob = "\n".join(f'{who}: "{t.player}"\n{t.speaker}: "{t.dialogue}"' for t in old) |
| |
| |
| raw = llm.complete( |
| messages=[ |
| {"role": "system", "content": prompts.COMPACT_SYSTEM}, |
| { |
| "role": "user", |
| "content": f"SUMMARY SO FAR:\n{state.summary}\n\nRECENT:\n{recent_blob}\n/no_think", |
| }, |
| ], |
| temperature=0.3, |
| top_p=0.9, |
| max_tokens=320, |
| ) |
| |
| |
| new_summary = strip_think(raw) |
| if not new_summary: |
| new_summary = (state.summary + " " + " ".join(t.dialogue for t in old)).strip()[-800:] |
| state.summary = new_summary |
| state.recent_turns = state.recent_turns[-keep:] if keep else [] |
|
|
|
|
| |
| |
| |
| def _repair_init(raw: dict) -> dict: |
| """Coerce a loosely-shaped InitOutput dict into the expected structure. |
| |
| Models without grammar constraints sometimes return `scene` as a plain string |
| and omit required fields like `one_line`/`appearance` in `first_character`. |
| """ |
| scene = raw.get("scene") |
| if isinstance(scene, str): |
| place = scene.split(",")[0].split(".")[0][:40].strip() or "School Grounds" |
| raw["scene"] = {"place": place, "description": scene, "mood": "neutral"} |
| elif isinstance(scene, dict): |
| if "place" not in scene: |
| scene["place"] = scene.get("name") or scene.get("location") or "School Grounds" |
| if "description" not in scene: |
| scene["description"] = scene.get("setting") or scene.get("place") or "A lovely setting." |
|
|
| fc = raw.get("first_character") |
| if isinstance(fc, dict): |
| if not fc.get("one_line"): |
| traits = fc.get("traits", []) |
| name = fc.get("name", "stranger") |
| fc["one_line"] = ( |
| fc.pop("bio", None) |
| or fc.pop("description", None) |
| or (f"a {traits[0].lower()} person" if traits else f"a mysterious {name}") |
| ) |
| if not fc.get("appearance"): |
| fc["appearance"] = ( |
| fc.pop("physical_description", None) |
| or fc.pop("looks", None) |
| or "a young person with distinctive anime-style features" |
| ) |
| return raw |
|
|
|
|
| def _norm(s: str) -> str: |
| """Normalize dialogue for repetition comparison (case/punctuation/whitespace).""" |
| import re |
|
|
| return re.sub(r"[\W_]+", " ", s.lower()).strip() |
|
|
|
|
| def _is_repeat(dialogue: str, state: GameState) -> bool: |
| """True when the dialogue is a (near-)verbatim copy of one of the last 3 lines.""" |
| import difflib |
|
|
| cand = _norm(dialogue) |
| if not cand: |
| return False |
| for t in state.recent_turns[-3:]: |
| prev = _norm(t.dialogue) |
| if cand == prev or difflib.SequenceMatcher(None, cand, prev).ratio() >= 0.95: |
| return True |
| return False |
|
|
|
|
| def _repair(state: GameState, out: DirectorOutput) -> DirectorOutput: |
| |
| |
| nc_arriving = out.directives.new_character |
| nc_id = (_slug(nc_arriving.id) or _slug(nc_arriving.name)) if nc_arriving else None |
| |
| if out.speaker != "narrator" and out.speaker not in state.scene.present: |
| if nc_id and _slug(out.speaker) == nc_id: |
| out.speaker = nc_id |
| else: |
| out.speaker = state.scene.present[0] if state.scene.present else "narrator" |
| |
| if out.directives.exit_character and out.directives.exit_character not in state.scene.present: |
| out.directives.exit_character = None |
| |
| out.directives.relationship_delta = max(-25, min(25, out.directives.relationship_delta)) |
| |
| |
| nc = out.directives.new_character |
| if nc is not None: |
| if not nc.one_line.strip(): |
| nc.one_line = f"a mysterious {nc.name}" |
| if not nc.traits: |
| nc.traits = ["curious", "reserved"] |
| if not nc.appearance.strip(): |
| nc.appearance = nc.one_line or "a young person with distinctive anime-style features" |
| |
| |
| nc.one_line = clip_words(nc.one_line, 30) |
| nc.appearance = clip_words(nc.appearance, 40) |
| nc.voice = clip_words(nc.voice, 25) |
| nc.goals = clip_words(nc.goals, 50) |
| return out |
|
|
|
|
| |
| |
| |
| def _mock_init(setup: SetupForm) -> InitOutput: |
| theme = config.THEMES.get(setup.theme, next(iter(config.THEMES.values()))) |
| cid = "hana" |
| return InitOutput( |
| style_guide=f"{config.STYLE_BASE}. Setting: {theme['setting']}. Palette: {theme['palette']}. " |
| f"Tone: {setup.tone}, warm and romantic.", |
| scene=SceneChange( |
| place=theme["setting"].split(",")[0].strip().title(), |
| description=f"The scene opens in {theme['setting']}. " |
| "A soft breeze carries cherry-blossom petals across the path ahead.", |
| mood=setup.tone, |
| ), |
| first_character=NewCharacter( |
| id=cid, |
| name="Hana", |
| one_line="a warm-hearted girl who always carries too many books and hides a poet's heart", |
| appearance="a slender young woman with wavy chestnut hair pinned loosely, warm amber eyes, " |
| "school uniform slightly dishevelled, a small notebook tucked under her arm", |
| voice="bright and a little breathless, laughs easily, sometimes trails into a half-whispered aside", |
| traits=["warm", "secretly poetic", "terrified of being ordinary"], |
| goals="to share one of her poems with someone who will truly listen", |
| ), |
| opening_line="Oh! I wasn't — I mean… hi. Sorry, I was miles away. You're the new transfer student, right? " |
| "I'm Hana. Welcome to, um, wherever this is.", |
| situation_intro=( |
| f"You find yourself at {theme['setting']}. " |
| "It's one of those afternoons where the light seems to linger just a little longer than usual, " |
| "and the world feels oddly full of possibility. You're about to cross paths with someone " |
| "you won't easily forget." |
| ), |
| ) |
|
|
|
|
| def _mock_turn(state: GameState, player_input: str) -> DirectorOutput: |
| rng = random.Random(state.seed + state.turn_index) |
| speaker = state.scene.present[0] if state.scene.present else "narrator" |
| name = state.characters[speaker].name if speaker in state.characters else "The wood" |
| low = player_input.lower() |
|
|
| emotion = "joy" if any(w in low for w in ("thank", "kind", "friend", "love")) else "neutral" |
| if any(w in low for w in ("no", "stop", "afraid", "run")): |
| emotion = "fear" |
|
|
| directives = Directives(relationship_delta=rng.choice([0, 1, 1, 2])) |
|
|
| |
| if any(w in low for w in _MOVE_WORDS) and rng.random() < 0.8: |
| place = rng.choice( |
| ["The Hollow Stair", "A Clearing of Bells", "The Underbridge", "The Quiet Library"] |
| ) |
| directives.scene_change = SceneChange( |
| place=place, |
| description=f"The path folds and {place.lower()} dreams itself into being around you.", |
| mood=state.scene.mood, |
| ) |
| dialogue = ( |
| f"This way, then—the wood likes to be followed. {place} was just now imagined for you." |
| ) |
| else: |
| dialogue = ( |
| f"“{player_input.strip() or '...'}”, you say. {name} considers this as if tasting it." |
| ) |
|
|
| return DirectorOutput( |
| speaker=speaker, dialogue=dialogue, emotion=emotion, directives=directives |
| ) |
|
|
|
|
| |
| def _slug(text: str) -> str: |
| return "".join(c if c.isalnum() else "_" for c in text.lower()).strip("_")[:40] or "x" |
|
|
|
|
| def _seed_for(world_seed: int, key: str) -> int: |
| return (world_seed * 2654435761 + sum(ord(c) for c in key) * 40503) % (2**31) |
|
|