112 lines
4.3 KiB
Python
112 lines
4.3 KiB
Python
import json
|
|
import os
|
|
import random
|
|
|
|
from services.llm import send_message_with_model
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
NARRATOR_MODEL = os.getenv("RPG_NARRATOR_MODEL", "").strip() or "deepseek/deepseek-chat-v3"
|
|
|
|
NARRATOR_PRE_SYSTEM = """You are the System/Narrator of an RPG chat.
|
|
Decide if the user's action requires a skill/ability check (physical action, persuasion, deception, stealth, combat, etc.).
|
|
Pure dialogue, questions, or passive observation do NOT require a check.
|
|
Return ONLY valid JSON (no markdown):
|
|
{
|
|
"needs_check": true,
|
|
"check_reason": "brief reason why a check is needed (e.g. 'jumping over a pit')",
|
|
"directives": ["short imperative rules for the next character reply"],
|
|
"resolution_text": "what actually happens as result of the action — written as narrator prose (1-2 sentences). Only if needs_check=true and roll/outcome provided.",
|
|
"status_quo_update": "optional short update about the world state"
|
|
}
|
|
If needs_check=false: directives may still guide tone/pacing, resolution_text must be empty string.
|
|
If needs_check=true and roll/outcome are provided: resolution_text MUST reflect the outcome.
|
|
- critical failure (1): embarrassing or painful failure with extra complication
|
|
- failure (2-8): action fails, partial or no progress
|
|
- success (9-19): action succeeds as intended
|
|
- critical success (20): spectacular success with bonus effect"""
|
|
|
|
NARRATOR_POST_SYSTEM = """You are the System/Narrator of an RPG chat.
|
|
After the character replied, update persistent state.
|
|
Return ONLY valid JSON (no markdown):
|
|
{
|
|
"status_quo_update": "what changed in the world/state (1-3 sentences)",
|
|
"facts": ["durable facts only"],
|
|
"choices": [{"id":"a","label":"..."}, ...],
|
|
"affinity_delta": 0,
|
|
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}]
|
|
}
|
|
Rules:
|
|
- affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral.
|
|
- quest_updates: only include if a quest was clearly started, completed, or failed. Empty array otherwise.
|
|
- choices: 0-4 options for what the player can do next."""
|
|
|
|
|
|
async def narrator_pre(
|
|
persona_name: str,
|
|
context: str,
|
|
global_plot: str,
|
|
facts_block: str,
|
|
user_message: str,
|
|
roll: int | None = None,
|
|
outcome: str | None = None,
|
|
) -> dict:
|
|
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
|
|
user = (
|
|
f"Persona: {persona_name}\n"
|
|
f"{roll_block}"
|
|
f"User action: {user_message}\n\n"
|
|
f"Global plot:\n{global_plot}\n\n"
|
|
f"Facts:\n{facts_block}\n\n"
|
|
f"Recent context:\n{context}\n"
|
|
)
|
|
raw = await send_message_with_model(
|
|
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
|
|
NARRATOR_MODEL,
|
|
)
|
|
cleaned = raw.strip()
|
|
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)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
logger.warning("Narrator-pre JSON parse failed. Raw=%.500s", raw)
|
|
return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""}
|
|
|
|
|
|
async def narrator_post(
|
|
persona_name: str,
|
|
context: str,
|
|
global_plot: str,
|
|
facts_block: str,
|
|
) -> dict:
|
|
user = (
|
|
f"Persona: {persona_name}\n\n"
|
|
f"Global plot:\n{global_plot}\n\n"
|
|
f"Facts:\n{facts_block}\n\n"
|
|
f"Recent context:\n{context}\n"
|
|
)
|
|
raw = await send_message_with_model(
|
|
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
|
|
NARRATOR_MODEL,
|
|
)
|
|
cleaned = raw.strip()
|
|
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)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
logger.warning("Narrator-post JSON parse failed. Raw=%.500s", raw)
|
|
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []}
|