| """ |
| vn_engine.py β The VN tool loop. |
| |
| Wraps an LLM ModelClient and the locked cast. Processes player choices |
| through the model, enforces cast-lock, and returns scene mutations. |
| |
| Architecture (same pattern as the DM): |
| 1. Player makes a choice |
| 2. Engine builds prompt with [STATE] banner + choice text |
| 3. Sends to model |
| 4. Parses [TOOL: ...] calls from response |
| 5. Mutates SceneState accordingly |
| 6. Returns (narration, updated_scene) |
| """ |
| from __future__ import annotations |
| import ast |
| import json |
| import re |
| from typing import Optional, Iterator |
|
|
| import os |
|
|
| from vn_contracts import (SceneState, CastMember, Choice, ActionResult, |
| AffectionFlag, SaveData, |
| member_to_dict, member_from_dict, cast_fingerprint) |
| from vn_prompt import build_full_prompt, build_state_banner |
| from model_client import ModelClient |
| from trace_log import log_turn |
|
|
| SAVE_DIR = os.path.join(os.path.dirname(__file__), "saves") |
| CAST_DIR = os.path.join(SAVE_DIR, "cast") |
| NUM_SLOTS = 5 |
|
|
|
|
| |
|
|
| def save_cast_card(cast: dict[str, CastMember]) -> str: |
| """Persist the cast as a card under saves/cast/<hash>.json (idempotent β |
| the fingerprint covers identity, so an existing card is reused). |
| Returns the fingerprint.""" |
| fp = cast_fingerprint(cast) |
| os.makedirs(CAST_DIR, exist_ok=True) |
| path = os.path.join(CAST_DIR, f"{fp}.json") |
| if not os.path.exists(path): |
| card = {"hash": fp, |
| "members": [member_to_dict(m) for m in cast.values()]} |
| with open(path, "w", encoding="utf-8") as f: |
| json.dump(card, f, ensure_ascii=False, indent=2) |
| return fp |
|
|
|
|
| def load_cast_card(cast_hash: str) -> Optional[dict[str, CastMember]]: |
| """Rebuild a cast dict from its card. None if the card doesn't exist.""" |
| path = os.path.join(CAST_DIR, f"{cast_hash}.json") |
| if not (cast_hash and os.path.exists(path)): |
| return None |
| with open(path, "r", encoding="utf-8") as f: |
| card = json.load(f) |
| members = [member_from_dict(d) for d in card.get("members", [])] |
| return {m.character.name: m for m in members} or None |
|
|
|
|
| def read_save(slot: int, save_dir: Optional[str] = None) -> Optional[SaveData]: |
| """Read a slot's SaveData without an engine. None if the slot is empty |
| or unreadable.""" |
| path = os.path.join(save_dir or SAVE_DIR, f"slot_{slot}.json") |
| try: |
| with open(path, "r", encoding="utf-8") as f: |
| return SaveData.from_dict(json.load(f)) |
| except (OSError, json.JSONDecodeError, TypeError): |
| return None |
|
|
|
|
| def list_saves(save_dir: Optional[str] = None) -> list[dict]: |
| """Slot metadata for the save/load UIs β one entry per slot, in order. |
| Empty/unreadable slots get {"slot": i, "empty": True}.""" |
| out = [] |
| for i in range(NUM_SLOTS): |
| data = read_save(i, save_dir) |
| if data is None: |
| out.append({"slot": i, "empty": True}) |
| continue |
| out.append({ |
| "slot": i, "empty": False, |
| "name": data.save_name or "", |
| "chapter": data.chapter, "turn": data.turn, |
| "cast_names": list(data.cast_names), |
| "saved_at": data.saved_at or "", |
| "cast_hash": data.cast_hash or "", |
| }) |
| return out |
|
|
| |
| _EXPRESSION_EMOTION = { |
| "neutral": "neutral", "smile": "happy", "laugh": "happy", |
| "sad": "sad", "angry": "angry", "surprised": "surprised", |
| "embarrassed": "embarrassed", |
| } |
|
|
|
|
| |
| _TOOL_CALL_RE = re.compile(r'\[TOOL:') |
|
|
|
|
| def _scan_balanced(text: str, start: int) -> int: |
| """Scan text from an opening bracket at `start` to its balanced close. |
| |
| Honors JSON double-quoted strings (brackets inside them don't count) |
| and backslash escapes. Returns the index AFTER the closing bracket, |
| or -1 if unbalanced. |
| """ |
| depth = 0 |
| in_str = False |
| esc = False |
| for i in range(start, len(text)): |
| c = text[i] |
| if esc: |
| esc = False |
| elif c == '\\': |
| esc = True |
| elif in_str: |
| if c == '"': |
| in_str = False |
| elif c == '"': |
| in_str = True |
| elif c in '[{': |
| depth += 1 |
| elif c in ']}': |
| depth -= 1 |
| if depth == 0: |
| return i + 1 |
| return -1 |
|
|
|
|
| def _split_complete_lines(buffer: str) -> tuple[list[str], str]: |
| """Cut `buffer` into complete lines on newlines that are NOT inside an |
| open bracket span or a double-quoted string. |
| |
| This keeps a multi-line `[TOOL: ... choices=[{...}]]` call (or any bracketed |
| marker the model pretty-prints across lines) intact as one unit, so the |
| line-buffered streaming parser never sees a half a tool call. Brackets and |
| backslash-escaped double quotes are tracked exactly as in parse_tool_calls; |
| single quotes are NOT string delimiters (apostrophes in prose are fine). |
| |
| Returns (complete_lines, remainder). `complete_lines` have no trailing '\\n'. |
| """ |
| depth = in_str = esc = 0 |
| lines: list[str] = [] |
| start = 0 |
| for i, c in enumerate(buffer): |
| if esc: |
| esc = 0 |
| elif c == '\\': |
| esc = 1 |
| elif in_str: |
| if c == '"': |
| in_str = 0 |
| elif c == '"': |
| in_str = 1 |
| elif c in '[{': |
| depth += 1 |
| elif c in ']}': |
| if depth > 0: |
| depth -= 1 |
| elif c == '\n' and depth == 0: |
| lines.append(buffer[start:i]) |
| start = i + 1 |
| return lines, buffer[start:] |
|
|
|
|
| def _parse_tool_args(args_str: str) -> dict: |
| """Parse `key=value` pairs from a tool-call argument string. |
| |
| Values may be: |
| - 'single-quoted' β if the value starts with [ or { (JSON), it is |
| scanned bracket-aware so apostrophes inside JSON strings (e.g. |
| "I've") do NOT terminate the value; otherwise scans to the next '. |
| - "double-quoted" β scans to the closing quote, honoring \\" escapes |
| (apostrophes inside are fine). |
| - bare β integer or word. |
| """ |
| result: dict = {} |
| i, n = 0, len(args_str) |
| key_re = re.compile(r'(\w+)\s*=\s*') |
|
|
| while i < n: |
| while i < n and args_str[i].isspace(): |
| i += 1 |
| m = key_re.match(args_str, i) |
| if not m: |
| break |
| key = m.group(1) |
| i = m.end() |
| if i >= n: |
| result[key] = "" |
| break |
|
|
| ch = args_str[i] |
| if ch == "'": |
| i += 1 |
| if i < n and args_str[i] in '[{': |
| end = _scan_balanced(args_str, i) |
| if end == -1: |
| result[key] = args_str[i:] |
| break |
| result[key] = args_str[i:end] |
| i = end |
| if i < n and args_str[i] == "'": |
| i += 1 |
| else: |
| |
| |
| |
| |
| |
| j = i |
| while True: |
| j = args_str.find("'", j) |
| if j == -1: |
| result[key] = args_str[i:] |
| i = n |
| break |
| if re.match(r"\s*(\w+\s*=|$)", args_str[j + 1:]): |
| result[key] = args_str[i:j] |
| i = j + 1 |
| break |
| j += 1 |
| elif ch == '"': |
| i += 1 |
| buf = [] |
| esc = False |
| while i < n: |
| c = args_str[i] |
| if esc: |
| buf.append(c) |
| esc = False |
| elif c == '\\': |
| esc = True |
| elif c == '"': |
| break |
| else: |
| buf.append(c) |
| i += 1 |
| result[key] = "".join(buf) |
| i += 1 |
| elif ch in '[{': |
| |
| |
| |
| end = _scan_balanced(args_str, i) |
| if end == -1: |
| result[key] = args_str[i:] |
| i = n |
| else: |
| result[key] = args_str[i:end] |
| i = end |
| else: |
| m2 = re.match(r'(\d+)|(\w+)', args_str[i:]) |
| if not m2: |
| i += 1 |
| continue |
| if m2.group(1) is not None: |
| result[key] = int(m2.group(1)) |
| else: |
| result[key] = m2.group(2) |
| i += m2.end() |
|
|
| return result |
|
|
|
|
| def parse_tool_calls(response: str) -> list[dict]: |
| """Extract ALL tool calls from the model's response. |
| |
| Supports [TOOL: name key="value" key='[json]' key=123 key=word] format. |
| Robust to apostrophes in dialogue/JSON text ("I've", "don't") β the |
| end-of-call scanner tracks only double-quoted strings (with escapes) |
| and bracket depth, never single quotes. |
| |
| Returns a list of dicts with keys: |
| - "tool": the tool name |
| - any parsed keyword arguments |
| """ |
| calls = [] |
| for m in _TOOL_CALL_RE.finditer(response): |
| start = m.end() |
| |
| |
| |
| |
| depth = 1 |
| in_str = False |
| esc = False |
| end_pos = -1 |
|
|
| for i in range(start, len(response)): |
| ch = response[i] |
| if esc: |
| esc = False |
| continue |
| if ch == '\\': |
| esc = True |
| continue |
| if in_str: |
| if ch == '"': |
| in_str = False |
| continue |
| if ch == '"': |
| in_str = True |
| elif ch == '[': |
| depth += 1 |
| elif ch == ']': |
| depth -= 1 |
| if depth == 0: |
| end_pos = i |
| break |
|
|
| if end_pos == -1: |
| continue |
|
|
| |
| inner = response[m.end():end_pos].strip() |
|
|
| |
| space_idx = inner.find(' ') |
| if space_idx == -1: |
| tool_name = inner |
| args_str = "" |
| else: |
| tool_name = inner[:space_idx] |
| args_str = inner[space_idx + 1:].strip() |
|
|
| result = {"tool": tool_name} |
| if args_str: |
| result.update(_parse_tool_args(args_str)) |
|
|
| calls.append(result) |
|
|
| return calls |
|
|
|
|
| |
|
|
| def _check_cast_lock(character_name: str, cast: dict[str, CastMember]) -> Optional[str]: |
| """Validate a character name against the locked cast. |
| |
| Returns the canonical name (properly cased) if found, |
| or None if the character doesn't exist in the cast. |
| """ |
| |
| if character_name in cast: |
| return character_name |
|
|
| |
| for name in cast: |
| if name.lower() == character_name.lower(): |
| return name |
|
|
| return None |
|
|
|
|
| def _find_cast_violations(response: str, cast: dict[str, CastMember]) -> list[str]: |
| """Find non-cast character names used as dialogue speakers. |
| |
| We look for the [character_name]: pattern in dialogue lines. |
| Returns the list of violating names (deduped, in order of appearance). |
| """ |
| dialogue_refs = re.findall(r'\[(\w+)\]:\s*"', response) |
| violations = [] |
| for ref in dialogue_refs: |
| if not _check_cast_lock(ref, cast) and ref not in violations: |
| violations.append(ref) |
| return violations |
|
|
|
|
| def _repair_cast_violation(response: str, cast: dict[str, CastMember]) -> str: |
| """Check a model response for non-cast character names and flag them. |
| |
| Returns the response with violations flagged via [CAST-LOCK VIOLATION]. |
| Does NOT stop execution β just flags for the engine to handle. |
| """ |
| violations = _find_cast_violations(response, cast) |
| if not violations: |
| return response |
|
|
| |
| violation_msg = ( |
| "\n\n[CAST-LOCK VIOLATION: The following character names are not in the " |
| f"locked cast and will be ignored: {', '.join(violations)}. " |
| "Only use characters from the cast list.]" |
| ) |
| return response + violation_msg |
|
|
|
|
| def _clean_noncast_dialogue(response: str, cast: dict[str, CastMember]) -> str: |
| """Remove dialogue lines from non-cast characters. |
| |
| If a line is tagged with a character not in the cast, strip that line. |
| This is the enforcement mechanism β the model CANNOT speak through |
| a character that doesn't have a sprite. |
| """ |
| lines = response.split("\n") |
| cleaned = [] |
| for line in lines: |
| m = re.match(r'^\[(\w+)\]:', line) |
| if m: |
| char_name = m.group(1) |
| if not _check_cast_lock(char_name, cast): |
| |
| cleaned.append(f"*(someone speaks)*") |
| continue |
| cleaned.append(line) |
| return "\n".join(cleaned) |
|
|
|
|
| |
| |
| |
|
|
| def _handle_advance_scene(tool_data: dict, scene: SceneState) -> ActionResult: |
| """Handle advance_scene tool call. |
| |
| Mutates scene: background, mood, description, present characters. |
| """ |
| description = tool_data.get("description", "") |
| background = tool_data.get("background", scene.background) |
| mood = tool_data.get("mood", scene.mood) |
|
|
| if description: |
| scene.description = description |
| scene.background = background |
| scene.mood = mood |
|
|
| return ActionResult( |
| success=True, |
| message=f"Scene advanced: {background} ({mood})", |
| data={"background": background, "mood": mood}, |
| ) |
|
|
|
|
| def _handle_set_background(tool_data: dict, scene: SceneState) -> ActionResult: |
| """Handle set_background tool call. |
| |
| Changes only the background β narration/mood untouched. |
| """ |
| background = tool_data.get("background", "") |
| if not background: |
| return ActionResult(success=False, message="set_background: no background key given") |
|
|
| scene.background = background |
| return ActionResult( |
| success=True, |
| message=f"Background set: {background}", |
| data={"background": background}, |
| ) |
|
|
|
|
| def _handle_set_expression(tool_data: dict, scene: SceneState, cast: dict[str, CastMember]) -> ActionResult: |
| """Handle set_expression tool call. |
| |
| Validates that the character exists and the expression is valid, |
| then records it as the character's current expression in scene state. |
| """ |
| char_name = tool_data.get("character", "") |
| expression = tool_data.get("expression", "") |
|
|
| canonical = _check_cast_lock(char_name, cast) |
| if not canonical: |
| return ActionResult( |
| success=False, |
| message=f"Cast-lock violation: '{char_name}' is not in the cast. Ignored.", |
| ) |
|
|
| if expression not in cast[canonical].expression_map: |
| return ActionResult( |
| success=False, |
| message=f"Expression '{expression}' not available for {canonical}. Available: {list(cast[canonical].expression_map.keys())}", |
| ) |
|
|
| if scene.expressions.get(canonical) == expression: |
| |
| |
| |
| return ActionResult( |
| success=True, |
| message=f"Expression unchanged: {canonical} already {expression}", |
| ) |
|
|
| scene.expressions[canonical] = expression |
| return ActionResult( |
| success=True, |
| message=f"Expression set: {canonical} -> {expression}", |
| data={"character": canonical, "expression": expression}, |
| ) |
|
|
|
|
| def _handle_enter_character(tool_data: dict, scene: SceneState, cast: dict[str, CastMember]) -> ActionResult: |
| """Handle enter_character tool call. |
| |
| Validates cast-lock, then appends to scene.present if not already present. |
| """ |
| char_name = tool_data.get("character", "") |
|
|
| canonical = _check_cast_lock(char_name, cast) |
| if not canonical: |
| return ActionResult( |
| success=False, |
| message=f"Cast-lock violation: '{char_name}' is not in the cast. Ignored.", |
| ) |
|
|
| if canonical not in scene.present: |
| scene.present.append(canonical) |
| return ActionResult( |
| success=True, |
| message=f"{canonical} entered the scene", |
| data={"character": canonical, "present": "enter"}, |
| ) |
|
|
|
|
| def _handle_exit_character(tool_data: dict, scene: SceneState, cast: dict[str, CastMember]) -> ActionResult: |
| """Handle exit_character tool call. |
| |
| Removes from scene.present. No-op if not present. |
| """ |
| char_name = tool_data.get("character", "") |
|
|
| canonical = _check_cast_lock(char_name, cast) |
| if not canonical: |
| return ActionResult( |
| success=False, |
| message=f"Cast-lock violation: '{char_name}' is not in the cast. Ignored.", |
| ) |
|
|
| if canonical in scene.present: |
| scene.present.remove(canonical) |
| return ActionResult( |
| success=True, |
| message=f"{canonical} exited the scene", |
| data={"character": canonical, "present": "exit"}, |
| ) |
|
|
|
|
| def _loads_lenient(blob: str): |
| """json.loads with a best-effort repair for common model deviations. |
| |
| Weaker models emit loosely-formatted JSON β most often unquoted object |
| keys (`{id: 1, text: ...}` instead of `{"id": 1, "text": ...}`), or |
| Python-repr style with single-quoted keys/values |
| (`[{'id': 'study', 'text': "I've ..."}]`). Try a strict parse first; |
| on failure, fall back to ast.literal_eval (which handles single quotes β |
| apostrophes inside values included β natively), then quote bare keys and |
| retry both. Raises json.JSONDecodeError if every repair fails. |
| """ |
| try: |
| return json.loads(blob) |
| except json.JSONDecodeError as err: |
| strict_err = err |
|
|
| |
| try: |
| return ast.literal_eval(blob) |
| except (ValueError, SyntaxError): |
| pass |
|
|
| |
| |
| |
| repaired = re.sub(r'([{,]\s*)([A-Za-z_]\w*)(\s*:)', r'\1"\2"\3', blob) |
| try: |
| return json.loads(repaired) |
| except json.JSONDecodeError: |
| pass |
| |
| |
| try: |
| return ast.literal_eval(repaired) |
| except (ValueError, SyntaxError): |
| raise strict_err |
|
|
|
|
| def _handle_offer_choices(tool_data: dict, scene: SceneState) -> ActionResult: |
| """Handle offer_choices tool call. |
| |
| Parses the choices JSON and sets them on the scene state. Tolerates the |
| loose JSON weaker models tend to emit (unquoted keys, numeric ids). |
| """ |
| choices_json = tool_data.get("choices", "") |
| if not choices_json: |
| return ActionResult(success=False, message="No choices provided") |
|
|
| try: |
| choices_data = _loads_lenient(choices_json) |
| except json.JSONDecodeError: |
| return ActionResult(success=False, message="Invalid choices JSON") |
|
|
| if not isinstance(choices_data, list): |
| return ActionResult(success=False, message="choices must be a JSON array") |
|
|
| choices = [] |
| for cd in choices_data: |
| if not isinstance(cd, dict): |
| continue |
| choice = Choice( |
| |
| |
| id=str(cd.get("id", f"c_{len(choices)}")), |
| text=cd.get("text", ""), |
| consequence=cd.get("consequence", ""), |
| next_background=cd.get("next_background"), |
| next_mood=cd.get("next_mood"), |
| expression_changes=cd.get("expression_changes", {}), |
| ) |
| |
| if "affection_flag" in cd: |
| flag_data = cd["affection_flag"] |
| if isinstance(flag_data, list) and len(flag_data) == 2: |
| choice.affection_flag = (flag_data[0], flag_data[1]) |
| choices.append(choice) |
|
|
| scene.choices = choices |
| return ActionResult( |
| success=True, |
| message=f"Offered {len(choices)} choices", |
| data={"choice_count": len(choices)}, |
| ) |
|
|
|
|
| def _handle_save_checkpoint(tool_data: dict, scene: SceneState) -> ActionResult: |
| """Handle save_checkpoint tool call. |
| |
| Marks the scene for auto-save (no-op in engine, UI handles actual serialization). |
| """ |
| scene.save_slot = 0 |
| return ActionResult(success=True, message="Checkpoint saved") |
|
|
|
|
| |
|
|
| _TOOL_HANDLERS = { |
| "advance_scene": _handle_advance_scene, |
| "set_background": _handle_set_background, |
| "set_expression": _handle_set_expression, |
| "enter_character": _handle_enter_character, |
| "exit_character": _handle_exit_character, |
| "offer_choices": _handle_offer_choices, |
| "save_checkpoint": _handle_save_checkpoint, |
| } |
|
|
|
|
| def _route_tool(tool_data: dict, scene: SceneState, cast: dict[str, CastMember]) -> ActionResult: |
| """Route a tool call to the appropriate handler.""" |
| tool_name = tool_data.get("tool", "") |
| handler = _TOOL_HANDLERS.get(tool_name) |
| if not handler: |
| return ActionResult(success=False, message=f"Unknown tool: {tool_name}") |
|
|
| |
| cast_tools = ("set_expression", "enter_character", "exit_character") |
| if tool_name in cast_tools: |
| return handler(tool_data, scene, cast) |
| return handler(tool_data, scene) |
|
|
|
|
| |
| |
| |
|
|
| class VNEngine: |
| """The VN tool loop. |
| |
| Wraps a ModelClient and a locked cast. Processes player choices |
| through the model, enforces cast-lock, and returns scene mutations. |
| |
| Usage: |
| engine = VNEngine(model=my_client, cast=my_cast, scene=initial_scene) |
| narration, updated_scene = engine.run_turn("I choose to talk to Yuki") |
| """ |
|
|
| def __init__(self, model: ModelClient, cast: dict[str, CastMember], |
| scene: SceneState, voice=None): |
| """ |
| Args: |
| model: Any ModelClient (real or mock). |
| cast: The locked cast (name -> CastMember). |
| scene: Initial scene state. |
| voice: Optional VoiceProvider for selective voicing of key |
| emotional lines. None disables voicing. |
| """ |
| self.model = model |
| self.cast = cast |
| self.scene = scene |
| self.voice = voice |
| self.last_voice = None |
|
|
| def run_turn(self, player_choice_text: str, |
| history: str = "") -> tuple[str, SceneState]: |
| """Run one VN turn (blocking). |
| |
| Drains run_turn_stream and returns its final (narration, scene). All |
| turn logic lives in the streaming generator; this is the one-shot |
| convenience for callers (and tests) that don't need incremental output. |
| |
| Args: |
| player_choice_text: The choice text the player selected. |
| history: Trimmed session-log tail (vn_prompt.build_recent_history) |
| so the model knows what it already narrated. |
| |
| Returns: |
| (narration_text, updated_scene_state) |
| """ |
| final = (None, self.scene) |
| for final in self.run_turn_stream(player_choice_text, history=history): |
| pass |
| return final |
|
|
| def run_turn_stream(self, player_choice_text: str, history: str = "" |
| ) -> Iterator[tuple[str, SceneState]]: |
| """Run one VN turn, streaming line-buffered. |
| |
| Streams the model response and processes it ONE COMPLETE LINE AT A |
| TIME (buffering deltas to '\\n' boundaries), so `[TOOL: ...]` calls and |
| `[Name]: "..."` speaker prefixes are never split mid-token. Per line: |
| route tool calls (mutating the scene live), record dialogue, and voice |
| the first eligible emotional beat. Yields (narration_so_far, scene) as |
| lines complete; the FINAL yield equals what run_turn returns β the |
| cleaned narration plus cast-lock / engine notes. |
| |
| Args: |
| player_choice_text: The choice text the player selected. |
| |
| Yields: |
| (narration_so_far, updated_scene_state) β last yield is final. |
| """ |
| |
| self.scene.choices = [] |
|
|
| |
| system, user = build_full_prompt(self.scene, self.cast, player_choice_text, |
| player_name=self.scene.player_name, |
| history=history) |
|
|
| |
| if self.scene.description: |
| user = f"Current scene: {self.scene.description}\n\n{user}" |
|
|
| messages = [ |
| {"role": "system", "content": system}, |
| {"role": "user", "content": user}, |
| ] |
|
|
| |
| tool_results: list[ActionResult] = [] |
| changed_exprs: set[str] = set() |
| self.last_voice = None |
| voiced = False |
|
|
| def process_line(line: str): |
| """Handle one complete line: route tool calls, record dialogue, |
| voice the first eligible beat. Mutates scene + closure state.""" |
| nonlocal voiced |
| |
| for tc in parse_tool_calls(line): |
| result = _route_tool(tc, self.scene, self.cast) |
| tool_results.append(result) |
| if result.success and "expression" in result.data: |
| changed_exprs.add(result.data["character"]) |
| |
| |
| cleaned = _clean_noncast_dialogue(line, self.cast) |
| for m in re.finditer(r'^\[(\w+)\]:\s*"([^"]*)"', cleaned, re.MULTILINE): |
| speaker = _check_cast_lock(m.group(1), self.cast) |
| if not speaker: |
| continue |
| self.scene.dialogue.append({ |
| "speaker": speaker, |
| "text": m.group(2), |
| "expression": self.scene.expressions.get(speaker, "neutral"), |
| }) |
| |
| |
| if (self.voice is not None and not voiced |
| and speaker in changed_exprs and m.group(2).strip()): |
| expr = self.scene.expressions.get(speaker, "neutral") |
| emotion = _EXPRESSION_EMOTION.get(expr, "neutral") |
| try: |
| self.last_voice = self.voice.speak( |
| m.group(2), speaker, emotion=emotion, |
| voice_id=self.cast[speaker].character.voice_id, |
| ) |
| except TypeError: |
| |
| self.last_voice = self.voice.speak(m.group(2), speaker, emotion=emotion) |
| except Exception as e: |
| print(f"[engine] voice failed: {e}") |
| voiced = True |
|
|
| |
| |
| response = "" |
| pending = "" |
| for delta in self.model.stream(messages): |
| if not delta: |
| continue |
| response += delta |
| pending += delta |
| lines, pending = _split_complete_lines(pending) |
| for line in lines: |
| process_line(line) |
| if lines: |
| |
| yield _clean_noncast_dialogue(response, self.cast), self.scene |
| |
| if pending: |
| process_line(pending) |
|
|
| |
| violations = _find_cast_violations(response, self.cast) |
|
|
| |
| |
| if not getattr(self.model, "is_mock", False): |
| |
| |
| |
| model_id = getattr(getattr(self.model, "cfg", None), "model", "") |
| resolve = getattr(self.model, "server_model", None) |
| if callable(resolve): |
| try: |
| model_id = resolve() or model_id |
| except Exception: |
| pass |
| log_turn( |
| messages, response, |
| model=model_id, |
| context={ |
| "turn": self.scene.turn, |
| "scene": self.scene.description, |
| "mood": self.scene.mood, |
| "present": list(self.scene.present), |
| "player_input": player_choice_text, |
| "cast_violations": violations, |
| }, |
| ) |
|
|
| |
| narration = _clean_noncast_dialogue(response, self.cast) |
| if violations: |
| narration += ( |
| "\n\n[Engine: CAST-LOCK removed non-cast speaker(s): " |
| f"{', '.join(violations)}]" |
| ) |
| engine_notes = [f"[Engine: {tr.message}]" |
| for tr in tool_results if not tr.success] |
| if engine_notes: |
| narration += "\n\n" + "\n".join(engine_notes) |
|
|
| |
| self.scene.turn += 1 |
| yield narration, self.scene |
|
|
| def apply_choice_effects(self, choice: Choice): |
| """Apply the effects of a player-selected choice. |
| |
| Mutates scene state: background, mood, affection flags, expressions. |
| |
| Args: |
| choice: The Choice the player picked. |
| """ |
| if choice.next_background: |
| self.scene.background = choice.next_background |
| if choice.next_mood: |
| self.scene.mood = choice.next_mood |
| if choice.affection_flag: |
| char_name, flag = choice.affection_flag |
| self.scene.set_affection(char_name, flag) |
| for char_name, expr in (choice.expression_changes or {}).items(): |
| canonical = _check_cast_lock(char_name, self.cast) |
| if canonical and expr in self.cast[canonical].expression_map: |
| self.scene.expressions[canonical] = expr |
|
|
| |
|
|
| def save(self, slot: int = 0, save_dir: Optional[str] = None, |
| save_name: str = "", log_text: str = "") -> str: |
| """Serialize the current scene to a JSON save slot. |
| |
| Also writes the cast card (saves/cast/<hash>.json) and stamps the |
| save with its fingerprint, a player-given name, and the local time β |
| so the slot can be described in the UI and loaded from the main menu. |
| Returns the path of the written save file. |
| """ |
| save_dir = save_dir or SAVE_DIR |
| os.makedirs(save_dir, exist_ok=True) |
| path = os.path.join(save_dir, f"slot_{slot}.json") |
| data = SaveData.from_scene(self.scene) |
| data.save_name = (save_name or "").strip()[:48] |
| from datetime import datetime |
| data.saved_at = datetime.now().strftime("%Y-%m-%d %H:%M") |
| data.cast_hash = save_cast_card(self.scene.cast) |
| data.log_text = log_text or "" |
| self.scene.save_slot = slot |
| with open(path, "w", encoding="utf-8") as f: |
| json.dump(data.to_dict(), f, ensure_ascii=False, indent=2) |
| return path |
|
|
| def load(self, slot: int = 0, save_dir: Optional[str] = None) -> SceneState: |
| """Restore scene state from a JSON save slot (cast stays locked). |
| |
| Raises FileNotFoundError if the slot doesn't exist. |
| """ |
| save_dir = save_dir or SAVE_DIR |
| path = os.path.join(save_dir, f"slot_{slot}.json") |
| with open(path, "r", encoding="utf-8") as f: |
| data = SaveData.from_dict(json.load(f)) |
| data.apply_to(self.scene) |
| self.scene.save_slot = slot |
| return self.scene |
|
|
| def reset_scene(self) -> SceneState: |
| """Reset the scene to a fresh state with the same cast.""" |
| self.scene = SceneState( |
| cast=self.cast, |
| present=[], |
| background="classroom", |
| mood="peaceful", |
| turn=0, |
| chapter="Prologue", |
| ) |
| return self.scene |
|
|