import json import os from services.llm import LLMError, send_message_with_model, send_message import logging logger = logging.getLogger(__name__) PLOT_MODEL = os.getenv("RPG_PLOT_MODEL", "").strip() or "deepseek/deepseek-chat-v3" GENRE_LABELS = { "adventure": "Adventure", "horror": "Horror", "romance": "Romance", "slice_of_life": "Slice of Life", "fantasy": "Fantasy", "sci_fi": "Sci-Fi", } ARC_SYSTEM = """You are a narrative designer for an RPG chat. Given the opening scene (greeting), character info, current facts, and genre(s), produce a STRUCTURED PLOT ARC. Return ONLY valid JSON (no markdown): { "title": "short arc title", "boundaries": ["things that must remain true to preserve immersion"], "phase": "opening|hook|complication|reveal|climax|aftermath", "cast": [{"name":"NPC name","role":"helper|antagonist|bystander","motivation":"..."}], "secrets": ["hidden truths not revealed yet"], "beats": [ {"id":"b1","title":"short quest title (3-6 words)","trigger":"event_driven:rest|event_driven:travel|event_driven:help_request|event_driven:after_fail|event_driven:after_success", "injection":"1-3 sentences to introduce the beat WITHOUT breaking current scene", "choices":[{"id":"a","label":"..."},{"id":"b","label":"..."}]} ], "next_beat_hint": "short hint for narrator what to push next" } Rules: - Respect the opening scene. Do not jump to unrelated characters immediately. - Beats must feel like natural developments fitting the genre(s). For cross-genre, blend tropes organically. - Keep injections immersive (in-world narration).""" def format_genres(genre: str) -> str: parts = [g.strip() for g in genre.replace("+", ",").split(",") if g.strip()] if not parts: return "Adventure" labels = [GENRE_LABELS.get(g, g.replace("_", " ").title()) for g in parts] if len(labels) == 1: return labels[0] return " + ".join(labels) + " (cross-genre blend)" async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenario: str, greeting: str, facts_block: str = "", genre: str = "adventure") -> dict: user = ( f"Character: {persona_name}\n" f"Description: {persona_desc}\n" f"Scenario: {persona_scenario}\n" f"Greeting: {greeting}\n" f"Genre: {format_genres(genre)}\n" f"Facts:\n{facts_block}\n" ).strip() messages = [ {"role": "system", "content": ARC_SYSTEM}, {"role": "user", "content": user}, ] try: raw = await ( send_message_with_model(messages, PLOT_MODEL) if PLOT_MODEL else send_message(messages) ) except LLMError as e: logger.warning("generate_plot_arc LLM failed (model=%s): %s", PLOT_MODEL or "SYSTEM", e) return {} except Exception as e: logger.warning("generate_plot_arc unexpected error: %s", e) return {} cleaned = raw.strip() # common OpenRouter formatting: fenced json if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned if cleaned.endswith("```"): cleaned = cleaned.rsplit("```", 1)[0] cleaned = cleaned.strip() try: data = json.loads(cleaned) return data if isinstance(data, dict) else {} except Exception: logger.warning("PlotArc JSON parse failed. Raw=%.500s", raw) return {} def should_advance_arc(user_text: str) -> str | None: t = (user_text or "").lower() if any(x in t for x in ["отдыха", "ночлег", "спим", "сон", "разбить лагерь", "лагерь", "отдохн"]): return "event_driven:rest" if any(x in t for x in ["идем дальше", "пойдем дальше", "в путь", "продолжаем путь", "уходим", "возвращаемся", "переходим"]): return "event_driven:travel" if any(x in t for x in ["помоги", "помочь", "нужна помощь", "спасите", "help"]): return "event_driven:help_request" return None PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"] def advance_phase(arc: dict) -> bool: """Advance arc to next phase if beats are exhausted. Returns True if phase changed.""" current = arc.get("phase", "opening") if arc.get("beats"): return False try: idx = PHASE_ORDER.index(current) except ValueError: return False if idx + 1 >= len(PHASE_ORDER): return False arc["phase"] = PHASE_ORDER[idx + 1] return True def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]: beats = arc.get("beats", []) if not isinstance(beats, list): return arc, [] matched, remaining = [], [] for b in beats: if len(matched) < max_beats and isinstance(b, dict) and b.get("trigger") == trigger: matched.append(b) else: remaining.append(b) arc["beats"] = remaining return arc, matched