Fixed RPG
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from services.llm import 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","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},
|
||||
]
|
||||
raw = await (send_message_with_model(messages, PLOT_MODEL) if PLOT_MODEL else send_message(messages))
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user