new RPG system
This commit is contained in:
+293
-72
@@ -18,25 +18,36 @@ GENRE_LABELS = {
|
||||
}
|
||||
|
||||
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.
|
||||
Given the opening scene (greeting), character info, current facts, and genre(s), produce ONE LINEAR STORY 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":"..."}]}
|
||||
"genre_blend": "e.g. Romance + Adventure",
|
||||
"global_story": "2-4 sentences: setup, through-line, planned finale",
|
||||
"ending": "how this arc resolves",
|
||||
"reward": "conditional reward/hook for player and character after finale",
|
||||
"boundaries": ["no teleporting", "stay in character", "..."],
|
||||
"current_step_index": 0,
|
||||
"status": "active",
|
||||
"steps": [
|
||||
{
|
||||
"id": "s1",
|
||||
"title": "short quest title (3-8 words)",
|
||||
"goal": "what must happen in this episode",
|
||||
"completion_criteria": "concrete signs the step is done (for narrator)",
|
||||
"character_guidance": "how the PC should behave toward the goal",
|
||||
"injection": "1-3 immersive sentences when this step begins",
|
||||
"choices": [{"id":"a","label":"..."},{"id":"b","label":"..."}]
|
||||
}
|
||||
],
|
||||
"next_beat_hint": "short hint for narrator what to push next"
|
||||
"meta": {"arc_number": 1, "previous_arc_summary": ""}
|
||||
}
|
||||
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)."""
|
||||
- 3-5 linear steps from opening to finale. ONE quest = ONE step at a time.
|
||||
- Step 1 often matches what already happened in the greeting (shelter, meet, etc.).
|
||||
- Steps must escalate naturally (trust, daily life, adventure, climax).
|
||||
- No event triggers — progression is narrative completion only.
|
||||
- Injections and titles in session language."""
|
||||
|
||||
|
||||
def format_genres(genre: str) -> str:
|
||||
@@ -49,18 +60,33 @@ def format_genres(genre: str) -> str:
|
||||
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:
|
||||
async def generate_plot_arc(
|
||||
persona_name: str,
|
||||
persona_desc: str,
|
||||
persona_scenario: str,
|
||||
greeting: str,
|
||||
facts_block: str = "",
|
||||
genre: str = "adventure",
|
||||
*,
|
||||
lang: str = "ru",
|
||||
recent_context: str = "",
|
||||
) -> dict:
|
||||
from services.rpg_locale import locale_instruction, locale_label
|
||||
|
||||
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"Session language: {locale_label(lang)}\n"
|
||||
f"Facts:\n{facts_block}\n"
|
||||
).strip()
|
||||
if recent_context.strip():
|
||||
user += f"\nRecent chat:\n{recent_context.strip()[-2000:]}\n"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": ARC_SYSTEM},
|
||||
{"role": "system", "content": ARC_SYSTEM + "\n" + locale_instruction(lang)},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
@@ -85,12 +111,93 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
|
||||
cleaned = cleaned.strip()
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
return data if isinstance(data, dict) else {}
|
||||
if isinstance(data, dict):
|
||||
from services.rpg_story import normalize_story_arc
|
||||
return normalize_story_arc(data, genre=genre)
|
||||
return {}
|
||||
except Exception:
|
||||
logger.warning("PlotArc JSON parse failed. Raw=%.500s", raw)
|
||||
return {}
|
||||
|
||||
|
||||
NEXT_ARC_SYSTEM = """You are a narrative designer for an RPG chat.
|
||||
The previous story arc COMPLETED. Design the NEXT arc continuing the same characters and relationship.
|
||||
Return ONLY valid JSON (same schema as initial arc):
|
||||
{
|
||||
"title": "...",
|
||||
"genre_blend": "...",
|
||||
"global_story": "...",
|
||||
"ending": "...",
|
||||
"reward": "...",
|
||||
"boundaries": [],
|
||||
"current_step_index": 0,
|
||||
"status": "active",
|
||||
"steps": [ ... 3-5 steps ... ],
|
||||
"meta": {"arc_number": N, "previous_arc_summary": "..."}
|
||||
}
|
||||
Rules:
|
||||
- Build on previous arc outcome and reward (e.g. tickets found → cruise trip).
|
||||
- New arc must feel like a natural sequel, not a reset.
|
||||
- Keep same cast; facts and affinity continue."""
|
||||
|
||||
|
||||
async def generate_next_arc(
|
||||
persona_name: str,
|
||||
persona_desc: str,
|
||||
persona_scenario: str,
|
||||
recent_context: str,
|
||||
*,
|
||||
previous_arc_summary: str = "",
|
||||
facts_block: str = "",
|
||||
genre: str = "adventure",
|
||||
lang: str = "ru",
|
||||
) -> dict:
|
||||
from services.rpg_locale import locale_instruction, locale_label
|
||||
|
||||
user = (
|
||||
f"Character: {persona_name}\n"
|
||||
f"Description: {persona_desc}\n"
|
||||
f"Scenario: {persona_scenario}\n"
|
||||
f"Genre: {format_genres(genre)}\n"
|
||||
f"Session language: {locale_label(lang)}\n"
|
||||
f"Previous arc summary:\n{previous_arc_summary}\n"
|
||||
f"Facts:\n{facts_block}\n"
|
||||
f"Recent chat:\n{recent_context.strip()[-3000:]}\n"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": NEXT_ARC_SYSTEM + "\n" + locale_instruction(lang)},
|
||||
{"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_next_arc failed: %s", e)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.warning("generate_next_arc unexpected: %s", e)
|
||||
return {}
|
||||
|
||||
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):
|
||||
from services.rpg_story import normalize_story_arc
|
||||
return normalize_story_arc(data, genre=genre)
|
||||
return {}
|
||||
except Exception:
|
||||
logger.warning("generate_next_arc JSON parse failed. Raw=%.400s", raw)
|
||||
return {}
|
||||
|
||||
|
||||
BEAT_MATCH_SYSTEM = """You decide whether the player's latest message should fire ONE scripted plot beat.
|
||||
Return ONLY valid JSON (no markdown):
|
||||
{"fire_beat_id": "id from list or null", "confidence": "high|low"}
|
||||
@@ -155,6 +262,8 @@ async def classify_plot_beat(
|
||||
beats: list[dict],
|
||||
recent_context: str = "",
|
||||
last_dice_outcome: str | None = None,
|
||||
*,
|
||||
lang: str = "ru",
|
||||
) -> str | None:
|
||||
"""LLM: return beat id to fire, or None."""
|
||||
pending = [b for b in beats if isinstance(b, dict) and b.get("id")]
|
||||
@@ -183,8 +292,13 @@ async def classify_plot_beat(
|
||||
if last_dice_outcome:
|
||||
user += f"\nLast dice outcome this turn: {last_dice_outcome}\n"
|
||||
|
||||
from services.rpg_locale import locale_instruction
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": BEAT_MATCH_SYSTEM},
|
||||
{
|
||||
"role": "system",
|
||||
"content": BEAT_MATCH_SYSTEM + "\n" + locale_instruction(lang),
|
||||
},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
@@ -232,10 +346,100 @@ def beat_title(beat: dict) -> str:
|
||||
return ((beat.get("title") or beat.get("injection") or "")[:120]).strip()
|
||||
|
||||
|
||||
def format_beat_injection_for_character(injection: str, *, lang: str = "ru") -> str:
|
||||
"""Soft plot hint for CHAT model — not a script to copy verbatim."""
|
||||
inj = (injection or "").strip()
|
||||
if not inj:
|
||||
return ""
|
||||
if lang == "ru":
|
||||
header = "--- Сюжетная подсказка (не цитируй дословно) ---"
|
||||
footer = (
|
||||
"Продолжи текущую сцену естественно по-русски; не цитируй подсказку дословно; "
|
||||
"не меняй локацию без согласия игрока."
|
||||
)
|
||||
else:
|
||||
header = "--- Plot hint (do not quote verbatim) ---"
|
||||
footer = (
|
||||
"Continue the scene naturally in English; do not quote the hint verbatim; "
|
||||
"do not change location without player consent."
|
||||
)
|
||||
return f"\n\n{header}\n{inj}\n{footer}\n---"
|
||||
|
||||
|
||||
def arc_user_turn_count(history: list | None) -> int:
|
||||
return sum(1 for m in (history or []) if isinstance(m, dict) and m.get("role") == "user")
|
||||
|
||||
|
||||
def can_fire_beat(arc: dict, user_turn: int, *, min_gap: int = 2) -> bool:
|
||||
meta = arc.get("meta") if isinstance(arc.get("meta"), dict) else {}
|
||||
last = meta.get("last_beat_fired_at_user_turn")
|
||||
if last is None:
|
||||
return True
|
||||
try:
|
||||
last_i = int(last)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
return user_turn - last_i >= min_gap
|
||||
|
||||
|
||||
def record_beat_fired(arc: dict, beat: dict, user_turn: int) -> None:
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
arc["meta"] = meta
|
||||
meta["last_beat_fired_at_user_turn"] = user_turn
|
||||
bid = beat.get("id")
|
||||
if bid:
|
||||
meta["last_beat_id"] = str(bid)
|
||||
|
||||
|
||||
async def complete_quest_for_fired_beat(session_id: str, beat: dict) -> None:
|
||||
"""Mark the fired beat's quest done so reconcile does not orphan it next turn."""
|
||||
from services.memory import upsert_quest
|
||||
|
||||
title = beat_title(beat)
|
||||
if title:
|
||||
await upsert_quest(session_id, title, "done")
|
||||
|
||||
|
||||
def count_active_quests(quests: list | None) -> int:
|
||||
return sum(1 for q in (quests or []) if q.get("status") == "active")
|
||||
|
||||
|
||||
def active_quest_titles_to_close(arc: dict, quests: list | None) -> list[str]:
|
||||
"""Active quests whose title does not match any pending beat in arc."""
|
||||
pending = {
|
||||
beat_title(b).lower()
|
||||
for b in (arc.get("beats") or [])
|
||||
if isinstance(b, dict)
|
||||
}
|
||||
to_close: list[str] = []
|
||||
for q in quests or []:
|
||||
if q.get("status") != "active":
|
||||
continue
|
||||
tl = (q.get("title") or "").strip().lower()
|
||||
if tl and tl not in pending:
|
||||
to_close.append((q.get("title") or "").strip())
|
||||
return to_close
|
||||
|
||||
|
||||
async def reconcile_active_quests_to_arc(session_id: str, arc: dict) -> int:
|
||||
"""Mark active quests done when their beat is no longer in arc (desync after fire/replenish)."""
|
||||
from services.memory import get_quests, upsert_quest
|
||||
|
||||
quests = await get_quests(session_id)
|
||||
titles = active_quest_titles_to_close(arc, quests)
|
||||
for title in titles:
|
||||
await upsert_quest(session_id, title, "done")
|
||||
if titles:
|
||||
logger.info(
|
||||
"reconcile_active_quests_to_arc: closed %d orphan(s) %s",
|
||||
len(titles),
|
||||
titles[:5],
|
||||
)
|
||||
return len(titles)
|
||||
|
||||
|
||||
def prune_beats_for_done_quests(arc: dict, quests: list | None) -> tuple[dict, list[dict]]:
|
||||
"""Drop beats whose title already matches a done/failed quest (manual quest close desync)."""
|
||||
done_titles = {
|
||||
@@ -272,53 +476,79 @@ async def process_arc_beats(
|
||||
*,
|
||||
recent_context: str = "",
|
||||
last_dice_outcome: str | None = None,
|
||||
needs_check: bool = False,
|
||||
user_turn: int = 0,
|
||||
allow_stuck_recovery: bool = True,
|
||||
) -> tuple[dict, list[dict], list[dict], str]:
|
||||
reconcile_closed_count: int = 0,
|
||||
lang: str = "ru",
|
||||
) -> tuple[dict, list[dict], list[dict], str, dict]:
|
||||
"""
|
||||
Prune completed beats, then fire by dice outcome, LLM match, keywords, or stuck recovery.
|
||||
Returns (arc, fired_beats, pruned_beats, mode).
|
||||
Returns (arc, fired_beats, pruned_beats, mode, extras).
|
||||
mode: '' | 'after_dice' | 'llm' | 'trigger' | 'stuck_recovery' | 'pruned'
|
||||
"""
|
||||
extras: dict = {"cooldown_skipped": False}
|
||||
if not arc:
|
||||
return arc, [], [], ""
|
||||
return arc, [], [], "", extras
|
||||
|
||||
arc, pruned = prune_beats_for_done_quests(arc, quests)
|
||||
beats_pending = arc.get("beats") or []
|
||||
|
||||
if not can_fire_beat(arc, user_turn):
|
||||
extras["cooldown_skipped"] = True
|
||||
if pruned:
|
||||
return arc, [], pruned, "pruned", extras
|
||||
return arc, [], [], "", extras
|
||||
|
||||
dice_trig = dice_outcome_to_beat_trigger(last_dice_outcome)
|
||||
if dice_trig and beats_pending:
|
||||
if needs_check and dice_trig and beats_pending:
|
||||
arc, fired = pop_matching_beats(arc, dice_trig, max_beats=1)
|
||||
if fired:
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
logger.info(
|
||||
"process_arc_beats: after_dice %s -> %s",
|
||||
last_dice_outcome,
|
||||
fired[0].get("id"),
|
||||
)
|
||||
return arc, fired, pruned, "after_dice"
|
||||
return arc, fired, pruned, "after_dice", extras
|
||||
|
||||
if beats_pending:
|
||||
beat_id = await classify_plot_beat(
|
||||
user_text, beats_pending, recent_context, last_dice_outcome
|
||||
user_text,
|
||||
beats_pending,
|
||||
recent_context,
|
||||
last_dice_outcome,
|
||||
lang=lang,
|
||||
)
|
||||
if beat_id:
|
||||
arc, fired = pop_beat_by_id(arc, beat_id)
|
||||
if fired:
|
||||
return arc, fired, pruned, "llm"
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
return arc, fired, pruned, "llm", extras
|
||||
|
||||
trig = should_advance_arc_keywords(user_text)
|
||||
if trig:
|
||||
arc, fired = pop_matching_beats(arc, trig, max_beats=1)
|
||||
if fired:
|
||||
return arc, fired, pruned, "trigger"
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
return arc, fired, pruned, "trigger", extras
|
||||
|
||||
if allow_stuck_recovery and arc.get("beats") and count_active_quests(quests) == 0:
|
||||
stuck_ok = (
|
||||
allow_stuck_recovery
|
||||
and reconcile_closed_count == 0
|
||||
and arc.get("beats")
|
||||
and count_active_quests(quests) == 0
|
||||
and can_fire_beat(arc, user_turn)
|
||||
)
|
||||
if stuck_ok:
|
||||
arc, fired = pop_next_beats(arc, 1)
|
||||
if fired:
|
||||
return arc, fired, pruned, "stuck_recovery"
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
return arc, fired, pruned, "stuck_recovery", extras
|
||||
|
||||
if pruned:
|
||||
return arc, [], pruned, "pruned"
|
||||
return arc, [], [], ""
|
||||
return arc, [], pruned, "pruned", extras
|
||||
return arc, [], [], "", extras
|
||||
|
||||
|
||||
PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"]
|
||||
@@ -360,6 +590,8 @@ async def replenish_arc_beats(
|
||||
recent_context: str,
|
||||
quests: list,
|
||||
genre: str = "adventure",
|
||||
*,
|
||||
lang: str = "ru",
|
||||
) -> dict:
|
||||
"""Append new beats when arc.beats is empty so plot/quest engine can continue."""
|
||||
if arc.get("beats"):
|
||||
@@ -368,9 +600,12 @@ async def replenish_arc_beats(
|
||||
quest_lines = "\n".join(
|
||||
f" [{q.get('status')}] {q.get('title')}" for q in (quests or [])
|
||||
) or " (none)"
|
||||
from services.rpg_locale import locale_instruction, locale_label
|
||||
|
||||
user = (
|
||||
f"Character: {persona_name}\n"
|
||||
f"Genre: {format_genres(genre)}\n"
|
||||
f"Session language: {locale_label(lang)}\n"
|
||||
f"Current arc title: {arc.get('title', '')}\n"
|
||||
f"Phase: {arc.get('phase', 'aftermath')}\n"
|
||||
f"Boundaries: {json.dumps(arc.get('boundaries', []), ensure_ascii=False)}\n"
|
||||
@@ -378,7 +613,7 @@ async def replenish_arc_beats(
|
||||
f"Recent chat:\n{recent_context[-4000:]}\n"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": BEATS_APPEND_SYSTEM},
|
||||
{"role": "system", "content": BEATS_APPEND_SYSTEM + "\n" + locale_instruction(lang)},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
@@ -425,41 +660,14 @@ async def reconcile_plot_arc(
|
||||
persona_name: str = "Character",
|
||||
genre: str = "adventure",
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Prune beats that match done quests; replenish if empty. Persists arc when changed.
|
||||
Returns (arc, changed).
|
||||
"""
|
||||
from services.memory import get_session, get_quests, update_session_plot_arc, seed_quests_from_arc
|
||||
"""Sync linear story arc and single active quest. replenish_if_empty ignored (legacy)."""
|
||||
from services.rpg_story import reconcile_story_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 = {}
|
||||
|
||||
quests = await get_quests(session_id)
|
||||
arc, pruned = prune_beats_for_done_quests(arc, quests)
|
||||
changed = bool(pruned)
|
||||
|
||||
if replenish_if_empty and not arc.get("beats"):
|
||||
arc = await replenish_arc_beats(
|
||||
arc,
|
||||
persona_name,
|
||||
recent_context,
|
||||
quests,
|
||||
genre=session.get("genre") or genre,
|
||||
)
|
||||
if arc.get("beats"):
|
||||
changed = True
|
||||
await seed_quests_from_arc(session_id, arc)
|
||||
|
||||
if changed:
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
return arc, changed
|
||||
return await reconcile_story_arc(
|
||||
session_id,
|
||||
persona_name=persona_name,
|
||||
genre=genre,
|
||||
)
|
||||
|
||||
|
||||
def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]:
|
||||
@@ -503,15 +711,28 @@ def normalize_choice(
|
||||
|
||||
|
||||
def choices_from_beat(beat: dict) -> list[dict]:
|
||||
if not isinstance(beat, dict):
|
||||
return choices_from_step(beat)
|
||||
|
||||
|
||||
def choices_from_step(step: dict) -> list[dict]:
|
||||
if not isinstance(step, dict):
|
||||
return []
|
||||
return [
|
||||
c for c in (
|
||||
normalize_choice(item, source="plot_beat", beat=beat)
|
||||
for item in (beat.get("choices") or [])
|
||||
)
|
||||
if c
|
||||
]
|
||||
out = []
|
||||
for item in (step.get("choices") or []):
|
||||
c = normalize_choice(item, source="plot_step", beat=step)
|
||||
if c:
|
||||
if step.get("id"):
|
||||
c["step_id"] = step["id"]
|
||||
c["beat_id"] = step["id"]
|
||||
title = (step.get("title") or "").strip()
|
||||
if title:
|
||||
c["beat_title"] = title
|
||||
c["step_title"] = title
|
||||
inj = (step.get("injection") or "").strip()
|
||||
if inj:
|
||||
c["beat_injection"] = inj
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def choices_from_narrator(raw_choices: list) -> list[dict]:
|
||||
|
||||
Reference in New Issue
Block a user