new RPG system

This commit is contained in:
2026-06-05 14:57:15 +03:00
parent 6189a5fb74
commit 01b16dbeaa
29 changed files with 2395 additions and 311 deletions
+305 -23
View File
@@ -396,16 +396,32 @@ async def delete_messages_after(session_id: str, message_id: int):
"DELETE FROM messages WHERE session_id = ? AND id > ?",
(session_id, message_id),
)
await db.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit()
await restore_session_to_message(session_id, message_id)
async def delete_message(message_id: int):
msg = await get_message(message_id)
if not msg:
return
session_id = msg["session_id"]
anchor = await get_max_message_id_before(session_id, message_id)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit()
await restore_session_to_message(session_id, anchor)
async def delete_message_and_following(session_id: str, message_id: int) -> bool:
anchor = await get_max_message_id_before(session_id, message_id)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"DELETE FROM messages WHERE session_id = ? AND id >= ?",
@@ -416,6 +432,7 @@ async def delete_message_and_following(session_id: str, message_id: int) -> bool
(session_id,),
)
await db.commit()
await restore_session_to_message(session_id, anchor)
return True
@@ -456,6 +473,185 @@ async def get_last_message_preview(session_id: str, max_len: int = 80) -> str:
return prefix + text
async def get_max_message_id_before(session_id: str, message_id: int) -> int | None:
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute(
"SELECT MAX(id) FROM messages WHERE session_id = ? AND id < ?",
(session_id, message_id),
) as cur:
row = await cur.fetchone()
if not row or row[0] is None:
return None
return int(row[0])
async def _collect_action_resolutions(session_id: str) -> list[dict]:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT message_id, intent_text, roll, outcome, resolution_text
FROM action_resolutions
WHERE session_id = ?
ORDER BY id""",
(session_id,),
) as cur:
rows = await cur.fetchall()
return [
{
"message_id": r["message_id"],
"intent_text": r["intent_text"],
"roll": r["roll"],
"outcome": r["outcome"],
"resolution_text": r["resolution_text"],
}
for r in rows
]
async def save_state_snapshot(session_id: str, message_id: int) -> None:
"""Persist RPG session state as it exists right after this message."""
session = await get_session(session_id)
if not session:
return
quests = await get_quests(session_id)
resolutions = await _collect_action_resolutions(session_id)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO session_state_snapshots
(message_id, session_id, facts_json, global_plot, status_quo,
plot_arc_json, affinity, outfit_json, scene_json,
narrative_stats_json, quests_json, action_resolutions_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(message_id) DO UPDATE SET
session_id = excluded.session_id,
facts_json = excluded.facts_json,
global_plot = excluded.global_plot,
status_quo = excluded.status_quo,
plot_arc_json = excluded.plot_arc_json,
affinity = excluded.affinity,
outfit_json = excluded.outfit_json,
scene_json = excluded.scene_json,
narrative_stats_json = excluded.narrative_stats_json,
quests_json = excluded.quests_json,
action_resolutions_json = excluded.action_resolutions_json""",
(
message_id,
session_id,
session.get("facts_json", "[]"),
session.get("global_plot", ""),
session.get("status_quo", ""),
session.get("plot_arc_json", "{}"),
int(session.get("affinity") or 0),
session.get("outfit_json", "[]"),
session.get("scene_json", "{}"),
session.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'),
json.dumps(quests, ensure_ascii=False),
json.dumps(resolutions, ensure_ascii=False),
),
)
await db.commit()
async def get_snapshot_at_or_before(session_id: str, message_id: int) -> dict | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT * FROM session_state_snapshots
WHERE session_id = ? AND message_id <= ?
ORDER BY message_id DESC LIMIT 1""",
(session_id, message_id),
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def restore_session_from_snapshot(session_id: str, snapshot: dict) -> None:
from services.rpg_state import DEFAULT_NARRATIVE_STATS
stats_default = json.dumps(DEFAULT_NARRATIVE_STATS, ensure_ascii=False)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""UPDATE sessions SET
facts_json = ?,
global_plot = ?,
status_quo = ?,
plot_arc_json = ?,
affinity = ?,
outfit_json = ?,
scene_json = ?,
narrative_stats_json = ?,
updated_at = CURRENT_TIMESTAMP
WHERE session_id = ?""",
(
snapshot.get("facts_json", "[]"),
snapshot.get("global_plot", ""),
snapshot.get("status_quo", ""),
snapshot.get("plot_arc_json", "{}"),
int(snapshot.get("affinity") or 0),
snapshot.get("outfit_json", "[]"),
snapshot.get("scene_json", "{}"),
snapshot.get("narrative_stats_json") or stats_default,
session_id,
),
)
await db.execute("DELETE FROM rpg_quests WHERE session_id = ?", (session_id,))
try:
quests = json.loads(snapshot.get("quests_json") or "[]")
except (json.JSONDecodeError, TypeError):
quests = []
if isinstance(quests, list):
for q in quests:
if not isinstance(q, dict):
continue
title = (q.get("title") or "").strip()
if title:
await db.execute(
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
(session_id, title[:120], q.get("status", "active")),
)
await db.execute("DELETE FROM action_resolutions WHERE session_id = ?", (session_id,))
try:
resolutions = json.loads(snapshot.get("action_resolutions_json") or "[]")
except (json.JSONDecodeError, TypeError):
resolutions = []
if isinstance(resolutions, list):
for r in resolutions:
if not isinstance(r, dict):
continue
await db.execute(
"""INSERT INTO action_resolutions
(session_id, message_id, intent_text, roll, outcome, resolution_text)
VALUES (?, ?, ?, ?, ?, ?)""",
(
session_id,
r.get("message_id"),
r.get("intent_text", ""),
int(r.get("roll") or 0),
r.get("outcome", ""),
r.get("resolution_text", ""),
),
)
await db.commit()
async def restore_session_to_message(session_id: str, anchor_message_id: int | None) -> bool:
"""Restore RPG state to snapshot at anchor (or nearest earlier message)."""
if anchor_message_id is None:
await _reset_persona_bound_state_only(session_id)
return False
snap = await get_snapshot_at_or_before(session_id, anchor_message_id)
if not snap:
return False
await restore_session_from_snapshot(session_id, snap)
return True
async def _reset_persona_bound_state_only(session_id: str) -> None:
async with aiosqlite.connect(DB_PATH) as db:
await _reset_persona_bound_state(db, session_id)
await db.commit()
async def fork_session(source_session_id: str, until_message_id: int) -> str | None:
source = await get_session(source_session_id)
if not source:
@@ -464,6 +660,8 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
import uuid
new_id = "sess_" + uuid.uuid4().hex[:8]
snap = await get_snapshot_at_or_before(source_session_id, until_message_id)
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO sessions
@@ -476,40 +674,112 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
source["persona_id"],
(source.get("title") or "Новый чат") + " (ветка)",
source.get("rpg_enabled", 0),
source.get("facts_json", "[]"),
source.get("global_plot", ""),
source.get("status_quo", ""),
source.get("plot_arc_json", "{}"),
(snap or source).get("facts_json", "[]"),
(snap or source).get("global_plot", ""),
(snap or source).get("status_quo", ""),
(snap or source).get("plot_arc_json", "{}"),
source.get("genre", "adventure"),
source.get("rpg_settings_json", "{}"),
source.get("affinity", 0),
source.get("outfit_json", "[]"),
source.get("scene_json", "{}"),
source.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'),
int((snap or source).get("affinity") or 0),
(snap or source).get("outfit_json", "[]"),
(snap or source).get("scene_json", "{}"),
(snap or source).get(
"narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'
),
),
)
async with db.execute(
"""SELECT role, content, image_prompt, image_path FROM messages
"""SELECT id, role, content, image_prompt, image_path,
image_prompt_alt, image_path_alt, choices_json
FROM messages
WHERE session_id = ? AND id <= ? ORDER BY id""",
(source_session_id, until_message_id),
) as cur:
rows = await cur.fetchall()
id_map: dict[int, int] = {}
for r in rows:
await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
VALUES (?, ?, ?, ?, ?)""",
(new_id, r[0], r[1], r[2], r[3]),
)
async with db.execute(
"SELECT title, status FROM rpg_quests WHERE session_id = ?",
(source_session_id,),
) as cur:
quests = await cur.fetchall()
for q in quests:
await db.execute(
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
(new_id, q[0], q[1]),
cur_ins = await db.execute(
"""INSERT INTO messages
(session_id, role, content, image_prompt, image_path,
image_prompt_alt, image_path_alt, choices_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(new_id, r[1], r[2], r[3], r[4], r[5], r[6], r[7]),
)
id_map[int(r[0])] = int(cur_ins.lastrowid)
if snap:
try:
quests = json.loads(snap.get("quests_json") or "[]")
except (json.JSONDecodeError, TypeError):
quests = []
if isinstance(quests, list):
for q in quests:
if isinstance(q, dict) and (q.get("title") or "").strip():
await db.execute(
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
(new_id, q["title"][:120], q.get("status", "active")),
)
try:
resolutions = json.loads(snap.get("action_resolutions_json") or "[]")
except (json.JSONDecodeError, TypeError):
resolutions = []
if isinstance(resolutions, list):
for res in resolutions:
if not isinstance(res, dict):
continue
old_mid = res.get("message_id")
new_mid = id_map.get(int(old_mid)) if old_mid is not None else None
if new_mid is None:
continue
await db.execute(
"""INSERT INTO action_resolutions
(session_id, message_id, intent_text, roll, outcome, resolution_text)
VALUES (?, ?, ?, ?, ?, ?)""",
(
new_id,
new_mid,
res.get("intent_text", ""),
int(res.get("roll") or 0),
res.get("outcome", ""),
res.get("resolution_text", ""),
),
)
async with db.execute(
"""SELECT message_id, facts_json, global_plot, status_quo, plot_arc_json,
affinity, outfit_json, scene_json, narrative_stats_json,
quests_json, action_resolutions_json
FROM session_state_snapshots
WHERE session_id = ? AND message_id <= ?""",
(source_session_id, until_message_id),
) as snap_cur:
snap_rows = await snap_cur.fetchall()
for srow in snap_rows:
new_mid = id_map.get(int(srow[0]))
if new_mid is None:
continue
res_json = srow[10]
try:
res_list = json.loads(res_json or "[]")
except (json.JSONDecodeError, TypeError):
res_list = []
if isinstance(res_list, list):
remapped = []
for res in res_list:
if not isinstance(res, dict):
continue
old_mid = res.get("message_id")
nm = id_map.get(int(old_mid)) if old_mid is not None else None
if nm is not None:
remapped.append({**res, "message_id": nm})
res_json = json.dumps(remapped, ensure_ascii=False)
await db.execute(
"""INSERT INTO session_state_snapshots
(message_id, session_id, facts_json, global_plot, status_quo,
plot_arc_json, affinity, outfit_json, scene_json,
narrative_stats_json, quests_json, action_resolutions_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(new_mid, new_id, srow[1], srow[2], srow[3], srow[4], srow[5],
srow[6], srow[7], srow[8], srow[9], res_json),
)
await db.commit()
return new_id
@@ -572,6 +842,17 @@ async def update_message_image_alt(message_id: int, image_path_alt: str):
await db.commit()
async def get_last_message_id(session_id: str) -> int | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1",
(session_id,),
) as cursor:
row = await cursor.fetchone()
return int(row["id"]) if row else None
async def get_last_assistant_message_id(session_id: str) -> int | None:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
@@ -591,6 +872,7 @@ async def clear_history(session_id: str):
"DELETE FROM messages WHERE session_id = ?", (session_id,)
)
await db.commit()
await _reset_persona_bound_state_only(session_id)
async def update_session_affinity(session_id: str, delta: int):
+21 -3
View File
@@ -7,7 +7,6 @@ from services.memory import (
get_last_assistant_message_id,
update_session_plot_arc,
update_message_choices,
seed_quests_from_arc,
get_quests,
)
from services.rpg_state import apply_narrator_post
@@ -64,7 +63,10 @@ async def ensure_plot_arc_and_quests(
if arc:
return arc
from services.rpg_locale import infer_rp_language
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
lang = infer_rp_language([{"role": "assistant", "content": greeting}])
arc = await generate_plot_arc(
persona.get("name", "Character"),
persona.get("description", ""),
@@ -72,13 +74,17 @@ async def ensure_plot_arc_and_quests(
greeting,
facts_block=facts_block,
genre=genre,
lang=lang,
recent_context=f"assistant: {greeting}",
)
if not arc:
return {}
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
if seed_quests:
await seed_quests_from_arc(session_id, arc)
from services.rpg_story import sync_quest_to_current_step
await sync_quest_to_current_step(session_id, arc)
return arc
@@ -122,6 +128,9 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
quests_pre = await get_quests(session_id)
narr_ctx = format_narrator_context(arc, quests_pre, session.get("status_quo") or "")
from services.rpg_locale import infer_rp_language
o_lang = infer_rp_language([{"role": "assistant", "content": first_mes_text}])
post = await narrator_post(
persona.get("name", persona_id),
ctx_txt,
@@ -129,12 +138,17 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
facts_block,
is_opening=True,
extra_context=narr_ctx,
lang=o_lang,
)
if rpg_settings.get("choices", True):
choices = choices_from_narrator(post.get("choices") or [])
await apply_narrator_post(session_id, post, rpg_settings, session)
from services.rpg_state import apply_narrator_post_with_story
await apply_narrator_post_with_story(
session_id, post, rpg_settings, session, arc=arc
)
session = await get_session(session_id) or session
status_quo = session.get("status_quo") or status_quo
outfit_json = session.get("outfit_json") or outfit_json
@@ -150,6 +164,10 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
sd_out = await run_sd_for_message(bundle, msg_id) if bundle else {}
updated = await get_session(session_id)
if rpg and msg_id:
from services.memory import save_state_snapshot
await save_state_snapshot(session_id, msg_id)
affinity = updated.get("affinity", 0) if updated else 0
if msg_id and choices:
+23 -42
View File
@@ -1,6 +1,12 @@
"""Shared context blocks for RPG narrator / plot LLM calls."""
from services.rpg_plot import count_active_quests
import json
from services.rpg_story import (
format_step_for_narrator,
normalize_story_arc,
step_progress,
)
def format_narrator_context(
@@ -8,46 +14,21 @@ def format_narrator_context(
quests: list | None,
status_quo: str = "",
) -> str:
parts: list[str] = []
arc = arc or {}
beats = arc.get("beats") or []
if not isinstance(beats, list):
beats = []
arc = normalize_story_arc(arc or {}) if arc else {}
return format_step_for_narrator(arc, quests, status_quo)
parts.append(f"Plot phase: {arc.get('phase', 'opening')}. Scripted beats left: {len(beats)}.")
if not beats:
parts.append(
"IMPORTANT: Scripted beats are EXHAUSTED (quests may already be done). "
"The story must CONTINUE — do not stall. "
"Always return 2-4 meaningful choices for the player's next actions. "
"You may add quest_updates with status 'active' for NEW optional threads. "
"Do NOT re-activate quests the player already completed unless they explicitly revisit that thread."
)
elif count_active_quests(quests) == 0:
pending = [
(b.get("title") or b.get("id") or "beat")
for b in beats[:3]
if isinstance(b, dict)
]
parts.append(
"IMPORTANT: No active quests but scripted beats remain — arc was likely desynced. "
"The engine will inject the next beat; prefer choices that fit pending beats: "
+ ", ".join(pending)
+ ". Do NOT treat the arc as finished."
)
hint = (arc.get("next_beat_hint") or "").strip()
if hint:
parts.append(f"Arc hint: {hint}")
if quests:
parts.append("Quest log:")
for q in quests:
parts.append(f" [{q.get('status', 'active')}] {q.get('title', '')}")
else:
parts.append("Quest log: (empty)")
sq = (status_quo or "").strip()
if sq:
parts.append(f"Status quo: {sq[:400]}")
return "\n".join(parts)
def format_arc_summary_for_runtime(arc: dict | None) -> str:
arc = normalize_story_arc(arc or {}) if arc else {}
if not arc:
return ""
cur, total = step_progress(arc)
return json.dumps(
{
"title": arc.get("title"),
"global_story": (arc.get("global_story") or "")[:300],
"step": f"{cur}/{total}",
"status": arc.get("status", "active"),
},
ensure_ascii=False,
)
+59 -7
View File
@@ -20,22 +20,37 @@ Return ONLY valid JSON (no markdown), as an array of objects:
Rules:
- Return at most 5 NEW facts per turn. If nothing new, return [].
- Do NOT repeat or rephrase facts already listed under "Already known".
- Facts must be durable (names, relations, inventory, locations, lasting world state).
- Facts must be DURABLE world/character state only:
traits, relationships, inventory, locations, secrets revealed, lasting abilities/rules.
- NEVER store plot events or scene narration (no "they went", "they decided", "they hugged",
"they started a new arc", "they stepped through the portal").
- Skip momentary emotions unless they permanently change a relationship.
- text <= 120 chars each.
- rp_day: in-world time label (день 1, второй день, та же ночь, через год). Use RP time hint when unclear."""
FACTS_COMPRESS_SYSTEM = """You consolidate RPG session memory for a long-running chat.
Return ONLY valid JSON (no markdown): an array of {"text": "...", "rp_day": "..."}.
Return ONLY valid JSON (no markdown): an array of {{"text": "...", "rp_day": "..."}}.
Goals:
- Use ONLY information from the input facts. NEVER invent or infer new facts.
- Aggressively MERGE near-duplicates (same topic in RU/EN, Rin/Рин, Grigo/Григорий).
- Keep ONE best fact per topic; combine rp_day if needed (e.g. "день 12").
- DROP redundant, trivial, or superseded facts.
- Keep: names, relationships, key locations, lasting magic/rules, inventory, unresolved threads.
- DROP redundant, trivial, superseded, and ALL one-off narrative/event facts.
- DROP facts that describe a single scene action (went, decided, hugged, called for help, stepped into portal).
- KEEP durable state only: names, nicknames, relationships, inventory, home items, locations,
lasting abilities, secrets/identity, unresolved mysteries.
- Target at most {target} facts (fewer is better). Each text <= 120 chars.
- rp_day = in-world labels only."""
_NARRATIVE_EVENT_RE = re.compile(
r"(?:"
r"отправил(?:ись|а|и)?|решили|начали|обнялись|шагнули|вызвали|стали ближе|"
r"передали|раскрыла|начали новую арку|вместе шагнули|тактическ(?:ое|и) отступлени|"
r"went to|decided to|hugged|stepped through|called for help|started a new arc"
r")",
re.IGNORECASE,
)
_NAME_ALIASES = (
("grigoriy", "григорий"),
("grigo", "григо"),
@@ -135,6 +150,38 @@ def facts_are_similar(a: str, b: str) -> bool:
return overlap >= 0.32
def is_likely_narrative_event(text: str) -> bool:
"""One-off scene actions — not durable memory."""
t = (text or "").strip()
if not t:
return True
if _NARRATIVE_EVENT_RE.search(t):
return True
if "новую арку" in t.lower() or "new arc" in t.lower():
return True
return False
def filter_durable_facts(facts: list[dict]) -> list[dict]:
return [f for f in facts if not is_likely_narrative_event(f.get("text", ""))]
def validate_compressed_against_source(
original: list[dict], compressed: list[dict]
) -> list[dict]:
"""Reject LLM-hallucinated facts not grounded in the input list."""
if not compressed:
return []
out: list[dict] = []
for c in compressed:
text = (c.get("text") or "").strip()
if not text or is_likely_narrative_event(text):
continue
if any(facts_are_similar(text, o.get("text", "")) for o in original):
out.append(c)
return out
def dedupe_facts_fuzzy(facts: list[dict]) -> list[dict]:
out: list[dict] = []
for f in facts:
@@ -243,8 +290,10 @@ async def compress_facts(
if entry:
out.append(entry)
if out:
logger.info("compress_facts: %d -> %d", len(facts), len(out))
return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT]
out = validate_compressed_against_source(facts, out)
if out:
logger.info("compress_facts: %d -> %d", len(facts), len(out))
return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT]
except json.JSONDecodeError:
logger.warning("compress_facts JSON parse failed. Raw=%.400s", raw)
return dedupe_facts_fuzzy(facts)[-target:]
@@ -263,6 +312,7 @@ async def merge_facts_persist(
existing_json, new_facts, rp_day_default=rp_day_default
)
facts = dedupe_facts_fuzzy(parse_facts_list(merged_json))
facts = filter_durable_facts(facts)
if len(facts) > FACTS_DEDUP_THRESHOLD:
facts = await compress_facts(
facts,
@@ -340,6 +390,8 @@ async def extract_facts(
continue
if any(facts_are_similar(entry["text"], k["text"]) for k in known):
continue
if is_likely_narrative_event(entry["text"]):
continue
if not entry["rp_day"] and hint:
entry["rp_day"] = hint[:80]
out.append(entry)
@@ -350,7 +402,7 @@ async def extract_facts(
def facts_to_prompt(facts_json: str, max_items: int = FACTS_PROMPT_MAX) -> str:
facts = dedupe_facts_fuzzy(parse_facts_list(facts_json))
facts = filter_durable_facts(dedupe_facts_fuzzy(parse_facts_list(facts_json)))
if not facts:
return ""
recent = facts[-max_items:]
+51
View File
@@ -0,0 +1,51 @@
"""Infer RP session language from recent chat for narrator/plot prompts."""
import re
_CYRILLIC = re.compile(r"[\u0400-\u04FF]")
def infer_rp_language(messages: list | None, *, sample: int = 8) -> str:
"""
Return 'ru' if recent user/assistant text is mostly Cyrillic, else 'en'.
"""
if not messages:
return "ru"
texts: list[str] = []
for m in reversed(messages):
if not isinstance(m, dict):
continue
if m.get("role") not in ("user", "assistant"):
continue
c = (m.get("content") or "").strip()
if c:
texts.append(c)
if len(texts) >= sample:
break
if not texts:
return "ru"
combined = " ".join(texts)
cyr = len(_CYRILLIC.findall(combined))
lat = len(re.findall(r"[A-Za-z]", combined))
if cyr == 0 and lat > 0:
return "en"
if cyr >= lat:
return "ru"
return "ru" if cyr > lat * 0.3 else "en"
def locale_instruction(lang: str) -> str:
if lang == "ru":
return (
"Session language: Russian. "
"All prose you generate (injections, titles, resolution_text, status_quo, choice labels) "
"MUST be in Russian."
)
return (
"Session language: English. "
"All prose you generate MUST be in English."
)
def locale_label(lang: str) -> str:
return "Russian" if lang == "ru" else "English"
+29 -6
View File
@@ -40,14 +40,17 @@ Return ONLY valid JSON (no markdown):
"stats_delta": {"lust": 0, "stamina": 0, "tension": 0},
"scene_update": {"place": "", "place_id": "", "time_of_day": "", "day": "", "weather": "", "exits": [], "layout_note": ""},
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}],
"outfit_update": ["danbooru_tag", "danbooru_tag"]
"outfit_update": ["danbooru_tag", "danbooru_tag"],
"step_complete": false,
"step_completion_note": "optional 1 sentence when step_complete is true"
}
Rules:
- status_quo_update: internal DM state only (facts, location, mood). Never address the player, never use headers like "Status quo"/"Статус кво", P.S., or author commentary.
- affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral.
- stats_delta: each lust/stamina/tension -2..+2 (0 if unchanged). lust=arousal, stamina=energy, tension=stress.
- scene_update: partial location/time schema; only keys that changed. Do not duplicate all of status_quo into scene_update.
- quest_updates: only include if a quest was clearly started, completed, or failed. Empty array otherwise.
- quest_updates: legacy; prefer step_complete for story progression. Empty array otherwise.
- step_complete: true ONLY when the CURRENT story step completion_criteria are clearly met. Do not rush.
- choices: 0-4 options for what the player can do next. REQUIRED when scripted beats are exhausted — never return an empty choices array unless the session truly ended.
- outfit_update: ONLY if clothing visibly changed. Use danbooru underscore_tags WITH COLOR when possible
(e.g. white_tank_top, black_sports_shorts, gold_championship_belt, blue_jeans, red_ribbon).
@@ -64,6 +67,8 @@ async def narrator_pre(
roll: int | None = None,
outcome: str | None = None,
extra_context: str = "",
*,
lang: str = "ru",
) -> dict:
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
user = (
@@ -76,9 +81,17 @@ async def narrator_pre(
)
if extra_context:
user += f"\n--- Session state ---\n{extra_context}\n---\n"
from services.rpg_locale import locale_instruction
try:
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
[
{
"role": "system",
"content": NARRATOR_PRE_SYSTEM + "\n" + locale_instruction(lang),
},
{"role": "user", "content": user},
],
NARRATOR_MODEL,
)
except LLMError as e:
@@ -111,6 +124,8 @@ async def narrator_post(
facts_block: str,
is_opening: bool = False,
extra_context: str = "",
*,
lang: str = "ru",
) -> dict:
opening_block = ""
if is_opening:
@@ -133,17 +148,25 @@ async def narrator_post(
)
if extra_context:
user += f"\n--- Session state ---\n{extra_context}\n---\n"
from services.rpg_locale import locale_instruction
try:
raw = await send_message_with_model(
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
[
{
"role": "system",
"content": NARRATOR_POST_SYSTEM + "\n" + locale_instruction(lang),
},
{"role": "user", "content": user},
],
NARRATOR_MODEL,
)
except LLMError as e:
logger.warning("Narrator-post LLM failed (model=%s): %s", NARRATOR_MODEL, e)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "step_complete": False, "_ok": False}
except Exception as e:
logger.warning("Narrator-post unexpected error: %s", e)
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "step_complete": False, "_ok": False}
cleaned = raw.strip()
if cleaned.startswith("```"):
+293 -72
View File
@@ -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]:
+58 -20
View File
@@ -186,31 +186,45 @@ def stats_prompt_block(stats: dict) -> str:
)
def format_narrator_outcome_for_llm(data: dict) -> str:
def format_narrator_outcome_for_llm(data: dict, *, lang: str = "ru") -> str:
"""Turn stored narrator JSON into a binding user-turn for the character model."""
roll = data.get("roll")
outcome = (data.get("outcome") or "").strip().lower()
text = (data.get("text") or "").strip()
lines = [
"--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
f"Roll d20={roll}. Outcome: {outcome}.",
f"What ACTUALLY happened (canonical truth): {text}",
]
if outcome in ("failure", "critical failure"):
lines.append(
"The player's action FAILED as they imagined it. "
"Do NOT write a success version: no crowd fleeing, no intimidation working, "
"no effortless victory. Show the failure, embarrassment, or partial result above."
)
elif outcome == "critical success":
lines.append(
"The attempt succeeded dramatically. You may show amplified success aligned with the outcome above."
)
if lang == "ru":
lines = [
"--- Правило рассказчика (ОБЯЗАТЕЛЬНО — ответ персонажа должен ему следовать) ---",
f"Бросок d20={roll}. Исход: {outcome}.",
f"Что РЕАЛЬНО произошло (канон): {text}",
]
if outcome in ("failure", "critical failure"):
lines.append(
"Действие игрока НЕ удалось. Не пиши успешную версию — покажи провал или частичный результат выше."
)
elif outcome == "critical success":
lines.append("Попытка удалась блестяще. Усиль успех в духе исхода выше.")
else:
lines.append("Попытка удалась. Ответ должен совпадать с исходом выше, не противоречить ему.")
lines.append("Отвечай только как персонаж на ЭТОТ исход. Не упоминай кубики, броски, статы.")
else:
lines.append(
"The attempt succeeded. Your reply must align with the narrator outcome above, not contradict it."
)
lines.append("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
lines = [
"--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
f"Roll d20={roll}. Outcome: {outcome}.",
f"What ACTUALLY happened (canonical truth): {text}",
]
if outcome in ("failure", "critical failure"):
lines.append(
"The player's action FAILED. Do NOT write a success version; show failure per above."
)
elif outcome == "critical success":
lines.append(
"The attempt succeeded dramatically. Align with the outcome above."
)
else:
lines.append(
"The attempt succeeded. Your reply must align with the narrator outcome above."
)
lines.append("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
lines.append("---")
return "\n".join(lines)
@@ -319,3 +333,27 @@ async def apply_narrator_post(session_id: str, post: dict, rpg_settings: dict, s
applied["quests_updated"] += 1
return applied
async def apply_narrator_post_with_story(
session_id: str,
post: dict,
rpg_settings: dict,
session: dict | None = None,
arc: dict | None = None,
) -> dict:
"""apply_narrator_post + linear story step advance."""
from services.rpg_story import apply_story_post, normalize_story_arc
applied = await apply_narrator_post(session_id, post, rpg_settings, session)
arc = normalize_story_arc(arc or {})
story = await apply_story_post(session_id, post, arc, rpg_settings)
applied.update({
"step_advanced": story.get("step_advanced", False),
"arc_completed": story.get("arc_completed", False),
"new_step_title": story.get("new_step_title", ""),
"step_injection": story.get("step_injection", ""),
})
if story.get("arc"):
applied["arc"] = story["arc"]
return applied
+567
View File
@@ -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