new RPG system
This commit is contained in:
@@ -0,0 +1,567 @@
|
||||
"""Linear story arc: global plot, steps, one active quest."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate_beats_to_steps(arc: dict) -> dict:
|
||||
"""One-time: collapse legacy beats[] into steps[]."""
|
||||
if not isinstance(arc, dict):
|
||||
return {}
|
||||
if arc.get("steps"):
|
||||
return arc
|
||||
beats = arc.get("beats") or []
|
||||
if not isinstance(beats, list) or not beats:
|
||||
return arc
|
||||
steps = []
|
||||
for i, b in enumerate(beats):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
title = (b.get("title") or b.get("injection") or f"Step {i + 1}").strip()[:120]
|
||||
steps.append({
|
||||
"id": b.get("id") or f"s_m{i + 1}",
|
||||
"title": title,
|
||||
"goal": title,
|
||||
"completion_criteria": f"Player and character naturally complete: {title}",
|
||||
"character_guidance": "Stay in character; move toward the goal without teleporting.",
|
||||
"injection": (b.get("injection") or "").strip(),
|
||||
"choices": b.get("choices") or [],
|
||||
})
|
||||
arc = dict(arc)
|
||||
arc["steps"] = steps
|
||||
arc.pop("beats", None)
|
||||
arc.setdefault("current_step_index", 0)
|
||||
arc.setdefault("status", "active")
|
||||
arc.setdefault("global_story", arc.get("next_beat_hint") or arc.get("title") or "")
|
||||
arc.setdefault("ending", "")
|
||||
arc.setdefault("reward", "")
|
||||
logger.info("migrate_beats_to_steps: %d steps", len(steps))
|
||||
return arc
|
||||
|
||||
|
||||
def normalize_story_arc(raw: dict, genre: str = "adventure") -> dict:
|
||||
"""Ensure required fields on a story arc from LLM or migration."""
|
||||
from services.rpg_plot import format_genres
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
arc = migrate_beats_to_steps(raw)
|
||||
arc.setdefault("title", "Story arc")
|
||||
arc.setdefault("genre_blend", format_genres(genre))
|
||||
arc.setdefault("global_story", "")
|
||||
arc.setdefault("ending", "")
|
||||
arc.setdefault("reward", "")
|
||||
arc.setdefault("boundaries", [])
|
||||
arc.setdefault("current_step_index", 0)
|
||||
arc.setdefault("status", "active")
|
||||
if not isinstance(arc.get("steps"), list):
|
||||
arc["steps"] = []
|
||||
arc["steps"] = [
|
||||
normalize_step(s, i) for i, s in enumerate(arc.get("steps") or []) if isinstance(s, dict)
|
||||
]
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
arc["meta"] = {"arc_number": 1, "previous_arc_summary": ""}
|
||||
else:
|
||||
meta.setdefault("arc_number", 1)
|
||||
meta.setdefault("previous_arc_summary", "")
|
||||
try:
|
||||
arc["current_step_index"] = max(0, int(arc.get("current_step_index") or 0))
|
||||
except (TypeError, ValueError):
|
||||
arc["current_step_index"] = 0
|
||||
return arc
|
||||
|
||||
|
||||
def normalize_step(step: dict, index: int = 0) -> dict:
|
||||
"""Repair legacy beat-shaped steps (status_quo / choices[].injection)."""
|
||||
s = dict(step)
|
||||
title = (s.get("title") or s.get("goal") or "").strip()
|
||||
if not title:
|
||||
sq = (s.get("status_quo") or "").strip()
|
||||
if sq:
|
||||
title = sq.split(".")[0].strip()[:120] or sq[:120]
|
||||
else:
|
||||
title = f"Шаг {index + 1}"
|
||||
s["id"] = (s.get("id") or f"s{index + 1}").strip()
|
||||
s["title"] = title[:120]
|
||||
s["goal"] = (s.get("goal") or title).strip()[:200]
|
||||
s.setdefault(
|
||||
"completion_criteria",
|
||||
f"Сцена естественно завершает эпизод: {title}",
|
||||
)
|
||||
s.setdefault(
|
||||
"character_guidance",
|
||||
"Оставайся в роли; веди сцену к цели шага без телепортов.",
|
||||
)
|
||||
inj = resolve_step_injection(s)
|
||||
if inj:
|
||||
s["injection"] = inj
|
||||
return s
|
||||
|
||||
|
||||
def resolve_step_injection(step: dict | None) -> str:
|
||||
if not isinstance(step, dict):
|
||||
return ""
|
||||
inj = (step.get("injection") or "").strip()
|
||||
if inj:
|
||||
return inj
|
||||
for item in step.get("choices") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
c_inj = (item.get("injection") or "").strip()
|
||||
if c_inj:
|
||||
return c_inj
|
||||
return (step.get("status_quo") or "").strip()
|
||||
|
||||
|
||||
def format_new_arc_opening(arc: dict | None, step: dict | None, *, lang: str = "ru") -> str:
|
||||
arc = arc or {}
|
||||
title = (arc.get("title") or "Новая арка").strip()
|
||||
inj = resolve_step_injection(step)
|
||||
if not inj:
|
||||
inj = (arc.get("global_story") or "").strip()[:400]
|
||||
if lang == "ru":
|
||||
header = f"📖 Новая арка: «{title}»"
|
||||
else:
|
||||
header = f"📖 New arc: «{title}»"
|
||||
return f"{header}\n\n{inj}".strip() if inj else header
|
||||
|
||||
|
||||
def get_current_step(arc: dict | None) -> dict | None:
|
||||
arc = arc or {}
|
||||
steps = arc.get("steps") or []
|
||||
if not isinstance(steps, list) or not steps:
|
||||
return None
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
if idx < 0 or idx >= len(steps):
|
||||
return None
|
||||
step = steps[idx]
|
||||
return step if isinstance(step, dict) else None
|
||||
|
||||
|
||||
def step_progress(arc: dict | None) -> tuple[int, int]:
|
||||
arc = arc or {}
|
||||
steps = arc.get("steps") or []
|
||||
total = len(steps) if isinstance(steps, list) else 0
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
if total == 0:
|
||||
return 0, 0
|
||||
return min(idx + 1, total), total
|
||||
|
||||
|
||||
def is_arc_completed(arc: dict | None) -> bool:
|
||||
arc = arc or {}
|
||||
if arc.get("status") == "completed":
|
||||
return True
|
||||
steps = arc.get("steps") or []
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
return bool(steps) and idx >= len(steps)
|
||||
|
||||
|
||||
def should_show_step_injection(arc: dict) -> bool:
|
||||
if is_arc_completed(arc):
|
||||
return False
|
||||
meta = arc.get("meta") if isinstance(arc.get("meta"), dict) else {}
|
||||
shown = meta.get("injection_shown_for_step")
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
return shown != idx
|
||||
|
||||
|
||||
def mark_injection_shown(arc: dict) -> None:
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
arc["meta"] = meta
|
||||
meta["injection_shown_for_step"] = int(arc.get("current_step_index") or 0)
|
||||
|
||||
|
||||
def format_step_guidance_for_character(
|
||||
step: dict, arc: dict | None = None, *, lang: str = "ru"
|
||||
) -> str:
|
||||
if not step:
|
||||
return ""
|
||||
goal = (step.get("goal") or step.get("title") or "").strip()
|
||||
guidance = (step.get("character_guidance") or "").strip()
|
||||
title = (step.get("title") or "").strip()
|
||||
cur, total = step_progress(arc or {})
|
||||
global_story = ((arc or {}).get("global_story") or "").strip()
|
||||
ending = ((arc or {}).get("ending") or "").strip()
|
||||
|
||||
if lang == "ru":
|
||||
lines = [
|
||||
"--- Текущий шаг сюжета (ОБЯЗАТЕЛЬНОЕ направление) ---",
|
||||
f"Шаг {cur}/{total}: {title}" if total else f"Шаг: {title}",
|
||||
f"Цель эпизода: {goal}",
|
||||
]
|
||||
if guidance:
|
||||
lines.append(f"Поведение персонажа: {guidance}")
|
||||
if global_story:
|
||||
lines.append(f"Глобальный конвой: {global_story[:400]}")
|
||||
if ending:
|
||||
lines.append(f"Финал арки (не раскрывать досрочно): {ending[:300]}")
|
||||
lines.append(
|
||||
"Веди сцену к цели шага естественно, в роли, без телепортов и смены локации без игрока."
|
||||
)
|
||||
lines.append("---")
|
||||
else:
|
||||
lines = [
|
||||
"--- Current story step (MANDATORY direction) ---",
|
||||
f"Step {cur}/{total}: {title}" if total else f"Step: {title}",
|
||||
f"Episode goal: {goal}",
|
||||
]
|
||||
if guidance:
|
||||
lines.append(f"Character behavior: {guidance}")
|
||||
if global_story:
|
||||
lines.append(f"Global arc: {global_story[:400]}")
|
||||
if ending:
|
||||
lines.append(f"Arc ending (do not spoil early): {ending[:300]}")
|
||||
lines.append(
|
||||
"Move the scene toward the step goal naturally, in character, no teleporting."
|
||||
)
|
||||
lines.append("---")
|
||||
return "\n\n" + "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def format_step_hint_for_character(injection: str, *, lang: str = "ru") -> str:
|
||||
inj = (injection or "").strip()
|
||||
if not inj:
|
||||
return ""
|
||||
if lang == "ru":
|
||||
header = "--- Сюжетная подсказка (не цитируй дословно) ---"
|
||||
footer = (
|
||||
"Продолжи текущую сцену естественно по-русски; не цитируй подсказку дословно; "
|
||||
"не меняй локацию без согласия игрока."
|
||||
)
|
||||
else:
|
||||
header = "--- Plot hint (do not quote verbatim) ---"
|
||||
footer = (
|
||||
"Continue naturally in English; do not quote verbatim; "
|
||||
"do not change location without player consent."
|
||||
)
|
||||
return f"\n\n{header}\n{inj}\n{footer}\n---"
|
||||
|
||||
|
||||
def format_step_for_narrator(
|
||||
arc: dict | None,
|
||||
quests: list | None = None,
|
||||
status_quo: str = "",
|
||||
) -> str:
|
||||
arc = normalize_story_arc(arc or {}) if arc else {}
|
||||
parts: list[str] = []
|
||||
parts.append(f"Story arc: {arc.get('title', '')} [{arc.get('status', 'active')}]")
|
||||
if arc.get("global_story"):
|
||||
parts.append(f"Global story: {arc['global_story'][:500]}")
|
||||
if arc.get("ending"):
|
||||
parts.append(f"Planned ending: {arc['ending'][:300]}")
|
||||
if arc.get("reward"):
|
||||
parts.append(f"Reward hook: {arc['reward'][:200]}")
|
||||
|
||||
cur, total = step_progress(arc)
|
||||
step = get_current_step(arc)
|
||||
if is_arc_completed(arc):
|
||||
parts.append(
|
||||
"IMPORTANT: Arc COMPLETED. Offer 2-4 choices including starting a NEW story arc "
|
||||
"(e.g. label about a new adventure/chapter) while keeping character continuity."
|
||||
)
|
||||
elif step:
|
||||
parts.append(f"Current step: {cur}/{total} — «{step.get('title', '')}»")
|
||||
parts.append(f"Step goal: {step.get('goal', '')}")
|
||||
parts.append(f"Completion criteria: {step.get('completion_criteria', '')}")
|
||||
parts.append(
|
||||
"Set step_complete:true ONLY when completion_criteria are clearly met in recent chat. "
|
||||
"Do NOT use quest_updates for step progression — the engine handles quests."
|
||||
)
|
||||
else:
|
||||
parts.append("No active step — arc may need rollover.")
|
||||
|
||||
if quests:
|
||||
parts.append("Quest log:")
|
||||
for q in quests:
|
||||
parts.append(f" [{q.get('status', 'active')}] {q.get('title', '')}")
|
||||
|
||||
sq = (status_quo or "").strip()
|
||||
if sq:
|
||||
parts.append(f"Status quo: {sq[:400]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def sync_quest_to_current_step(session_id: str, arc: dict) -> int:
|
||||
from services.memory import get_quests, upsert_quest
|
||||
|
||||
arc = normalize_story_arc(arc)
|
||||
quests = await get_quests(session_id)
|
||||
step = get_current_step(arc)
|
||||
closed = 0
|
||||
|
||||
if is_arc_completed(arc) or not step:
|
||||
for q in quests:
|
||||
if q.get("status") == "active":
|
||||
await upsert_quest(session_id, q["title"], "done")
|
||||
closed += 1
|
||||
return closed
|
||||
|
||||
target_title = (step.get("title") or "Current step").strip()[:120]
|
||||
for q in quests:
|
||||
if q.get("status") == "active" and (q.get("title") or "").strip() != target_title:
|
||||
await upsert_quest(session_id, q["title"], "done")
|
||||
closed += 1
|
||||
|
||||
existing_titles = {(q.get("title") or "").strip() for q in quests}
|
||||
if target_title not in existing_titles:
|
||||
await upsert_quest(session_id, target_title, "active")
|
||||
else:
|
||||
for q in quests:
|
||||
if (q.get("title") or "").strip() == target_title and q.get("status") != "active":
|
||||
await upsert_quest(session_id, target_title, "active")
|
||||
return closed
|
||||
|
||||
|
||||
async def apply_step_advance(session_id: str, arc: dict) -> dict:
|
||||
from services.memory import update_session_plot_arc, upsert_quest
|
||||
|
||||
arc = normalize_story_arc(arc)
|
||||
steps = arc.get("steps") or []
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
result = {
|
||||
"advanced": False,
|
||||
"arc_completed": False,
|
||||
"new_step": None,
|
||||
"injection": "",
|
||||
"step_index": idx,
|
||||
}
|
||||
|
||||
if is_arc_completed(arc) or idx >= len(steps):
|
||||
arc["status"] = "completed"
|
||||
result["arc_completed"] = True
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
old_step = steps[idx] if idx < len(steps) else None
|
||||
if old_step and isinstance(old_step, dict):
|
||||
title = (old_step.get("title") or "").strip()
|
||||
if title:
|
||||
await upsert_quest(session_id, title, "done")
|
||||
|
||||
idx += 1
|
||||
arc["current_step_index"] = idx
|
||||
if idx >= len(steps):
|
||||
arc["status"] = "completed"
|
||||
result["arc_completed"] = True
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
else:
|
||||
new_step = steps[idx]
|
||||
result["advanced"] = True
|
||||
result["new_step"] = new_step
|
||||
result["step_index"] = idx
|
||||
if isinstance(new_step, dict):
|
||||
result["injection"] = (new_step.get("injection") or "").strip()
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
arc["meta"] = meta
|
||||
meta.pop("injection_shown_for_step", None)
|
||||
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
logger.info("apply_step_advance: idx=%s completed=%s", idx, result["arc_completed"])
|
||||
return result
|
||||
|
||||
|
||||
async def reconcile_story_arc(
|
||||
session_id: str,
|
||||
*,
|
||||
persona_name: str = "Character",
|
||||
genre: str = "adventure",
|
||||
) -> tuple[dict, bool]:
|
||||
from services.memory import get_session, update_session_plot_arc
|
||||
|
||||
session = await get_session(session_id)
|
||||
if not session or not session.get("rpg_enabled"):
|
||||
return {}, False
|
||||
try:
|
||||
arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arc = {}
|
||||
if not isinstance(arc, dict):
|
||||
arc = {}
|
||||
|
||||
before = json.dumps(arc, sort_keys=True)
|
||||
arc = normalize_story_arc(arc, genre=session.get("genre") or genre)
|
||||
changed = before != json.dumps(arc, sort_keys=True)
|
||||
|
||||
closed = await sync_quest_to_current_step(session_id, arc)
|
||||
if closed:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
return arc, changed
|
||||
|
||||
|
||||
async def apply_story_post(
|
||||
session_id: str,
|
||||
post: dict,
|
||||
arc: dict,
|
||||
rpg_settings: dict,
|
||||
) -> dict:
|
||||
from services.memory import get_session
|
||||
|
||||
arc = normalize_story_arc(arc)
|
||||
out = {
|
||||
"step_advanced": False,
|
||||
"arc_completed": False,
|
||||
"step_injection": "",
|
||||
"new_step_title": "",
|
||||
"arc": arc,
|
||||
}
|
||||
|
||||
if not rpg_settings.get("quests", True) or is_arc_completed(arc):
|
||||
return out
|
||||
|
||||
note = (post.get("step_completion_note") or "").strip()
|
||||
step_complete = bool(post.get("step_complete"))
|
||||
# LLM sometimes provides a completion note but misses the boolean.
|
||||
# Treat a non-empty note as a conservative signal to advance.
|
||||
if not step_complete and note:
|
||||
step_complete = True
|
||||
logger.info("step_complete fallback via note: %s", note[:200])
|
||||
|
||||
if not step_complete:
|
||||
return out
|
||||
|
||||
if note:
|
||||
logger.info("step_complete: %s", note[:200])
|
||||
|
||||
advance = await apply_step_advance(session_id, arc)
|
||||
out["step_advanced"] = advance.get("advanced", False)
|
||||
out["arc_completed"] = advance.get("arc_completed", False)
|
||||
out["step_injection"] = advance.get("injection") or ""
|
||||
new_step = advance.get("new_step")
|
||||
if isinstance(new_step, dict):
|
||||
out["new_step_title"] = (new_step.get("title") or "").strip()
|
||||
|
||||
session = await get_session(session_id) or {}
|
||||
try:
|
||||
out["arc"] = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
NEW_ARC_CHOICE_MARKERS = (
|
||||
"новая арка",
|
||||
"новую арку",
|
||||
"new arc",
|
||||
"new chapter",
|
||||
"следующая арка",
|
||||
"начать новую",
|
||||
# LLM/UI sometimes label next-arc choices as "new quest" instead of "new arc".
|
||||
# The router still keys off this function, so we recognize both.
|
||||
"новый квест",
|
||||
"новую квест",
|
||||
"начать новый квест",
|
||||
"new quest",
|
||||
"start new quest",
|
||||
)
|
||||
|
||||
|
||||
def is_new_arc_request(message: str) -> bool:
|
||||
t = (message or "").lower()
|
||||
return any(m in t for m in NEW_ARC_CHOICE_MARKERS)
|
||||
|
||||
|
||||
def normalize_new_arc_first(value: str | None) -> str | None:
|
||||
v = (value or "").strip().lower()
|
||||
return v if v in ("user", "character") else None
|
||||
|
||||
|
||||
def new_arc_roll_choice(*, lang: str = "ru") -> dict:
|
||||
label = "Начать новую арку" if lang == "ru" else "Start new arc"
|
||||
return {
|
||||
"id": "new_arc",
|
||||
"type": "new_arc_roll",
|
||||
"label": label,
|
||||
"source": "story_engine",
|
||||
}
|
||||
|
||||
|
||||
def filter_new_arc_noise_choices(choices: list) -> list:
|
||||
"""Drop LLM 'start new quest' duplicates when we show the structured roll button."""
|
||||
out = []
|
||||
for c in choices or []:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
if c.get("type") == "new_arc_roll":
|
||||
out.append(c)
|
||||
continue
|
||||
if is_new_arc_request(c.get("label", "")):
|
||||
continue
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def append_new_arc_roll_choice(choices: list, *, lang: str = "ru") -> list:
|
||||
choices = filter_new_arc_noise_choices(choices)
|
||||
if any(c.get("type") == "new_arc_roll" for c in choices):
|
||||
return choices
|
||||
return [*choices, new_arc_roll_choice(lang=lang)]
|
||||
|
||||
|
||||
async def roll_next_arc(
|
||||
session_id: str,
|
||||
persona: dict,
|
||||
greeting_or_context: str,
|
||||
genre: str,
|
||||
*,
|
||||
lang: str = "ru",
|
||||
recent_context: str = "",
|
||||
facts_block: str = "",
|
||||
) -> dict:
|
||||
from services.memory import get_session, update_session_plot_arc
|
||||
from services.rpg_plot import generate_next_arc
|
||||
|
||||
session = await get_session(session_id) or {}
|
||||
try:
|
||||
old_arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
old_arc = {}
|
||||
|
||||
summary = (
|
||||
f"Title: {old_arc.get('title', '')}. "
|
||||
f"Story: {(old_arc.get('global_story') or '')[:500]}. "
|
||||
f"Ending: {(old_arc.get('ending') or '')[:300]}. "
|
||||
f"Reward: {(old_arc.get('reward') or '')[:200]}."
|
||||
)
|
||||
arc_num = int((old_arc.get("meta") or {}).get("arc_number") or 1) + 1
|
||||
|
||||
ctx_parts = [recent_context.strip(), greeting_or_context.strip()]
|
||||
combined_context = "\n".join(p for p in ctx_parts if p)
|
||||
|
||||
new_arc = await generate_next_arc(
|
||||
persona.get("name", "Character"),
|
||||
persona.get("description", ""),
|
||||
persona.get("scenario", ""),
|
||||
combined_context,
|
||||
previous_arc_summary=summary,
|
||||
facts_block=facts_block,
|
||||
genre=genre,
|
||||
lang=lang,
|
||||
)
|
||||
if not new_arc:
|
||||
return old_arc
|
||||
|
||||
new_arc = normalize_story_arc(new_arc, genre=genre)
|
||||
meta = new_arc.get("meta") or {}
|
||||
meta["arc_number"] = arc_num
|
||||
meta["previous_arc_summary"] = summary[:800]
|
||||
new_arc["meta"] = meta
|
||||
new_arc["current_step_index"] = 0
|
||||
new_arc["status"] = "active"
|
||||
|
||||
await update_session_plot_arc(session_id, json.dumps(new_arc, ensure_ascii=False))
|
||||
await sync_quest_to_current_step(session_id, new_arc)
|
||||
logger.info("roll_next_arc: arc #%s «%s»", arc_num, new_arc.get("title"))
|
||||
return new_arc
|
||||
Reference in New Issue
Block a user