From 01b16dbeaa83ff887b7065c0ee5c79f300e4c5d2 Mon Sep 17 00:00:00 2001 From: grigo Date: Fri, 5 Jun 2026 14:57:15 +0300 Subject: [PATCH] new RPG system --- RPG_FLOW.md | 25 +- _bootstrap.py | 1 + database/db.py | 26 ++ models/schemas.py | 3 + routers/chat.py | 306 ++++++++++++----- services/memory.py | 328 ++++++++++++++++-- services/opening.py | 24 +- services/rpg_context.py | 65 ++-- services/rpg_facts.py | 66 +++- services/rpg_locale.py | 51 +++ services/rpg_narrator.py | 35 +- services/rpg_plot.py | 365 ++++++++++++++++---- services/rpg_state.py | 78 +++-- services/rpg_story.py | 567 +++++++++++++++++++++++++++++++ static/css/app.css | 74 ++++ static/index.html | 1 + static/js/chat.js | 127 ++++++- static/js/chatSettings.js | 2 +- static/js/newChatWizard.js | 2 +- tests/test_arc_stuck_recovery.py | 147 +++++++- tests/test_dice_llm_payload.py | 16 +- tests/test_plot_choices.py | 28 +- tests/test_rpg_facts.py | 31 ++ tests/test_rpg_locale.py | 22 ++ tests/test_rpg_story.py | 195 +++++++++++ tests/test_session_snapshots.py | 80 +++++ tools/_rpg_story_impl.py | 2 + tools/apply_linear_story.py | 2 + tools/patch_rpg_context.py | 37 ++ 29 files changed, 2395 insertions(+), 311 deletions(-) create mode 100644 _bootstrap.py create mode 100644 services/rpg_locale.py create mode 100644 services/rpg_story.py create mode 100644 tests/test_rpg_locale.py create mode 100644 tests/test_rpg_story.py create mode 100644 tests/test_session_snapshots.py create mode 100644 tools/_rpg_story_impl.py create mode 100644 tools/apply_linear_story.py create mode 100644 tools/patch_rpg_context.py diff --git a/RPG_FLOW.md b/RPG_FLOW.md index c552495..f57dc85 100644 --- a/RPG_FLOW.md +++ b/RPG_FLOW.md @@ -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 | diff --git a/_bootstrap.py b/_bootstrap.py new file mode 100644 index 0000000..3bc7772 --- /dev/null +++ b/_bootstrap.py @@ -0,0 +1 @@ +print('bootstrap') diff --git a/database/db.py b/database/db.py index d753ebb..c4e4b91 100644 --- a/database/db.py +++ b/database/db.py @@ -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()} diff --git a/models/schemas.py b/models/schemas.py index 648241c..4f788da 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -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): diff --git a/routers/chat.py b/routers/chat.py index 0d83d10..5f0d0c0 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -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"): + new_step = get_current_step(arc) + if new_step: + inj = (new_step.get("injection") or "").strip() + if inj: + debug_blocks.append({"type": "narrator_injection", "text": inj}) + if 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', '')}»", + }) + + 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( diff --git a/services/memory.py b/services/memory.py index b90ebc5..188eddf 100644 --- a/services/memory.py +++ b/services/memory.py @@ -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): diff --git a/services/opening.py b/services/opening.py index 6a175fd..fef9605 100644 --- a/services/opening.py +++ b/services/opening.py @@ -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: diff --git a/services/rpg_context.py b/services/rpg_context.py index 27afcf8..7cbcd68 100644 --- a/services/rpg_context.py +++ b/services/rpg_context.py @@ -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, + ) diff --git a/services/rpg_facts.py b/services/rpg_facts.py index d535202..d532ac8 100644 --- a/services/rpg_facts.py +++ b/services/rpg_facts.py @@ -20,22 +20,37 @@ Return ONLY valid JSON (no markdown), as an array of objects: Rules: - Return at most 5 NEW facts per turn. If nothing new, return []. - Do NOT repeat or rephrase facts already listed under "Already known". -- Facts must be durable (names, relations, inventory, locations, lasting world state). +- Facts must be DURABLE world/character state only: + traits, relationships, inventory, locations, secrets revealed, lasting abilities/rules. +- NEVER store plot events or scene narration (no "they went", "they decided", "they hugged", + "they started a new arc", "they stepped through the portal"). - Skip momentary emotions unless they permanently change a relationship. - text <= 120 chars each. - rp_day: in-world time label (день 1, второй день, та же ночь, через год). Use RP time hint when unclear.""" FACTS_COMPRESS_SYSTEM = """You consolidate RPG session memory for a long-running chat. -Return ONLY valid JSON (no markdown): an array of {"text": "...", "rp_day": "..."}. +Return ONLY valid JSON (no markdown): an array of {{"text": "...", "rp_day": "..."}}. Goals: +- Use ONLY information from the input facts. NEVER invent or infer new facts. - Aggressively MERGE near-duplicates (same topic in RU/EN, Rin/Рин, Grigo/Григорий). - Keep ONE best fact per topic; combine rp_day if needed (e.g. "день 1–2"). -- DROP redundant, trivial, or superseded facts. -- Keep: names, relationships, key locations, lasting magic/rules, inventory, unresolved threads. +- DROP redundant, trivial, superseded, and ALL one-off narrative/event facts. +- DROP facts that describe a single scene action (went, decided, hugged, called for help, stepped into portal). +- KEEP durable state only: names, nicknames, relationships, inventory, home items, locations, + lasting abilities, secrets/identity, unresolved mysteries. - Target at most {target} facts (fewer is better). Each text <= 120 chars. - rp_day = in-world labels only.""" +_NARRATIVE_EVENT_RE = re.compile( + r"(?:" + r"отправил(?:ись|а|и)?|решили|начали|обнялись|шагнули|вызвали|стали ближе|" + r"передали|раскрыла|начали новую арку|вместе шагнули|тактическ(?:ое|и) отступлени|" + r"went to|decided to|hugged|stepped through|called for help|started a new arc" + r")", + re.IGNORECASE, +) + _NAME_ALIASES = ( ("grigoriy", "григорий"), ("grigo", "григо"), @@ -135,6 +150,38 @@ def facts_are_similar(a: str, b: str) -> bool: return overlap >= 0.32 +def is_likely_narrative_event(text: str) -> bool: + """One-off scene actions — not durable memory.""" + t = (text or "").strip() + if not t: + return True + if _NARRATIVE_EVENT_RE.search(t): + return True + if "новую арку" in t.lower() or "new arc" in t.lower(): + return True + return False + + +def filter_durable_facts(facts: list[dict]) -> list[dict]: + return [f for f in facts if not is_likely_narrative_event(f.get("text", ""))] + + +def validate_compressed_against_source( + original: list[dict], compressed: list[dict] +) -> list[dict]: + """Reject LLM-hallucinated facts not grounded in the input list.""" + if not compressed: + return [] + out: list[dict] = [] + for c in compressed: + text = (c.get("text") or "").strip() + if not text or is_likely_narrative_event(text): + continue + if any(facts_are_similar(text, o.get("text", "")) for o in original): + out.append(c) + return out + + def dedupe_facts_fuzzy(facts: list[dict]) -> list[dict]: out: list[dict] = [] for f in facts: @@ -243,8 +290,10 @@ async def compress_facts( if entry: out.append(entry) if out: - logger.info("compress_facts: %d -> %d", len(facts), len(out)) - return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT] + out = validate_compressed_against_source(facts, out) + if out: + logger.info("compress_facts: %d -> %d", len(facts), len(out)) + return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT] except json.JSONDecodeError: logger.warning("compress_facts JSON parse failed. Raw=%.400s", raw) return dedupe_facts_fuzzy(facts)[-target:] @@ -263,6 +312,7 @@ async def merge_facts_persist( existing_json, new_facts, rp_day_default=rp_day_default ) facts = dedupe_facts_fuzzy(parse_facts_list(merged_json)) + facts = filter_durable_facts(facts) if len(facts) > FACTS_DEDUP_THRESHOLD: facts = await compress_facts( facts, @@ -340,6 +390,8 @@ async def extract_facts( continue if any(facts_are_similar(entry["text"], k["text"]) for k in known): continue + if is_likely_narrative_event(entry["text"]): + continue if not entry["rp_day"] and hint: entry["rp_day"] = hint[:80] out.append(entry) @@ -350,7 +402,7 @@ async def extract_facts( def facts_to_prompt(facts_json: str, max_items: int = FACTS_PROMPT_MAX) -> str: - facts = dedupe_facts_fuzzy(parse_facts_list(facts_json)) + facts = filter_durable_facts(dedupe_facts_fuzzy(parse_facts_list(facts_json))) if not facts: return "" recent = facts[-max_items:] diff --git a/services/rpg_locale.py b/services/rpg_locale.py new file mode 100644 index 0000000..240db45 --- /dev/null +++ b/services/rpg_locale.py @@ -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" diff --git a/services/rpg_narrator.py b/services/rpg_narrator.py index 12398f5..ca74b1c 100644 --- a/services/rpg_narrator.py +++ b/services/rpg_narrator.py @@ -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("```"): diff --git a/services/rpg_plot.py b/services/rpg_plot.py index d8a5d85..9c26d5c 100644 --- a/services/rpg_plot.py +++ b/services/rpg_plot.py @@ -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]: diff --git a/services/rpg_state.py b/services/rpg_state.py index ffc42b9..a4ca634 100644 --- a/services/rpg_state.py +++ b/services/rpg_state.py @@ -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 diff --git a/services/rpg_story.py b/services/rpg_story.py new file mode 100644 index 0000000..2967830 --- /dev/null +++ b/services/rpg_story.py @@ -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 diff --git a/static/css/app.css b/static/css/app.css index 03af736..79a52ef 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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; diff --git a/static/index.html b/static/index.html index d84b585..04e928a 100644 --- a/static/index.html +++ b/static/index.html @@ -28,6 +28,7 @@