| """
|
| 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
|
|
|