Compare commits

...

2 Commits

Author SHA1 Message Date
Grigo 7f3599a859 new RPG system 2026-06-05 15:13:55 +03:00
Grigo 01b16dbeaa new RPG system 2026-06-05 14:57:15 +03:00
29 changed files with 2395 additions and 311 deletions
+14 -11
View File
@@ -165,11 +165,15 @@ sequenceDiagram
- `status_quo_update``UPDATE sessions.status_quo`.
- `scene_update` (partial) → merge в `scene_json`.
- **Нет** d20, **нет** bubble «Рассказчик».
3. **runtime_suffix** = `build_rpg_runtime_suffix(session)` + `narrator_extra` (directives только).
4. `upsert_static_system_message(static)` — в БД system без RPG-блоков.
5. `add_message(user, message)`.
6. **context_usage** — если > 85%, в конец system добавляется `[Context: ~N% …]`.
7. **llm_messages** = system(static+runtime) + вся история user/assistant.
3. **Linear story (pre-stream):** `reconcile_story_arc` → один active-квест = текущий `steps[i]`:
- `format_step_guidance_for_character` — цель шага в system CHAT.
- При первом входе в шаг: `injection` как мягкая подсказка (`format_step_hint_for_character`).
- Если арка завершена и игрок выбрал «новую арку» → `roll_next_arc`.
4. **runtime_suffix** = `build_rpg_runtime_suffix(session)` + `narrator_extra` (directives + step guidance/hint).
5. `upsert_static_system_message(static)` — в БД system без RPG-блоков.
6. `add_message(user, message)`.
7. **context_usage** — если > 85%, в конец system добавляется `[Context: ~N% …]`.
8. **llm_messages** = system(static+runtime) + история; язык сессии (`infer_rp_language`) в narrator/plot/injection.
### Этап B — SSE stream
@@ -187,12 +191,11 @@ sequenceDiagram
| # | Действие | Модель | Условие |
|---|----------|--------|---------|
| C1 | `generate_plot_arc` | PLOT | Только если `plot_arc_json` пуст (редко после opening) |
| C2 | `should_advance_arc(user_message)` | код | Ключевые слова: отдых → `event_driven:rest`, путь → `travel`, помощь → `help_request` |
| C3 | `pop_matching_beats` + injection | — | Если trig совпал с beat; choices из beat |
| C4 | `advance_phase` | — | Если beats пусты — фаза opening→hook→… |
| C5 | `extract_facts` | FACTS | Последние 10 реплик; merge до 80, в промпт 20 |
| C6 | **`narrator_post`** | NARRATOR | Контекст: последние 8 реплик **включая новый ответ** |
| C7 | **`apply_narrator_post`** | — | status_quo, affinity_delta, scene, outfit, quests, stats_delta* |
| C2 | **`narrator_post`** | NARRATOR | Контекст: шаг N/M, completion_criteria, последние 8 реплик |
| C3 | **`apply_narrator_post_with_story`** | — | facts, affinity, scene; `step_complete` → advance step, sync quest |
| C4 | step choices / new arc | — | choices из нового шага; при `arc_completed` — «Начать новую арку» |
| C5 | `extract_facts` | FACTS | Последние 10 реплик |
| C6 | SD + SSE `done` | — | **всегда** `quests` + `story_arc` в payload (live UI) |
| C8 | `generate_sd_prompt` + Comfy | SD | outfit + scene_json + последние 6 реплик |
| C9 | SSE `done` | — | choices, affinity, quests, image_*, debug |
+1
View File
@@ -0,0 +1 @@
print('bootstrap')
+26
View File
@@ -76,6 +76,7 @@ async def init_db():
await _migrate_characters_columns(db)
await _migrate_rpg_quests(db)
await _migrate_action_resolutions(db)
await _migrate_state_snapshots(db)
await db.commit()
@@ -178,6 +179,31 @@ async def _migrate_action_resolutions(db):
)
async def _migrate_state_snapshots(db):
await db.executescript(
"""
CREATE TABLE IF NOT EXISTS session_state_snapshots (
message_id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
facts_json TEXT NOT NULL DEFAULT '[]',
global_plot TEXT NOT NULL DEFAULT '',
status_quo TEXT NOT NULL DEFAULT '',
plot_arc_json TEXT NOT NULL DEFAULT '{}',
affinity INTEGER NOT NULL DEFAULT 0,
outfit_json TEXT NOT NULL DEFAULT '[]',
scene_json TEXT NOT NULL DEFAULT '{}',
narrative_stats_json TEXT NOT NULL DEFAULT '{"lust":0,"stamina":10,"tension":0}',
quests_json TEXT NOT NULL DEFAULT '[]',
action_resolutions_json TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_state_snapshots_session
ON session_state_snapshots(session_id);
"""
)
async def _migrate_characters_columns(db):
async with db.execute("PRAGMA table_info(characters)") as cur:
cols = {row[1] for row in await cur.fetchall()}
+3
View File
@@ -8,6 +8,9 @@ class ChatRequest(BaseModel):
is_narrator_choice: bool = False
skip_user_add: bool = False
first_mes_override: Optional[str] = None
# After arc completion: "user" = injection only, player speaks next;
# "character" = injection then character opens the new arc.
new_arc_first: Optional[str] = None
class MessageEditRequest(BaseModel):
+219 -87
View File
@@ -24,7 +24,6 @@ from services.memory import (
update_session_genre,
update_session_plot_arc,
get_quests,
seed_quests_from_arc,
narrator_message_content,
parse_narrator_message,
add_action_resolution,
@@ -36,10 +35,13 @@ from services.memory import (
update_message_choices,
clear_choices_for_session,
upsert_static_system_message,
save_state_snapshot,
get_last_message_id,
)
from services.context_budget import compute_payload_usage, context_warning_line
from services.rpg_state import (
apply_narrator_post,
apply_narrator_post_with_story,
parse_scene_json,
parse_stats_json,
scene_prompt_block,
@@ -57,17 +59,30 @@ from services.sd_images import run_sd_for_message
from services.character_card import get_character
from services import sdbackend as sd_service
from services.rpg_facts import extract_facts, merge_facts_persist, facts_to_prompt, rp_day_from_scene
from services.rpg_context import format_narrator_context
from services.rpg_context import format_narrator_context, format_arc_summary_for_runtime
from services.rpg_plot import (
generate_plot_arc,
process_arc_beats,
advance_phase,
replenish_arc_beats,
reconcile_plot_arc,
reconcile_plot_arc,
choices_from_beat,
choices_from_step,
choices_from_narrator,
)
from services.rpg_story import (
normalize_story_arc,
get_current_step,
format_step_guidance_for_character,
format_step_hint_for_character,
format_new_arc_opening,
should_show_step_injection,
mark_injection_shown,
reconcile_story_arc,
sync_quest_to_current_step,
is_arc_completed,
is_new_arc_request,
normalize_new_arc_first,
append_new_arc_roll_choice,
roll_next_arc,
step_progress,
)
from services.rpg_narrator import narrator_pre, narrator_post
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening
@@ -102,9 +117,9 @@ def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str
except Exception:
arc = {}
if arc:
runtime_suffix += "\n\n--- PlotArc ---\n" + json.dumps(
{k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False
) + "\n---"
summary = format_arc_summary_for_runtime(arc)
if summary:
runtime_suffix += "\n\n--- Story arc ---\n" + summary + "\n---"
status_quo = (session.get("status_quo") or "").strip()
if status_quo:
from services.rp_sanitize import status_quo_prompt_block
@@ -122,7 +137,9 @@ def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str
return runtime_suffix
def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
def messages_for_llm(
history: list, llm_system_content: str, *, rp_lang: str = "ru"
) -> list[dict]:
"""Build LLM payload: one system message (static + runtime), no duplicate system rows."""
out: list[dict] = []
system_used = False
@@ -134,7 +151,10 @@ def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
elif m["role"] == "narrator":
data = parse_narrator_message(m.get("content") or "")
if data:
out.append({"role": "user", "content": format_narrator_outcome_for_llm(data)})
out.append({
"role": "user",
"content": format_narrator_outcome_for_llm(data, lang=rp_lang),
})
elif m["role"] == "user":
has_res = bool(m.get("action_resolution"))
out.append({
@@ -272,14 +292,18 @@ async def rpg_bootstrap(req: RpgBootstrapRequest):
session = await get_session(req.session_id) or {}
rpg_settings = get_rpg_settings(session)
if rpg_settings.get("narrator", True) and greeting:
from services.rpg_locale import infer_rp_language
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
b_lang = infer_rp_language([{"role": "assistant", "content": greeting}])
post = await narrator_post(
persona.get("name", persona_id),
f"assistant: {greeting}",
arc_json,
facts_block,
is_opening=True,
lang=b_lang,
)
await apply_narrator_post(req.session_id, post, rpg_settings, session)
quests = await get_quests(req.session_id)
@@ -319,20 +343,70 @@ async def chat_stream(request: ChatRequest):
pre = {}
directives: list = []
pre_ok = False
needs_check = False
rp_lang = "ru"
story_arc_meta: dict = {}
new_arc_first = normalize_new_arc_first(request.new_arc_first)
skip_character_reply = False
new_arc_injection_text = ""
if session and session.get("rpg_enabled"):
from services.rpg_locale import infer_rp_language
rpg_settings = get_rpg_settings(session)
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
rp_lang = infer_rp_language(history)
try:
arc = json.loads(session.get("plot_arc_json") or "{}")
except Exception:
arc = {}
arc = normalize_story_arc(arc, genre=session.get("genre") or "adventure")
wants_new_arc_roll = is_arc_completed(arc) and (
new_arc_first or is_new_arc_request(request.message)
)
if wants_new_arc_roll:
if not new_arc_first:
new_arc_first = "character"
persona = await get_persona(persona_id) or {}
recent_roll = "\n".join(
f"{m['role']}: {m['content']}" for m in history[-8:]
if m.get("role") in ("user", "assistant")
)
rolled = await roll_next_arc(
request.session_id,
persona,
request.message,
session.get("genre") or "adventure",
lang=rp_lang,
recent_context=recent_roll,
facts_block=facts_block,
)
if rolled and not is_arc_completed(rolled):
arc = rolled
story_arc_meta["new_arc_rolled"] = True
story_arc_meta["new_arc_first"] = new_arc_first
skip_character_reply = new_arc_first == "user"
step0 = get_current_step(arc)
if step0:
new_arc_injection_text = format_new_arc_opening(arc, step0, lang=rp_lang)
mark_injection_shown(arc)
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
arc, _ = await reconcile_story_arc(
request.session_id,
persona_name=(await get_persona(persona_id) or {}).get("name", persona_id),
genre=session.get("genre") or "adventure",
)
session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
quests_list = await get_quests(request.session_id)
narr_ctx = format_narrator_context(
arc, quests_list, session.get("status_quo") or ""
)
if rpg_settings.get("narrator", True):
if rpg_settings.get("narrator", True) and not story_arc_meta.get("new_arc_rolled"):
persona = await get_persona(persona_id) or {}
recent_txt = "\n".join(
f"{m['role']}: {m['content']}" for m in history[-8:]
@@ -347,6 +421,7 @@ async def chat_stream(request: ChatRequest):
facts_block,
request.message,
extra_context=narr_ctx,
lang=rp_lang,
)
pre_ok = bool(pre.get("_ok"))
@@ -373,6 +448,7 @@ async def chat_stream(request: ChatRequest):
roll=roll,
outcome=outcome,
extra_context=narr_ctx,
lang=rp_lang,
)
resolution_text = (pre2.get("resolution_text") or "").strip()
directives = pre2.get("directives") or []
@@ -422,6 +498,28 @@ async def chat_stream(request: ChatRequest):
"---"
)
step = get_current_step(arc)
if step:
narrator_extra += format_step_guidance_for_character(step, arc, lang=rp_lang)
if (
not story_arc_meta.get("new_arc_rolled")
and should_show_step_injection(arc)
):
inj = (step.get("injection") or "").strip()
if inj:
narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
mark_injection_shown(arc)
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
elif story_arc_meta.get("new_arc_rolled") and new_arc_first == "character":
inj = (step.get("injection") or "").strip()
if inj:
narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
cur, total = step_progress(arc)
story_arc_meta["story_step"] = f"{cur}/{total}"
runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra
llm_system = static_prompt + runtime_suffix
@@ -429,7 +527,12 @@ async def chat_stream(request: ChatRequest):
llm_system += RP_OUTPUT_REMINDER
user_message_content = request.message
if request.is_narrator_choice:
if request.is_narrator_choice and new_arc_first:
first_who = "игрок" if new_arc_first == "user" else "персонаж"
user_message_content = (
f"[Player chose: Начать новую арку — первый ход: {first_who}]"
)
elif request.is_narrator_choice:
user_message_content = f"[Player chose: {request.message}]"
await upsert_static_system_message(request.session_id, static_prompt, history)
@@ -438,6 +541,8 @@ async def chat_stream(request: ChatRequest):
if not request.skip_user_add:
await clear_choices_for_session(request.session_id)
user_msg_id = await add_message(request.session_id, "user", user_message_content)
if user_msg_id and session and session.get("rpg_enabled"):
await save_state_snapshot(request.session_id, user_msg_id)
if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id:
await add_action_resolution(
request.session_id,
@@ -449,23 +554,71 @@ async def chat_stream(request: ChatRequest):
)
narrator_msg["user_message_id"] = user_msg_id
if narrator_msg and (narrator_msg.get("text") or "").strip():
await add_message(
narr_id = await add_message(
request.session_id,
"narrator",
narrator_message_content(narrator_msg),
)
if narr_id and session and session.get("rpg_enabled"):
await save_state_snapshot(request.session_id, narr_id)
messages = await get_history(request.session_id)
usage = compute_payload_usage(messages, llm_system)
warn = context_warning_line(usage.get("percent", 0))
if warn:
llm_system += warn
llm_messages = messages_for_llm(messages, llm_system)
llm_messages = messages_for_llm(messages, llm_system, rp_lang=rp_lang)
full_reply = []
async def generate():
nonlocal arc
if new_arc_injection_text:
new_arc_narrator = {"text": new_arc_injection_text}
narr_inj_id = await add_message(
request.session_id,
"narrator",
narrator_message_content(new_arc_narrator),
)
if narr_inj_id and session and session.get("rpg_enabled"):
await save_state_snapshot(request.session_id, narr_inj_id)
yield f"data: {json.dumps({'narrator': new_arc_narrator})}\n\n"
if skip_character_reply:
choices = []
step = get_current_step(arc)
if step and rpg_settings.get("choices", True):
choices += choices_from_step(step)
quests_updated = await get_quests(request.session_id)
updated_session = await get_session(request.session_id) or session
narrator_meta = {
"new_arc_rolled": True,
"new_arc_first": new_arc_first,
"story_step": story_arc_meta.get("story_step", ""),
"rp_language": rp_lang,
}
done_payload = {
"done": True,
"assistant_message_id": None,
"assistant_content": "",
"choices": choices,
"debug": [],
"affinity": updated_session.get("affinity", 0) if updated_session else 0,
"quests": quests_updated if session and session.get("rpg_enabled") else [],
"story_arc": arc if session and session.get("rpg_enabled") else None,
"narrator_meta": narrator_meta,
}
if rpg_settings.get("stats") and updated_session:
done_payload["narrative_stats"] = parse_stats_json(
updated_session.get("narrative_stats_json")
)
if session and session.get("rpg_enabled"):
last_id = await get_last_message_id(request.session_id)
if last_id:
await save_state_snapshot(request.session_id, last_id)
yield f"data: {json.dumps(done_payload)}\n\n"
return
if narrator_msg:
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
@@ -492,8 +645,13 @@ async def chat_stream(request: ChatRequest):
if session and session.get("rpg_enabled"):
try:
if not arc:
if not arc or not arc.get("steps"):
persona = await get_persona(persona_id) or {}
gen_ctx = "\n".join(
f"{m['role']}: {m['content']}"
for m in (await get_history(request.session_id))[-6:]
if m.get("role") in ("user", "assistant")
)
arc = await generate_plot_arc(
persona.get("name", persona_id),
persona.get("description", ""),
@@ -501,6 +659,8 @@ async def chat_stream(request: ChatRequest):
persona.get("first_mes", ""),
facts_block=facts_to_prompt(session.get("facts_json", "[]")),
genre=session.get("genre") or "adventure",
lang=rp_lang,
recent_context=gen_ctx,
)
if arc:
await update_session_plot_arc(
@@ -511,54 +671,16 @@ async def chat_stream(request: ChatRequest):
"text": json.dumps(arc, ensure_ascii=False, indent=2),
})
if rpg_settings.get("quests", True):
await seed_quests_from_arc(request.session_id, arc)
await sync_quest_to_current_step(request.session_id, arc)
quests_list = await get_quests(request.session_id)
if arc:
beat_ctx = "\n".join(
f"{m['role']}: {m['content']}"
for m in (await get_history(request.session_id))[-6:]
if m.get("role") in ("user", "assistant")
)
arc, beats, pruned, beat_mode = await process_arc_beats(
arc,
quests_list,
request.message,
recent_context=beat_ctx,
last_dice_outcome=outcome if roll is not None else None,
)
if pruned or beats:
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
if pruned:
debug_blocks.append({
"type": "plot_arc_prune",
"text": f"Removed {len(pruned)} beat(s) already completed as quests",
})
if beats:
inj = beats[0].get("injection", "")
if inj:
debug_blocks.append({"type": "narrator_injection", "text": inj})
if rpg_settings.get("choices", True):
choices += choices_from_beat(beats[0])
if beat_mode in ("after_dice", "llm", "trigger", "stuck_recovery"):
debug_blocks.append({
"type": "plot_arc",
"text": (
f"Beat fired ({beat_mode}): "
f"«{beats[0].get('title', '')}»"
),
})
if advance_phase(arc):
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
if pruned and not arc.get("beats"):
narrator_meta["arc_pruned"] = len(pruned)
if beat_mode:
narrator_meta["beat_mode"] = beat_mode
arc = normalize_story_arc(
arc, genre=session.get("genre") or "adventure"
)
cur, total = step_progress(arc)
narrator_meta["story_step"] = f"{cur}/{total}"
narrator_meta["rp_language"] = rp_lang
if story_arc_meta.get("new_arc_rolled"):
narrator_meta["new_arc_rolled"] = True
ctx = [
m for m in (await get_history(request.session_id))
@@ -598,6 +720,7 @@ async def chat_stream(request: ChatRequest):
json.dumps(arc, ensure_ascii=False) if arc else "",
facts_to_prompt(session.get("facts_json", "[]")),
extra_context=narr_ctx_post,
lang=rp_lang,
)
sq = (post.get("status_quo_update") or "").strip()
@@ -607,9 +730,11 @@ async def chat_stream(request: ChatRequest):
if rpg_settings.get("choices", True):
choices += choices_from_narrator(post.get("choices") or [])
applied = await apply_narrator_post(
request.session_id, post, rpg_settings, session
applied = await apply_narrator_post_with_story(
request.session_id, post, rpg_settings, session, arc=arc
)
if applied.get("arc"):
arc = applied["arc"]
narrator_meta = {
"pre_ok": pre_ok,
"post_ok": bool(post.get("_ok")),
@@ -619,26 +744,27 @@ async def chat_stream(request: ChatRequest):
**applied,
}
if not arc.get("beats"):
persona = await get_persona(persona_id) or {}
arc = await replenish_arc_beats(
arc,
persona.get("name", persona_id),
ctx_txt,
await get_quests(request.session_id),
session.get("genre") or "adventure",
)
if arc.get("beats"):
await update_session_plot_arc(
request.session_id, json.dumps(arc, ensure_ascii=False)
)
debug_blocks.append({
"type": "plot_arc",
"text": f"Added {len(arc.get('beats', []))} new plot beats",
})
narrator_meta["beats_replenished"] = len(arc.get("beats", []))
if rpg_settings.get("quests", True):
await seed_quests_from_arc(request.session_id, arc)
if applied.get("step_advanced"):
# Drop stale narrator/plot choices from the step we just left;
# injection is shown once in the plot choice panel (beat_injection).
choices = [c for c in choices if c.get("type") == "new_arc_roll"]
new_step = get_current_step(arc)
if new_step and rpg_settings.get("choices", True):
choices += choices_from_step(new_step)
debug_blocks.append({
"type": "plot_arc",
"text": f"Step advanced: «{applied.get('new_step_title', '')}»",
})
narrator_meta["choices_count"] = len(choices)
if applied.get("arc_completed"):
debug_blocks.append({
"type": "plot_arc",
"text": "Story arc completed — new arc available",
})
if is_arc_completed(arc) and rpg_settings.get("choices", True):
choices = append_new_arc_roll_choice(choices, lang=rp_lang)
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
from services.outfit_tags import outfit_list_to_json
@@ -705,7 +831,8 @@ async def chat_stream(request: ChatRequest):
"choices": choices,
"debug": debug_blocks,
"affinity": affinity,
"quests": quests_updated,
"quests": quests_updated if session and session.get("rpg_enabled") else [],
"story_arc": arc if session and session.get("rpg_enabled") else None,
"narrator_meta": narrator_meta,
}
if rpg_settings.get("stats") and updated_session:
@@ -713,6 +840,11 @@ async def chat_stream(request: ChatRequest):
updated_session.get("narrative_stats_json")
)
if session and session.get("rpg_enabled"):
snap_id = msg_id or await get_last_message_id(request.session_id)
if snap_id:
await save_state_snapshot(request.session_id, snap_id)
yield f"data: {json.dumps(done_payload)}\n\n"
return StreamingResponse(
+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
+74
View File
@@ -463,6 +463,73 @@ header h1 { font-size: 1.1rem; color: #e94560; }
background: rgba(201, 162, 39, 0.2);
}
.choice-row-new-arc {
margin-top: 12px;
}
.new-arc-roll {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(233, 69, 96, 0.45);
background: linear-gradient(135deg, rgba(233, 69, 96, 0.12), rgba(201, 162, 39, 0.1));
}
.new-arc-roll-header {
font-size: 1rem;
font-weight: 600;
color: #f5d0d8;
margin-bottom: 6px;
}
.new-arc-roll-hint {
font-size: 0.78rem;
color: #b8b0c8;
line-height: 1.4;
margin-bottom: 12px;
}
.new-arc-roll-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(15, 52, 96, 0.9);
}
.new-arc-half {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 72px;
padding: 12px 10px;
border: none;
background: #16213e;
color: #ddd;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.new-arc-half-user {
border-right: 1px solid rgba(15, 52, 96, 0.9);
}
.new-arc-half:hover {
background: rgba(233, 69, 96, 0.22);
color: #fff;
}
.new-arc-half-icon {
font-size: 1.25rem;
line-height: 1;
}
.new-arc-half-title {
font-size: 0.92rem;
font-weight: 600;
}
.new-arc-half-sub {
font-size: 0.72rem;
color: #9aa3b8;
}
.new-arc-half:hover .new-arc-half-sub {
color: #e8edf7;
}
.typing {
align-self: flex-start;
@@ -763,6 +830,13 @@ textarea:focus { border-color: #e94560; }
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 4px;
}
.quest-panel-arc-header {
font-size: 0.72rem;
color: #c9a227;
margin-bottom: 6px;
line-height: 1.35;
}
.quest-panel-arc-header.hidden { display: none; }
.quest-panel-hint {
font-size: 0.68rem;
color: #555;
+1
View File
@@ -28,6 +28,7 @@
<div class="session-list" id="sessionList"></div>
<div class="quest-panel hidden" id="questPanel">
<div class="quest-panel-header">Квесты</div>
<div class="quest-panel-arc-header hidden" id="questPanelHeader"></div>
<p class="quest-panel-hint" id="questPanelHint">Клик по 🔸 — выбор, затем кнопка ниже</p>
<div id="questList"></div>
<div class="quest-panel-actions" id="questPanelActions">
+111 -16
View File
@@ -154,12 +154,63 @@ export function ensureMessageActionsLast(wrapper) {
if (actions) wrapper.appendChild(actions);
}
function renderNewArcRollButton(wrapper, choice) {
removeChoiceRows(wrapper);
const row = document.createElement('div');
row.className = 'choice-row choice-row-new-arc';
const panel = document.createElement('div');
panel.className = 'new-arc-roll';
const hdr = document.createElement('div');
hdr.className = 'new-arc-roll-header';
hdr.textContent = choice?.label || 'Начать новую арку';
panel.appendChild(hdr);
const hint = document.createElement('div');
hint.className = 'new-arc-roll-hint';
hint.textContent = 'Арка завершена. Можно продолжить эпилог или начать новую — кто ходит первым после инъекта рассказчика?';
panel.appendChild(hint);
const split = document.createElement('div');
split.className = 'new-arc-roll-split';
const mkHalf = (side, icon, title, subtitle, first) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `new-arc-half new-arc-half-${side}`;
btn.innerHTML = `<span class="new-arc-half-icon">${icon}</span><span class="new-arc-half-title">${title}</span><span class="new-arc-half-sub">${subtitle}</span>`;
btn.addEventListener('click', () => {
removeChoiceRows(wrapper);
sendMessage(choice?.label || 'Начать новую арку', {
isNarratorChoice: true,
newArcFirst: first,
});
});
return btn;
};
split.appendChild(mkHalf('user', '👤', 'Игрок', 'первый ход после инъекта', 'user'));
split.appendChild(mkHalf('character', '🤖', 'Персонаж', 'открывает новую арку', 'character'));
panel.appendChild(split);
row.appendChild(panel);
wrapper.appendChild(row);
ensureMessageActionsLast(wrapper);
}
export function renderChoices(wrapper, choices) {
if (!choices?.length || !wrapper) return;
removeChoiceRows(wrapper);
const plotChoices = choices.filter(c => c?.source === 'plot_beat');
const otherChoices = choices.filter(c => c?.source !== 'plot_beat');
const newArc = choices.find(c => c?.type === 'new_arc_roll');
if (newArc) {
renderNewArcRollButton(wrapper, newArc);
return;
}
const isPlot = c => c?.source === 'plot_beat' || c?.source === 'plot_step';
const plotChoices = choices.filter(isPlot);
const otherChoices = choices.filter(c => !isPlot(c));
const row = document.createElement('div');
row.className = 'choice-row';
@@ -167,7 +218,7 @@ export function renderChoices(wrapper, choices) {
const appendBtn = (container, c) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = c.source === 'plot_beat' ? 'choice-btn choice-btn-plot' : 'choice-btn';
btn.className = isPlot(c) ? 'choice-btn choice-btn-plot' : 'choice-btn';
const label = c.label || '';
btn.textContent = label;
if (c.beat_title) btn.title = c.beat_title;
@@ -251,10 +302,11 @@ function showNarratorActivityHint(wrapper, meta) {
if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`);
if (meta.status_quo) parts.push('🌍 status_quo');
if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`);
if (meta.beat_mode === 'after_dice') parts.push('📜 beat (d20)');
if (meta.beat_mode === 'llm') parts.push('📜 beat (AI)');
if (meta.beat_mode === 'stuck_recovery') parts.push('📜 beat (recovery)');
if (meta.beat_mode === 'trigger') parts.push('📜 beat (keywords)');
if (meta.step_advanced) parts.push('📜 шаг +1');
if (meta.arc_completed) parts.push('🏁 арка завершена');
if (meta.new_arc_rolled) parts.push('📖 новая арка');
if (meta.story_step) parts.push(`📜 ${meta.story_step}`);
if (meta.rp_language) parts.push(`🌐 ${meta.rp_language}`);
if (meta.arc_pruned) parts.push(`🧹 ${meta.arc_pruned} beat`);
if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`);
}
@@ -299,13 +351,27 @@ function syncQuestActionButtons() {
if (failBtn) failBtn.disabled = !active;
}
export function updateQuestPanel(quests) {
export function updateQuestPanel(quests, storyArc = null) {
const list = document.getElementById('questList');
const actions = document.getElementById('questPanelActions');
const header = document.getElementById('questPanelHeader');
if (!list) return;
_questsCache = quests || [];
list.innerHTML = '';
if (header && storyArc) {
const steps = storyArc.steps || [];
const idx = Number(storyArc.current_step_index ?? 0);
const cur = steps.length ? Math.min(idx + 1, steps.length) : 0;
const title = storyArc.title || 'Арка';
const status = storyArc.status === 'completed' ? ' · завершена' : '';
header.textContent = `Арка: ${title} · шаг ${cur}/${steps.length || '?'}${status}`;
header.classList.remove('hidden');
} else if (header) {
header.textContent = '';
header.classList.add('hidden');
}
if (!_questsCache.length) {
_selectedQuestId = null;
syncQuestActionButtons();
@@ -577,7 +643,10 @@ export async function reloadChatFromServer(id) {
try {
const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
if (data?.text) renderNarratorMessage(data);
} catch { /* ignore bad narrator payload */ }
} catch {
const plain = (m.content || '').trim();
if (plain) renderNarratorMessage({ text: plain });
}
return;
}
addMessage(
@@ -694,8 +763,11 @@ async function consumeStream(res) {
data.assistant_message_id,
data.choices,
);
} else if (data.choices?.length && bubble) {
renderChoices(bubble.parentElement, data.choices);
} else if (data.choices?.length) {
const choiceHost = bubble?.parentElement
|| [...dom.messagesEl.querySelectorAll('.message.narrator')].pop()
|| [...dom.messagesEl.querySelectorAll('.message.assistant')].pop();
if (choiceHost) renderChoices(choiceHost, data.choices);
}
if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
if (data.narrator_meta && bubble?.parentElement) {
@@ -703,7 +775,9 @@ async function consumeStream(res) {
}
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (data.quests?.length) updateQuestPanel(data.quests);
if (data.quests !== undefined) {
updateQuestPanel(data.quests, data.story_arc ?? null);
}
_pendingUserBubble = null;
@@ -822,21 +896,42 @@ export function clearMessages() {
}
}
export async function sendMessage(text, isNarratorChoice = false) {
export async function sendMessage(text, choiceOpts = false) {
if (typeof text !== 'string') text = dom.inputEl.value.trim();
if (!text || !sessionId) return;
let isNarratorChoice = false;
let newArcFirst = null;
if (typeof choiceOpts === 'boolean') {
isNarratorChoice = choiceOpts;
} else if (choiceOpts && typeof choiceOpts === 'object') {
isNarratorChoice = !!choiceOpts.isNarratorChoice;
newArcFirst = choiceOpts.newArcFirst || null;
}
dom.inputEl.value = '';
dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true;
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text;
dom.messagesEl.querySelectorAll('.message.assistant').forEach(w => removeChoiceRows(w));
let userContent = text;
if (isNarratorChoice && newArcFirst) {
const who = newArcFirst === 'user' ? 'игрок' : 'персонаж';
userContent = `[Player chose: Начать новую арку — первый ход: ${who}]`;
} else if (isNarratorChoice) {
userContent = `[Player chose: ${text}]`;
}
dom.messagesEl.querySelectorAll('.message.assistant, .message.narrator').forEach(w => removeChoiceRows(w));
_pendingUserBubble = addMessage('user', userContent);
showTyping();
try {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }),
body: JSON.stringify({
message: text,
session_id: sessionId,
is_narrator_choice: isNarratorChoice,
new_arc_first: newArcFirst,
}),
});
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
removeTyping();
+1 -1
View File
@@ -80,7 +80,7 @@ async function bootstrapRpg(sid, personaId, genreValue, settings) {
const { updateAffinityDisplay } = await import('./chat.js');
updateAffinityDisplay(data.affinity);
}
if (data.quests) updateQuestPanel(data.quests);
if (data.quests) updateQuestPanel(data.quests, data.plot_arc ?? null);
if (data.plot_arc) {
const title = data.plot_arc.title || '';
const hint = data.plot_arc.next_beat_hint || '';
+1 -1
View File
@@ -225,7 +225,7 @@ export async function createNewChatFromWizard() {
await reloadChatFromServer(sid);
if (openingData?.quests?.length) {
updateQuestPanel(openingData.quests);
updateQuestPanel(openingData.quests, openingData.plot_arc ?? null);
}
if (openingData?.affinity !== undefined) {
updateAffinityDisplay(openingData.affinity);
+130 -17
View File
@@ -1,4 +1,5 @@
import asyncio
from unittest.mock import AsyncMock, patch
from services.rpg_plot import (
prune_beats_for_done_quests,
@@ -6,6 +7,10 @@ from services.rpg_plot import (
should_advance_arc_keywords,
pop_matching_beats,
dice_outcome_to_beat_trigger,
active_quest_titles_to_close,
can_fire_beat,
record_beat_fired,
complete_quest_for_fired_beat,
)
@@ -21,21 +26,60 @@ def test_prune_removes_beat_when_quest_done():
assert arc["beats"] == []
def test_stuck_recovery_fires_when_no_active_quests():
def test_stuck_recovery_fires_when_no_active_quests_and_cooldown_ok():
arc = {
"beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
]
],
"meta": {"last_beat_fired_at_user_turn": 1},
}
quests = [{"title": "Old", "status": "done"}]
async def run():
return await process_arc_beats(arc, quests, "hello")
return await process_arc_beats(arc, quests, "hello", user_turn=5)
arc2, fired, pruned, mode = asyncio.run(run())
arc2, fired, pruned, mode, extras = asyncio.run(run())
assert mode == "stuck_recovery"
assert fired[0]["title"] == "New Beat"
assert arc2["beats"] == []
assert extras.get("cooldown_skipped") is False
def test_stuck_recovery_skipped_when_cooldown_active():
arc = {
"beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
],
"meta": {"last_beat_fired_at_user_turn": 4},
}
quests = []
async def run():
return await process_arc_beats(arc, quests, "hello", user_turn=5)
arc2, fired, _, mode, extras = asyncio.run(run())
assert mode == ""
assert fired == []
assert extras.get("cooldown_skipped") is True
assert len(arc2["beats"]) == 1
def test_stuck_recovery_skipped_after_reconcile_closed_quests():
arc = {
"beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
],
}
quests = []
async def run():
return await process_arc_beats(
arc, quests, "hello", user_turn=10, reconcile_closed_count=2
)
_, fired, _, mode, _ = asyncio.run(run())
assert mode == ""
assert fired == []
def test_dice_outcome_maps_to_after_fail():
@@ -44,7 +88,7 @@ def test_dice_outcome_maps_to_after_fail():
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
def test_after_fail_beat_fires_on_dice_failure():
def test_after_fail_beat_fires_on_dice_failure_only_with_needs_check():
arc = {
"beats": [
{
@@ -54,25 +98,94 @@ def test_after_fail_beat_fires_on_dice_failure():
"injection": "The stumble leaves you both shaken.",
"choices": [{"id": "a", "label": "Try again"}],
},
]
}
async def run(needs_check: bool):
return await process_arc_beats(
arc, [], "продолжаем", last_dice_outcome="failure", needs_check=needs_check, user_turn=3
)
arc_ok, fired, _, mode, _ = asyncio.run(run(True))
assert mode == "after_dice"
assert fired[0]["id"] == "b_fail"
assert arc_ok["beats"] == []
arc2 = {
"beats": [
{
"id": "b_ok",
"title": "Victory Lap",
"trigger": "event_driven:after_success",
"id": "b_fail",
"title": "Dust Yourself Off",
"trigger": "event_driven:after_fail",
"choices": [],
},
]
}
async def run():
return await process_arc_beats(
arc, [], "продолжаем разговор", last_dice_outcome="failure"
)
async def run_no_check():
with patch(
"services.rpg_plot.classify_plot_beat",
new_callable=AsyncMock,
return_value=None,
):
return await process_arc_beats(
arc2,
[],
"продолжаем",
last_dice_outcome="failure",
needs_check=False,
user_turn=3,
allow_stuck_recovery=False,
)
arc2, fired, _, mode = asyncio.run(run())
assert mode == "after_dice"
assert fired[0]["id"] == "b_fail"
assert len(arc2["beats"]) == 1
assert arc2["beats"][0]["id"] == "b_ok"
_, fired2, _, mode2, _ = asyncio.run(run_no_check())
assert mode2 == ""
assert fired2 == []
def test_orphan_active_quests_closed_when_not_in_arc():
arc = {
"beats": [
{
"id": "b_new_3",
"title": "Defensive Magic",
"trigger": "event_driven:after_fail",
"choices": [],
}
]
}
quests = [
{"title": "Floral Whispers", "status": "active"},
{"title": "Gift from the Glade", "status": "active"},
{"title": "Defensive Magic", "status": "active"},
{"title": "Old Done", "status": "done"},
]
closed = active_quest_titles_to_close(arc, quests)
assert set(closed) == {"Floral Whispers", "Gift from the Glade"}
def test_can_fire_beat_respects_min_gap():
arc = {"meta": {"last_beat_fired_at_user_turn": 5}}
assert can_fire_beat(arc, 6, min_gap=2) is False
assert can_fire_beat(arc, 7, min_gap=2) is True
def test_record_beat_fired_updates_meta():
arc = {}
record_beat_fired(arc, {"id": "b1"}, 8)
assert arc["meta"]["last_beat_fired_at_user_turn"] == 8
assert arc["meta"]["last_beat_id"] == "b1"
def test_complete_quest_for_fired_beat():
beat = {"id": "b1", "title": "Pancake Talk", "injection": "..."}
async def run():
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as mock:
await complete_quest_for_fired_beat("sess-1", beat)
mock.assert_called_once_with("sess-1", "Pancake Talk", "done")
asyncio.run(run())
def test_keyword_fallback_travel():
+13 -3
View File
@@ -29,7 +29,7 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
},
{"role": "narrator", "content": narrator_json},
]
llm = messages_for_llm(history, "system+runtime")
llm = messages_for_llm(history, "system+runtime", rp_lang="en")
user_msgs = [m for m in llm if m["role"] == "user"]
assert len(user_msgs) == 2
assert "canonical outcome" in user_msgs[0]["content"].lower()
@@ -39,14 +39,24 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
assert "Do NOT write a success version" in user_msgs[1]["content"]
def test_format_narrator_failure_wording():
def test_format_narrator_failure_wording_en():
text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "It failed."}
{"roll": 5, "outcome": "failure", "text": "It failed."},
lang="en",
)
assert "FAILED" in text
assert "Do NOT write a success version" in text
def test_format_narrator_failure_wording_ru():
text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "Не вышло."},
lang="ru",
)
assert "ОБЯЗАТЕЛЬНО" in text
assert "Не удалось" in text or "НЕ удалось" in text
def test_parse_narrator_message_roundtrip():
raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"})
data = parse_narrator_message(raw)
+26 -2
View File
@@ -1,4 +1,9 @@
from services.rpg_plot import choices_from_beat, choices_from_narrator, normalize_choice
from services.rpg_plot import (
choices_from_beat,
choices_from_narrator,
normalize_choice,
format_beat_injection_for_character,
)
def test_choices_from_beat_tags_source():
@@ -13,7 +18,7 @@ def test_choices_from_beat_tags_source():
}
out = choices_from_beat(beat)
assert len(out) == 2
assert out[0]["source"] == "plot_beat"
assert out[0]["source"] == "plot_step"
assert out[0]["beat_title"] == "Rest Stop Confession"
assert out[0]["beat_id"] == "b_new_1"
assert "beat_injection" in out[0]
@@ -25,5 +30,24 @@ def test_choices_from_narrator_tags_source():
assert "beat_title" not in out[0]
def test_beat_injection_block_for_character_prompt():
block = format_beat_injection_for_character(
"Over pancakes, Luna steals glances at you.",
lang="en",
)
assert "Plot hint" in block
assert "Over pancakes" in block
assert format_beat_injection_for_character("") == ""
def test_beat_injection_block_ru():
block = format_beat_injection_for_character(
"Луна крадёт взгляды через блины.",
lang="ru",
)
assert "Сюжетная подсказка" in block
assert "по-русски" in block
def test_normalize_choice_skips_empty_label():
assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None
+31
View File
@@ -1,10 +1,14 @@
from services.rpg_facts import (
FACTS_COMPRESS_SYSTEM,
merge_facts,
parse_facts_list,
facts_to_prompt,
facts_list_to_json,
dedupe_facts_fuzzy,
facts_are_similar,
is_likely_narrative_event,
filter_durable_facts,
validate_compressed_against_source,
)
@@ -34,6 +38,33 @@ def test_dedupe_collapses_duplicates():
assert len(out) == 2
def test_facts_compress_system_format_escapes_json_braces():
system = FACTS_COMPRESS_SYSTEM.format(target=22)
assert "Target at most 22 facts" in system
assert '{"text":' in system
def test_narrative_event_facts_filtered():
assert is_likely_narrative_event("Сара и Григо отправились через портал")
assert not is_likely_narrative_event("Сара носит красный спортивный костюм")
facts = filter_durable_facts([
{"text": "Сара любит шоколад", "rp_day": "день 1"},
{"text": "Сара и Григо решили вызвать СОДП", "rp_day": "день 2"},
])
assert len(facts) == 1
def test_validate_compressed_rejects_hallucinations():
original = [{"text": "Сара любит шоколад", "rp_day": "день 1"}]
compressed = [
{"text": "Сара любит шоколад", "rp_day": "день 1"},
{"text": "Сара — королева галактики", "rp_day": "день 9"},
]
out = validate_compressed_against_source(original, compressed)
assert len(out) == 1
assert "шоколад" in out[0]["text"]
def test_merge_with_rp_day():
existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}])
merged = parse_facts_list(
+22
View File
@@ -0,0 +1,22 @@
from services.rpg_locale import infer_rp_language, locale_instruction
def test_infer_ru_from_cyrillic_chat():
msgs = [
{"role": "user", "content": "Привет, как дела?"},
{"role": "assistant", "content": "Нормально, идём дальше."},
]
assert infer_rp_language(msgs) == "ru"
def test_infer_en_from_latin_chat():
msgs = [
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "Fine, let's continue."},
]
assert infer_rp_language(msgs) == "en"
def test_locale_instruction_ru():
assert "Russian" in locale_instruction("ru")
assert "MUST be in Russian" in locale_instruction("ru")
+195
View File
@@ -0,0 +1,195 @@
import asyncio
import json
from unittest.mock import AsyncMock, patch
from services.rpg_story import (
migrate_beats_to_steps,
normalize_story_arc,
get_current_step,
step_progress,
is_arc_completed,
sync_quest_to_current_step,
apply_step_advance,
apply_story_post,
is_new_arc_request,
normalize_new_arc_first,
new_arc_roll_choice,
append_new_arc_roll_choice,
format_step_guidance_for_character,
format_new_arc_opening,
normalize_step,
resolve_step_injection,
roll_next_arc,
)
def test_migrate_beats_to_steps():
arc = {
"beats": [
{"id": "b1", "title": "Meet Yuki", "injection": "Door opens.", "choices": []},
]
}
out = migrate_beats_to_steps(arc)
assert len(out["steps"]) == 1
assert "beats" not in out
assert out["steps"][0]["title"] == "Meet Yuki"
def test_step_progress():
arc = normalize_story_arc({
"steps": [{"id": "s1", "title": "A"}, {"id": "s2", "title": "B"}],
"current_step_index": 1,
})
assert step_progress(arc) == (2, 2)
def test_is_arc_completed():
arc = normalize_story_arc({
"steps": [{"id": "s1", "title": "A"}],
"current_step_index": 1,
"status": "active",
})
assert is_arc_completed(arc)
def test_new_arc_request_detected():
assert is_new_arc_request("Начать новую арку")
assert is_new_arc_request("Начать новый квест: 'Тайна плазменных шаров'")
assert is_new_arc_request("Start new quest")
assert not is_new_arc_request("привет")
def test_normalize_new_arc_first():
assert normalize_new_arc_first("user") == "user"
assert normalize_new_arc_first("Character") == "character"
assert normalize_new_arc_first("other") is None
def test_new_arc_roll_choice_and_filter():
base = new_arc_roll_choice()
assert base["type"] == "new_arc_roll"
merged = append_new_arc_roll_choice([
{"id": "x", "label": "Начать новый квест: Foo", "source": "narrator"},
{"id": "y", "label": "Пойти спать", "source": "narrator"},
])
assert len(merged) == 2
assert merged[-1]["type"] == "new_arc_roll"
assert not any("новый квест" in (c.get("label") or "").lower() for c in merged[:-1])
def test_normalize_step_repairs_legacy_beat_shape():
step = normalize_step({
"status_quo": "Сара и Григо прибывают к лаборатории.",
"choices": [{"label": "Войти", "injection": "*Дверь скрипит*"}],
}, 0)
assert step["title"]
assert step["completion_criteria"]
assert resolve_step_injection(step) == "*Дверь скрипит*"
def test_format_new_arc_opening():
arc = {"title": "Тайна", "global_story": "Расследование шаров."}
step = {"status_quo": "У ворот лаборатории тихо.", "choices": []}
text = format_new_arc_opening(arc, step, lang="ru")
assert "Тайна" in text
assert "лаборатории" in text or "Расследование" in text
def test_guidance_block_ru():
step = {"title": "Приютить", "goal": "Впустить внутрь", "character_guidance": "Робость"}
block = format_step_guidance_for_character(step, {"global_story": "x", "steps": [step]}, lang="ru")
assert "Приютить" in block
assert "ОБЯЗАТЕЛЬНОЕ" in block
def test_apply_step_advance():
arc = normalize_story_arc({
"title": "Test",
"steps": [
{"id": "s1", "title": "Step One", "choices": []},
{"id": "s2", "title": "Step Two", "injection": "Next scene.", "choices": []},
],
"current_step_index": 0,
})
async def run():
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as uq:
with patch("services.memory.update_session_plot_arc", new_callable=AsyncMock):
with patch(
"services.rpg_story.sync_quest_to_current_step",
new_callable=AsyncMock,
):
return await apply_step_advance("sess", arc), uq
result, mock_uq = asyncio.run(run())
assert result["advanced"] is True
assert result["injection"] == "Next scene."
mock_uq.assert_called()
def test_apply_story_post_step_complete_fallback_via_note():
arc = normalize_story_arc({
"title": "Test",
"steps": [
{"id": "s1", "title": "Step One", "choices": []},
{"id": "s2", "title": "Step Two", "choices": []},
],
"current_step_index": 0,
})
post = {
"step_complete": False,
"step_completion_note": "The scene clearly satisfies the completion criteria.",
}
async def run():
with patch("services.memory.get_session", new_callable=AsyncMock) as gs:
gs.return_value = {"plot_arc_json": json.dumps(arc, ensure_ascii=False)}
with patch(
"services.rpg_story.apply_step_advance",
new_callable=AsyncMock,
) as asa:
asa.return_value = {"advanced": True, "arc_completed": False, "new_step": arc["steps"][1], "injection": ""}
r = await apply_story_post("sess", post, arc, {"quests": True})
return r, asa
r, asa = asyncio.run(run())
assert r["step_advanced"] is True
assert asa.await_count == 1
def test_roll_next_arc_combines_context_for_generate_next_arc():
old_arc = normalize_story_arc({
"title": "Arc One",
"steps": [{"id": "s1", "title": "Done"}],
"current_step_index": 1,
"status": "completed",
})
new_arc = normalize_story_arc({
"title": "Arc Two",
"steps": [{"id": "s1", "title": "Start"}],
"current_step_index": 0,
})
async def run():
with patch("services.memory.get_session", new_callable=AsyncMock) as gs:
gs.return_value = {"plot_arc_json": json.dumps(old_arc, ensure_ascii=False)}
with patch("services.rpg_plot.generate_next_arc", new_callable=AsyncMock) as gna:
gna.return_value = new_arc
with patch("services.memory.update_session_plot_arc", new_callable=AsyncMock):
with patch(
"services.rpg_story.sync_quest_to_current_step",
new_callable=AsyncMock,
):
return await roll_next_arc(
"sess",
{"name": "Sarah", "description": "", "scenario": ""},
"Начать новую арку",
"adventure",
recent_context="assistant: goodbye",
), gna
result, gna = asyncio.run(run())
assert result["title"] == "Arc Two"
gna.assert_awaited_once()
assert gna.await_args.args[3] == "assistant: goodbye\nНачать новую арку"
+80
View File
@@ -0,0 +1,80 @@
import asyncio
import json
import pytest
import database.db as dbmod
import services.memory as memory
@pytest.fixture
def snapshot_db(tmp_path, monkeypatch):
db_file = tmp_path / "snapshots.db"
monkeypatch.setenv("DB_PATH", str(db_file))
monkeypatch.setattr(dbmod, "DB_PATH", str(db_file))
monkeypatch.setattr(memory, "DB_PATH", str(db_file))
asyncio.run(dbmod.init_db())
return str(db_file)
def test_snapshot_save_restore_and_delete_rollback(snapshot_db):
asyncio.run(_test_snapshot_save_restore_and_delete_rollback())
async def _test_snapshot_save_restore_and_delete_rollback():
sid = "sess_snap_test"
await memory.get_or_create_session(sid, "default")
await memory.update_session_rpg(sid, True)
await memory.update_session_facts(sid, json.dumps([{"text": "Fact A", "rp_day": "день 1"}]))
await memory.update_session_plot_arc(
sid, json.dumps({"title": "Arc", "steps": [{"id": "s1", "title": "One"}], "current_step_index": 0})
)
await memory.set_session_affinity(sid, 3)
await memory.upsert_quest(sid, "Quest one", "active")
u1 = await memory.add_message(sid, "user", "hello")
await memory.save_state_snapshot(sid, u1)
await memory.update_session_facts(sid, json.dumps([{"text": "Fact B", "rp_day": "день 2"}]))
await memory.set_session_affinity(sid, 9)
a1 = await memory.add_message(sid, "assistant", "hi")
await memory.save_state_snapshot(sid, a1)
session = await memory.get_session(sid)
assert json.loads(session["facts_json"])[0]["text"] == "Fact B"
assert session["affinity"] == 9
await memory.delete_message_and_following(sid, a1)
session = await memory.get_session(sid)
assert json.loads(session["facts_json"])[0]["text"] == "Fact A"
assert session["affinity"] == 3
quests = await memory.get_quests(sid)
assert len(quests) == 1
def test_fork_uses_snapshot_not_current_state(snapshot_db):
asyncio.run(_test_fork_uses_snapshot_not_current_state())
async def _test_fork_uses_snapshot_not_current_state():
sid = "sess_fork_src"
await memory.get_or_create_session(sid, "default")
await memory.update_session_rpg(sid, True)
u1 = await memory.add_message(sid, "user", "start")
await memory.update_session_facts(sid, json.dumps([{"text": "Early", "rp_day": "1"}]))
await memory.save_state_snapshot(sid, u1)
a1 = await memory.add_message(sid, "assistant", "reply")
await memory.update_session_facts(sid, json.dumps([{"text": "Late", "rp_day": "2"}]))
await memory.save_state_snapshot(sid, a1)
new_id = await memory.fork_session(sid, u1)
assert new_id
forked = await memory.get_session(new_id)
assert json.loads(forked["facts_json"])[0]["text"] == "Early"
hist = await memory.get_history(new_id)
roles = [m["role"] for m in hist if m["role"] != "system"]
assert roles == ["user"]
+2
View File
@@ -0,0 +1,2 @@
# generated module - copied to services/rpg_story.py by apply script
PLACEHOLDER = True
+2
View File
@@ -0,0 +1,2 @@
"""Apply linear story refactor — run once: python tools/apply_linear_story.py"""
print("placeholder")
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
Path("../services/rpg_context.py").write_text('''"""Shared context blocks for RPG narrator / plot LLM calls."""
import json
from services.rpg_story import (
format_step_for_narrator,
normalize_story_arc,
step_progress,
)
def format_narrator_context(
arc: dict | None,
quests: list | None,
status_quo: str = "",
) -> str:
arc = normalize_story_arc(arc or {}) if arc else {}
return format_step_for_narrator(arc, quests, status_quo)
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,
)
''', encoding='utf-8')
print('done')