136 lines
5.0 KiB
Python
136 lines
5.0 KiB
Python
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
|
|
|