new RPG system
This commit is contained in:
+305
-23
@@ -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
@@ -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
@@ -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
@@ -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. "день 1–2").
|
||||
- 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:]
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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