Compare commits

..

2 Commits

Author SHA1 Message Date
Grigo 7f3599a859 new RPG system 2026-06-05 15:13:55 +03:00
Grigo 01b16dbeaa new RPG system 2026-06-05 14:57:15 +03:00
29 changed files with 2395 additions and 311 deletions
+14 -11
View File
@@ -165,11 +165,15 @@ sequenceDiagram
- `status_quo_update``UPDATE sessions.status_quo`. - `status_quo_update``UPDATE sessions.status_quo`.
- `scene_update` (partial) → merge в `scene_json`. - `scene_update` (partial) → merge в `scene_json`.
- **Нет** d20, **нет** bubble «Рассказчик». - **Нет** d20, **нет** bubble «Рассказчик».
3. **runtime_suffix** = `build_rpg_runtime_suffix(session)` + `narrator_extra` (directives только). 3. **Linear story (pre-stream):** `reconcile_story_arc` → один active-квест = текущий `steps[i]`:
4. `upsert_static_system_message(static)` — в БД system без RPG-блоков. - `format_step_guidance_for_character` — цель шага в system CHAT.
5. `add_message(user, message)`. - При первом входе в шаг: `injection` как мягкая подсказка (`format_step_hint_for_character`).
6. **context_usage** — если > 85%, в конец system добавляется `[Context: ~N% …]`. - Если арка завершена и игрок выбрал «новую арку» → `roll_next_arc`.
7. **llm_messages** = system(static+runtime) + вся история user/assistant. 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 ### Этап B — SSE stream
@@ -187,12 +191,11 @@ sequenceDiagram
| # | Действие | Модель | Условие | | # | Действие | Модель | Условие |
|---|----------|--------|---------| |---|----------|--------|---------|
| C1 | `generate_plot_arc` | PLOT | Только если `plot_arc_json` пуст (редко после opening) | | C1 | `generate_plot_arc` | PLOT | Только если `plot_arc_json` пуст (редко после opening) |
| C2 | `should_advance_arc(user_message)` | код | Ключевые слова: отдых → `event_driven:rest`, путь → `travel`, помощь → `help_request` | | C2 | **`narrator_post`** | NARRATOR | Контекст: шаг N/M, completion_criteria, последние 8 реплик |
| C3 | `pop_matching_beats` + injection | — | Если trig совпал с beat; choices из beat | | C3 | **`apply_narrator_post_with_story`** | — | facts, affinity, scene; `step_complete` → advance step, sync quest |
| C4 | `advance_phase` | — | Если beats пусты — фаза opening→hook→… | | C4 | step choices / new arc | — | choices из нового шага; при `arc_completed` — «Начать новую арку» |
| C5 | `extract_facts` | FACTS | Последние 10 реплик; merge до 80, в промпт 20 | | C5 | `extract_facts` | FACTS | Последние 10 реплик |
| C6 | **`narrator_post`** | NARRATOR | Контекст: последние 8 реплик **включая новый ответ** | | C6 | SD + SSE `done` | — | **всегда** `quests` + `story_arc` в payload (live UI) |
| C7 | **`apply_narrator_post`** | — | status_quo, affinity_delta, scene, outfit, quests, stats_delta* |
| C8 | `generate_sd_prompt` + Comfy | SD | outfit + scene_json + последние 6 реплик | | C8 | `generate_sd_prompt` + Comfy | SD | outfit + scene_json + последние 6 реплик |
| C9 | SSE `done` | — | choices, affinity, quests, image_*, debug | | C9 | SSE `done` | — | choices, affinity, quests, image_*, debug |
+1
View File
@@ -0,0 +1 @@
print('bootstrap')
+26
View File
@@ -76,6 +76,7 @@ async def init_db():
await _migrate_characters_columns(db) await _migrate_characters_columns(db)
await _migrate_rpg_quests(db) await _migrate_rpg_quests(db)
await _migrate_action_resolutions(db) await _migrate_action_resolutions(db)
await _migrate_state_snapshots(db)
await db.commit() 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 def _migrate_characters_columns(db):
async with db.execute("PRAGMA table_info(characters)") as cur: async with db.execute("PRAGMA table_info(characters)") as cur:
cols = {row[1] for row in await cur.fetchall()} cols = {row[1] for row in await cur.fetchall()}
+3
View File
@@ -8,6 +8,9 @@ class ChatRequest(BaseModel):
is_narrator_choice: bool = False is_narrator_choice: bool = False
skip_user_add: bool = False skip_user_add: bool = False
first_mes_override: Optional[str] = None 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): class MessageEditRequest(BaseModel):
+215 -83
View File
@@ -24,7 +24,6 @@ from services.memory import (
update_session_genre, update_session_genre,
update_session_plot_arc, update_session_plot_arc,
get_quests, get_quests,
seed_quests_from_arc,
narrator_message_content, narrator_message_content,
parse_narrator_message, parse_narrator_message,
add_action_resolution, add_action_resolution,
@@ -36,10 +35,13 @@ from services.memory import (
update_message_choices, update_message_choices,
clear_choices_for_session, clear_choices_for_session,
upsert_static_system_message, upsert_static_system_message,
save_state_snapshot,
get_last_message_id,
) )
from services.context_budget import compute_payload_usage, context_warning_line from services.context_budget import compute_payload_usage, context_warning_line
from services.rpg_state import ( from services.rpg_state import (
apply_narrator_post, apply_narrator_post,
apply_narrator_post_with_story,
parse_scene_json, parse_scene_json,
parse_stats_json, parse_stats_json,
scene_prompt_block, 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.character_card import get_character
from services import sdbackend as sd_service 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_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 ( from services.rpg_plot import (
generate_plot_arc, generate_plot_arc,
process_arc_beats,
advance_phase,
replenish_arc_beats,
reconcile_plot_arc, reconcile_plot_arc,
reconcile_plot_arc, choices_from_step,
choices_from_beat,
choices_from_narrator, 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.rpg_narrator import narrator_pre, narrator_post
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening 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: except Exception:
arc = {} arc = {}
if arc: if arc:
runtime_suffix += "\n\n--- PlotArc ---\n" + json.dumps( summary = format_arc_summary_for_runtime(arc)
{k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False if summary:
) + "\n---" runtime_suffix += "\n\n--- Story arc ---\n" + summary + "\n---"
status_quo = (session.get("status_quo") or "").strip() status_quo = (session.get("status_quo") or "").strip()
if status_quo: if status_quo:
from services.rp_sanitize import status_quo_prompt_block 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 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.""" """Build LLM payload: one system message (static + runtime), no duplicate system rows."""
out: list[dict] = [] out: list[dict] = []
system_used = False system_used = False
@@ -134,7 +151,10 @@ def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
elif m["role"] == "narrator": elif m["role"] == "narrator":
data = parse_narrator_message(m.get("content") or "") data = parse_narrator_message(m.get("content") or "")
if data: 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": elif m["role"] == "user":
has_res = bool(m.get("action_resolution")) has_res = bool(m.get("action_resolution"))
out.append({ out.append({
@@ -272,14 +292,18 @@ async def rpg_bootstrap(req: RpgBootstrapRequest):
session = await get_session(req.session_id) or {} session = await get_session(req.session_id) or {}
rpg_settings = get_rpg_settings(session) rpg_settings = get_rpg_settings(session)
if rpg_settings.get("narrator", True) and greeting: 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 "" arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
facts_block = facts_to_prompt(session.get("facts_json", "[]")) facts_block = facts_to_prompt(session.get("facts_json", "[]"))
b_lang = infer_rp_language([{"role": "assistant", "content": greeting}])
post = await narrator_post( post = await narrator_post(
persona.get("name", persona_id), persona.get("name", persona_id),
f"assistant: {greeting}", f"assistant: {greeting}",
arc_json, arc_json,
facts_block, facts_block,
is_opening=True, is_opening=True,
lang=b_lang,
) )
await apply_narrator_post(req.session_id, post, rpg_settings, session) await apply_narrator_post(req.session_id, post, rpg_settings, session)
quests = await get_quests(req.session_id) quests = await get_quests(req.session_id)
@@ -319,20 +343,70 @@ async def chat_stream(request: ChatRequest):
pre = {} pre = {}
directives: list = [] directives: list = []
pre_ok = False 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"): if session and session.get("rpg_enabled"):
from services.rpg_locale import infer_rp_language
rpg_settings = get_rpg_settings(session) rpg_settings = get_rpg_settings(session)
facts_block = facts_to_prompt(session.get("facts_json", "[]")) facts_block = facts_to_prompt(session.get("facts_json", "[]"))
rp_lang = infer_rp_language(history)
try: try:
arc = json.loads(session.get("plot_arc_json") or "{}") arc = json.loads(session.get("plot_arc_json") or "{}")
except Exception: except Exception:
arc = {} 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) quests_list = await get_quests(request.session_id)
narr_ctx = format_narrator_context( narr_ctx = format_narrator_context(
arc, quests_list, session.get("status_quo") or "" 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 {} persona = await get_persona(persona_id) or {}
recent_txt = "\n".join( recent_txt = "\n".join(
f"{m['role']}: {m['content']}" for m in history[-8:] f"{m['role']}: {m['content']}" for m in history[-8:]
@@ -347,6 +421,7 @@ async def chat_stream(request: ChatRequest):
facts_block, facts_block,
request.message, request.message,
extra_context=narr_ctx, extra_context=narr_ctx,
lang=rp_lang,
) )
pre_ok = bool(pre.get("_ok")) pre_ok = bool(pre.get("_ok"))
@@ -373,6 +448,7 @@ async def chat_stream(request: ChatRequest):
roll=roll, roll=roll,
outcome=outcome, outcome=outcome,
extra_context=narr_ctx, extra_context=narr_ctx,
lang=rp_lang,
) )
resolution_text = (pre2.get("resolution_text") or "").strip() resolution_text = (pre2.get("resolution_text") or "").strip()
directives = pre2.get("directives") or [] 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 runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra
llm_system = static_prompt + runtime_suffix llm_system = static_prompt + runtime_suffix
@@ -429,7 +527,12 @@ async def chat_stream(request: ChatRequest):
llm_system += RP_OUTPUT_REMINDER llm_system += RP_OUTPUT_REMINDER
user_message_content = request.message 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}]" user_message_content = f"[Player chose: {request.message}]"
await upsert_static_system_message(request.session_id, static_prompt, history) 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: if not request.skip_user_add:
await clear_choices_for_session(request.session_id) await clear_choices_for_session(request.session_id)
user_msg_id = await add_message(request.session_id, "user", user_message_content) 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: if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id:
await add_action_resolution( await add_action_resolution(
request.session_id, request.session_id,
@@ -449,23 +554,71 @@ async def chat_stream(request: ChatRequest):
) )
narrator_msg["user_message_id"] = user_msg_id narrator_msg["user_message_id"] = user_msg_id
if narrator_msg and (narrator_msg.get("text") or "").strip(): if narrator_msg and (narrator_msg.get("text") or "").strip():
await add_message( narr_id = await add_message(
request.session_id, request.session_id,
"narrator", "narrator",
narrator_message_content(narrator_msg), 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) messages = await get_history(request.session_id)
usage = compute_payload_usage(messages, llm_system) usage = compute_payload_usage(messages, llm_system)
warn = context_warning_line(usage.get("percent", 0)) warn = context_warning_line(usage.get("percent", 0))
if warn: if warn:
llm_system += 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 = [] full_reply = []
async def generate(): async def generate():
nonlocal arc 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: if narrator_msg:
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n" 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"): if session and session.get("rpg_enabled"):
try: try:
if not arc: if not arc or not arc.get("steps"):
persona = await get_persona(persona_id) or {} 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( arc = await generate_plot_arc(
persona.get("name", persona_id), persona.get("name", persona_id),
persona.get("description", ""), persona.get("description", ""),
@@ -501,6 +659,8 @@ async def chat_stream(request: ChatRequest):
persona.get("first_mes", ""), persona.get("first_mes", ""),
facts_block=facts_to_prompt(session.get("facts_json", "[]")), facts_block=facts_to_prompt(session.get("facts_json", "[]")),
genre=session.get("genre") or "adventure", genre=session.get("genre") or "adventure",
lang=rp_lang,
recent_context=gen_ctx,
) )
if arc: if arc:
await update_session_plot_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), "text": json.dumps(arc, ensure_ascii=False, indent=2),
}) })
if rpg_settings.get("quests", True): 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) arc = normalize_story_arc(
if arc: arc, genre=session.get("genre") or "adventure"
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( cur, total = step_progress(arc)
arc, narrator_meta["story_step"] = f"{cur}/{total}"
quests_list, narrator_meta["rp_language"] = rp_lang
request.message, if story_arc_meta.get("new_arc_rolled"):
recent_context=beat_ctx, narrator_meta["new_arc_rolled"] = True
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
ctx = [ ctx = [
m for m in (await get_history(request.session_id)) 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 "", json.dumps(arc, ensure_ascii=False) if arc else "",
facts_to_prompt(session.get("facts_json", "[]")), facts_to_prompt(session.get("facts_json", "[]")),
extra_context=narr_ctx_post, extra_context=narr_ctx_post,
lang=rp_lang,
) )
sq = (post.get("status_quo_update") or "").strip() sq = (post.get("status_quo_update") or "").strip()
@@ -607,9 +730,11 @@ async def chat_stream(request: ChatRequest):
if rpg_settings.get("choices", True): if rpg_settings.get("choices", True):
choices += choices_from_narrator(post.get("choices") or []) choices += choices_from_narrator(post.get("choices") or [])
applied = await apply_narrator_post( applied = await apply_narrator_post_with_story(
request.session_id, post, rpg_settings, session request.session_id, post, rpg_settings, session, arc=arc
) )
if applied.get("arc"):
arc = applied["arc"]
narrator_meta = { narrator_meta = {
"pre_ok": pre_ok, "pre_ok": pre_ok,
"post_ok": bool(post.get("_ok")), "post_ok": bool(post.get("_ok")),
@@ -619,26 +744,27 @@ async def chat_stream(request: ChatRequest):
**applied, **applied,
} }
if not arc.get("beats"): if applied.get("step_advanced"):
persona = await get_persona(persona_id) or {} # Drop stale narrator/plot choices from the step we just left;
arc = await replenish_arc_beats( # injection is shown once in the plot choice panel (beat_injection).
arc, choices = [c for c in choices if c.get("type") == "new_arc_roll"]
persona.get("name", persona_id), new_step = get_current_step(arc)
ctx_txt, if new_step and rpg_settings.get("choices", True):
await get_quests(request.session_id), choices += choices_from_step(new_step)
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({ debug_blocks.append({
"type": "plot_arc", "type": "plot_arc",
"text": f"Added {len(arc.get('beats', []))} new plot beats", "text": f"Step advanced: «{applied.get('new_step_title', '')}»",
}) })
narrator_meta["beats_replenished"] = len(arc.get("beats", [])) narrator_meta["choices_count"] = len(choices)
if rpg_settings.get("quests", True):
await seed_quests_from_arc(request.session_id, arc) 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") outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update: if isinstance(outfit_update, list) and outfit_update:
from services.outfit_tags import outfit_list_to_json from services.outfit_tags import outfit_list_to_json
@@ -705,7 +831,8 @@ async def chat_stream(request: ChatRequest):
"choices": choices, "choices": choices,
"debug": debug_blocks, "debug": debug_blocks,
"affinity": affinity, "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, "narrator_meta": narrator_meta,
} }
if rpg_settings.get("stats") and updated_session: if rpg_settings.get("stats") and updated_session:
@@ -713,6 +840,11 @@ async def chat_stream(request: ChatRequest):
updated_session.get("narrative_stats_json") 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" yield f"data: {json.dumps(done_payload)}\n\n"
return StreamingResponse( return StreamingResponse(
+301 -19
View File
@@ -396,16 +396,32 @@ async def delete_messages_after(session_id: str, message_id: int):
"DELETE FROM messages WHERE session_id = ? AND id > ?", "DELETE FROM messages WHERE session_id = ? AND id > ?",
(session_id, message_id), (session_id, message_id),
) )
await db.execute(
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
(session_id,),
)
await db.commit() await db.commit()
await restore_session_to_message(session_id, message_id)
async def delete_message(message_id: int): 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: async with aiosqlite.connect(DB_PATH) as db:
await db.execute("DELETE FROM messages WHERE id = ?", (message_id,)) 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 db.commit()
await restore_session_to_message(session_id, anchor)
async def delete_message_and_following(session_id: str, message_id: int) -> bool: 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: async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
"DELETE FROM messages WHERE session_id = ? AND id >= ?", "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,), (session_id,),
) )
await db.commit() await db.commit()
await restore_session_to_message(session_id, anchor)
return True return True
@@ -456,6 +473,185 @@ async def get_last_message_preview(session_id: str, max_len: int = 80) -> str:
return prefix + text 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: async def fork_session(source_session_id: str, until_message_id: int) -> str | None:
source = await get_session(source_session_id) source = await get_session(source_session_id)
if not source: if not source:
@@ -464,6 +660,8 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
import uuid import uuid
new_id = "sess_" + uuid.uuid4().hex[:8] 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: async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
"""INSERT INTO sessions """INSERT INTO sessions
@@ -476,39 +674,111 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
source["persona_id"], source["persona_id"],
(source.get("title") or "Новый чат") + " (ветка)", (source.get("title") or "Новый чат") + " (ветка)",
source.get("rpg_enabled", 0), source.get("rpg_enabled", 0),
source.get("facts_json", "[]"), (snap or source).get("facts_json", "[]"),
source.get("global_plot", ""), (snap or source).get("global_plot", ""),
source.get("status_quo", ""), (snap or source).get("status_quo", ""),
source.get("plot_arc_json", "{}"), (snap or source).get("plot_arc_json", "{}"),
source.get("genre", "adventure"), source.get("genre", "adventure"),
source.get("rpg_settings_json", "{}"), source.get("rpg_settings_json", "{}"),
source.get("affinity", 0), int((snap or source).get("affinity") or 0),
source.get("outfit_json", "[]"), (snap or source).get("outfit_json", "[]"),
source.get("scene_json", "{}"), (snap or source).get("scene_json", "{}"),
source.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'), (snap or source).get(
"narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'
),
), ),
) )
async with db.execute( 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""", WHERE session_id = ? AND id <= ? ORDER BY id""",
(source_session_id, until_message_id), (source_session_id, until_message_id),
) as cur: ) as cur:
rows = await cur.fetchall() rows = await cur.fetchall()
id_map: dict[int, int] = {}
for r in rows: for r in rows:
await db.execute( cur_ins = await db.execute(
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path) """INSERT INTO messages
VALUES (?, ?, ?, ?, ?)""", (session_id, role, content, image_prompt, image_path,
(new_id, r[0], r[1], r[2], r[3]), 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]),
) )
async with db.execute( id_map[int(r[0])] = int(cur_ins.lastrowid)
"SELECT title, status FROM rpg_quests WHERE session_id = ?", if snap:
(source_session_id,), try:
) as cur: quests = json.loads(snap.get("quests_json") or "[]")
quests = await cur.fetchall() except (json.JSONDecodeError, TypeError):
quests = []
if isinstance(quests, list):
for q in quests: for q in quests:
if isinstance(q, dict) and (q.get("title") or "").strip():
await db.execute( await db.execute(
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)", "INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
(new_id, q[0], q[1]), (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() await db.commit()
return new_id return new_id
@@ -572,6 +842,17 @@ async def update_message_image_alt(message_id: int, image_path_alt: str):
await db.commit() 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 def get_last_assistant_message_id(session_id: str) -> int | None:
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
@@ -591,6 +872,7 @@ async def clear_history(session_id: str):
"DELETE FROM messages WHERE session_id = ?", (session_id,) "DELETE FROM messages WHERE session_id = ?", (session_id,)
) )
await db.commit() await db.commit()
await _reset_persona_bound_state_only(session_id)
async def update_session_affinity(session_id: str, delta: int): async def update_session_affinity(session_id: str, delta: int):
+21 -3
View File
@@ -7,7 +7,6 @@ from services.memory import (
get_last_assistant_message_id, get_last_assistant_message_id,
update_session_plot_arc, update_session_plot_arc,
update_message_choices, update_message_choices,
seed_quests_from_arc,
get_quests, get_quests,
) )
from services.rpg_state import apply_narrator_post from services.rpg_state import apply_narrator_post
@@ -64,7 +63,10 @@ async def ensure_plot_arc_and_quests(
if arc: if arc:
return arc return arc
from services.rpg_locale import infer_rp_language
facts_block = facts_to_prompt(session.get("facts_json", "[]")) facts_block = facts_to_prompt(session.get("facts_json", "[]"))
lang = infer_rp_language([{"role": "assistant", "content": greeting}])
arc = await generate_plot_arc( arc = await generate_plot_arc(
persona.get("name", "Character"), persona.get("name", "Character"),
persona.get("description", ""), persona.get("description", ""),
@@ -72,13 +74,17 @@ async def ensure_plot_arc_and_quests(
greeting, greeting,
facts_block=facts_block, facts_block=facts_block,
genre=genre, genre=genre,
lang=lang,
recent_context=f"assistant: {greeting}",
) )
if not arc: if not arc:
return {} return {}
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False)) await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
if seed_quests: 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 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) quests_pre = await get_quests(session_id)
narr_ctx = format_narrator_context(arc, quests_pre, session.get("status_quo") or "") 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( post = await narrator_post(
persona.get("name", persona_id), persona.get("name", persona_id),
ctx_txt, ctx_txt,
@@ -129,12 +138,17 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
facts_block, facts_block,
is_opening=True, is_opening=True,
extra_context=narr_ctx, extra_context=narr_ctx,
lang=o_lang,
) )
if rpg_settings.get("choices", True): if rpg_settings.get("choices", True):
choices = choices_from_narrator(post.get("choices") or []) 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 session = await get_session(session_id) or session
status_quo = session.get("status_quo") or status_quo status_quo = session.get("status_quo") or status_quo
outfit_json = session.get("outfit_json") or outfit_json 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 {} sd_out = await run_sd_for_message(bundle, msg_id) if bundle else {}
updated = await get_session(session_id) 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 affinity = updated.get("affinity", 0) if updated else 0
if msg_id and choices: if msg_id and choices:
+23 -42
View File
@@ -1,6 +1,12 @@
"""Shared context blocks for RPG narrator / plot LLM calls.""" """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( def format_narrator_context(
@@ -8,46 +14,21 @@ def format_narrator_context(
quests: list | None, quests: list | None,
status_quo: str = "", status_quo: str = "",
) -> str: ) -> str:
parts: list[str] = [] arc = normalize_story_arc(arc or {}) if arc else {}
arc = arc or {} return format_step_for_narrator(arc, quests, status_quo)
beats = arc.get("beats") or []
if not isinstance(beats, list):
beats = []
parts.append(f"Plot phase: {arc.get('phase', 'opening')}. Scripted beats left: {len(beats)}.")
if not beats: def format_arc_summary_for_runtime(arc: dict | None) -> str:
parts.append( arc = normalize_story_arc(arc or {}) if arc else {}
"IMPORTANT: Scripted beats are EXHAUSTED (quests may already be done). " if not arc:
"The story must CONTINUE — do not stall. " return ""
"Always return 2-4 meaningful choices for the player's next actions. " cur, total = step_progress(arc)
"You may add quest_updates with status 'active' for NEW optional threads. " return json.dumps(
"Do NOT re-activate quests the player already completed unless they explicitly revisit that thread." {
"title": arc.get("title"),
"global_story": (arc.get("global_story") or "")[:300],
"step": f"{cur}/{total}",
"status": arc.get("status", "active"),
},
ensure_ascii=False,
) )
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)
+57 -5
View File
@@ -20,22 +20,37 @@ Return ONLY valid JSON (no markdown), as an array of objects:
Rules: Rules:
- Return at most 5 NEW facts per turn. If nothing new, return []. - Return at most 5 NEW facts per turn. If nothing new, return [].
- Do NOT repeat or rephrase facts already listed under "Already known". - 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. - Skip momentary emotions unless they permanently change a relationship.
- text <= 120 chars each. - text <= 120 chars each.
- rp_day: in-world time label (день 1, второй день, та же ночь, через год). Use RP time hint when unclear.""" - 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. 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: 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/Григорий). - Aggressively MERGE near-duplicates (same topic in RU/EN, Rin/Рин, Grigo/Григорий).
- Keep ONE best fact per topic; combine rp_day if needed (e.g. "день 12"). - Keep ONE best fact per topic; combine rp_day if needed (e.g. "день 12").
- DROP redundant, trivial, or superseded facts. - DROP redundant, trivial, superseded, and ALL one-off narrative/event facts.
- Keep: names, relationships, key locations, lasting magic/rules, inventory, unresolved threads. - 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. - Target at most {target} facts (fewer is better). Each text <= 120 chars.
- rp_day = in-world labels only.""" - 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 = ( _NAME_ALIASES = (
("grigoriy", "григорий"), ("grigoriy", "григорий"),
("grigo", "григо"), ("grigo", "григо"),
@@ -135,6 +150,38 @@ def facts_are_similar(a: str, b: str) -> bool:
return overlap >= 0.32 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]: def dedupe_facts_fuzzy(facts: list[dict]) -> list[dict]:
out: list[dict] = [] out: list[dict] = []
for f in facts: for f in facts:
@@ -242,6 +289,8 @@ async def compress_facts(
entry = parse_fact_entry(item) entry = parse_fact_entry(item)
if entry: if entry:
out.append(entry) out.append(entry)
if out:
out = validate_compressed_against_source(facts, out)
if out: if out:
logger.info("compress_facts: %d -> %d", len(facts), len(out)) logger.info("compress_facts: %d -> %d", len(facts), len(out))
return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT] return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT]
@@ -263,6 +312,7 @@ async def merge_facts_persist(
existing_json, new_facts, rp_day_default=rp_day_default existing_json, new_facts, rp_day_default=rp_day_default
) )
facts = dedupe_facts_fuzzy(parse_facts_list(merged_json)) facts = dedupe_facts_fuzzy(parse_facts_list(merged_json))
facts = filter_durable_facts(facts)
if len(facts) > FACTS_DEDUP_THRESHOLD: if len(facts) > FACTS_DEDUP_THRESHOLD:
facts = await compress_facts( facts = await compress_facts(
facts, facts,
@@ -340,6 +390,8 @@ async def extract_facts(
continue continue
if any(facts_are_similar(entry["text"], k["text"]) for k in known): if any(facts_are_similar(entry["text"], k["text"]) for k in known):
continue continue
if is_likely_narrative_event(entry["text"]):
continue
if not entry["rp_day"] and hint: if not entry["rp_day"] and hint:
entry["rp_day"] = hint[:80] entry["rp_day"] = hint[:80]
out.append(entry) 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: 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: if not facts:
return "" return ""
recent = facts[-max_items:] recent = facts[-max_items:]
+51
View File
@@ -0,0 +1,51 @@
"""Infer RP session language from recent chat for narrator/plot prompts."""
import re
_CYRILLIC = re.compile(r"[\u0400-\u04FF]")
def infer_rp_language(messages: list | None, *, sample: int = 8) -> str:
"""
Return 'ru' if recent user/assistant text is mostly Cyrillic, else 'en'.
"""
if not messages:
return "ru"
texts: list[str] = []
for m in reversed(messages):
if not isinstance(m, dict):
continue
if m.get("role") not in ("user", "assistant"):
continue
c = (m.get("content") or "").strip()
if c:
texts.append(c)
if len(texts) >= sample:
break
if not texts:
return "ru"
combined = " ".join(texts)
cyr = len(_CYRILLIC.findall(combined))
lat = len(re.findall(r"[A-Za-z]", combined))
if cyr == 0 and lat > 0:
return "en"
if cyr >= lat:
return "ru"
return "ru" if cyr > lat * 0.3 else "en"
def locale_instruction(lang: str) -> str:
if lang == "ru":
return (
"Session language: Russian. "
"All prose you generate (injections, titles, resolution_text, status_quo, choice labels) "
"MUST be in Russian."
)
return (
"Session language: English. "
"All prose you generate MUST be in English."
)
def locale_label(lang: str) -> str:
return "Russian" if lang == "ru" else "English"
+29 -6
View File
@@ -40,14 +40,17 @@ Return ONLY valid JSON (no markdown):
"stats_delta": {"lust": 0, "stamina": 0, "tension": 0}, "stats_delta": {"lust": 0, "stamina": 0, "tension": 0},
"scene_update": {"place": "", "place_id": "", "time_of_day": "", "day": "", "weather": "", "exits": [], "layout_note": ""}, "scene_update": {"place": "", "place_id": "", "time_of_day": "", "day": "", "weather": "", "exits": [], "layout_note": ""},
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}], "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: 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. - 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. - 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. - 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. - 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. - 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 - 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). (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, roll: int | None = None,
outcome: str | None = None, outcome: str | None = None,
extra_context: str = "", extra_context: str = "",
*,
lang: str = "ru",
) -> dict: ) -> dict:
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else "" roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
user = ( user = (
@@ -76,9 +81,17 @@ async def narrator_pre(
) )
if extra_context: if extra_context:
user += f"\n--- Session state ---\n{extra_context}\n---\n" user += f"\n--- Session state ---\n{extra_context}\n---\n"
from services.rpg_locale import locale_instruction
try: try:
raw = await send_message_with_model( 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, NARRATOR_MODEL,
) )
except LLMError as e: except LLMError as e:
@@ -111,6 +124,8 @@ async def narrator_post(
facts_block: str, facts_block: str,
is_opening: bool = False, is_opening: bool = False,
extra_context: str = "", extra_context: str = "",
*,
lang: str = "ru",
) -> dict: ) -> dict:
opening_block = "" opening_block = ""
if is_opening: if is_opening:
@@ -133,17 +148,25 @@ async def narrator_post(
) )
if extra_context: if extra_context:
user += f"\n--- Session state ---\n{extra_context}\n---\n" user += f"\n--- Session state ---\n{extra_context}\n---\n"
from services.rpg_locale import locale_instruction
try: try:
raw = await send_message_with_model( 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, NARRATOR_MODEL,
) )
except LLMError as e: except LLMError as e:
logger.warning("Narrator-post LLM failed (model=%s): %s", NARRATOR_MODEL, 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: except Exception as e:
logger.warning("Narrator-post unexpected error: %s", 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() cleaned = raw.strip()
if cleaned.startswith("```"): if cleaned.startswith("```"):
+292 -71
View File
@@ -18,25 +18,36 @@ GENRE_LABELS = {
} }
ARC_SYSTEM = """You are a narrative designer for an RPG chat. 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): Return ONLY valid JSON (no markdown):
{ {
"title": "short arc title", "title": "short arc title",
"boundaries": ["things that must remain true to preserve immersion"], "genre_blend": "e.g. Romance + Adventure",
"phase": "opening|hook|complication|reveal|climax|aftermath", "global_story": "2-4 sentences: setup, through-line, planned finale",
"cast": [{"name":"NPC name","role":"helper|antagonist|bystander","motivation":"..."}], "ending": "how this arc resolves",
"secrets": ["hidden truths not revealed yet"], "reward": "conditional reward/hook for player and character after finale",
"beats": [ "boundaries": ["no teleporting", "stay in character", "..."],
{"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", "current_step_index": 0,
"injection":"1-3 sentences to introduce the beat WITHOUT breaking current scene", "status": "active",
"choices":[{"id":"a","label":"..."},{"id":"b","label":"..."}]} "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: Rules:
- Respect the opening scene. Do not jump to unrelated characters immediately. - 3-5 linear steps from opening to finale. ONE quest = ONE step at a time.
- Beats must feel like natural developments fitting the genre(s). For cross-genre, blend tropes organically. - Step 1 often matches what already happened in the greeting (shelter, meet, etc.).
- Keep injections immersive (in-world narration).""" - 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: def format_genres(genre: str) -> str:
@@ -49,18 +60,33 @@ def format_genres(genre: str) -> str:
return " + ".join(labels) + " (cross-genre blend)" 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 = ( user = (
f"Character: {persona_name}\n" f"Character: {persona_name}\n"
f"Description: {persona_desc}\n" f"Description: {persona_desc}\n"
f"Scenario: {persona_scenario}\n" f"Scenario: {persona_scenario}\n"
f"Greeting: {greeting}\n" f"Greeting: {greeting}\n"
f"Genre: {format_genres(genre)}\n" f"Genre: {format_genres(genre)}\n"
f"Session language: {locale_label(lang)}\n"
f"Facts:\n{facts_block}\n" f"Facts:\n{facts_block}\n"
).strip() ).strip()
if recent_context.strip():
user += f"\nRecent chat:\n{recent_context.strip()[-2000:]}\n"
messages = [ messages = [
{"role": "system", "content": ARC_SYSTEM}, {"role": "system", "content": ARC_SYSTEM + "\n" + locale_instruction(lang)},
{"role": "user", "content": user}, {"role": "user", "content": user},
] ]
try: try:
@@ -85,12 +111,93 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
cleaned = cleaned.strip() cleaned = cleaned.strip()
try: try:
data = json.loads(cleaned) 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: except Exception:
logger.warning("PlotArc JSON parse failed. Raw=%.500s", raw) logger.warning("PlotArc JSON parse failed. Raw=%.500s", raw)
return {} 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. BEAT_MATCH_SYSTEM = """You decide whether the player's latest message should fire ONE scripted plot beat.
Return ONLY valid JSON (no markdown): Return ONLY valid JSON (no markdown):
{"fire_beat_id": "id from list or null", "confidence": "high|low"} {"fire_beat_id": "id from list or null", "confidence": "high|low"}
@@ -155,6 +262,8 @@ async def classify_plot_beat(
beats: list[dict], beats: list[dict],
recent_context: str = "", recent_context: str = "",
last_dice_outcome: str | None = None, last_dice_outcome: str | None = None,
*,
lang: str = "ru",
) -> str | None: ) -> str | None:
"""LLM: return beat id to fire, or None.""" """LLM: return beat id to fire, or None."""
pending = [b for b in beats if isinstance(b, dict) and b.get("id")] 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: if last_dice_outcome:
user += f"\nLast dice outcome this turn: {last_dice_outcome}\n" user += f"\nLast dice outcome this turn: {last_dice_outcome}\n"
from services.rpg_locale import locale_instruction
messages = [ messages = [
{"role": "system", "content": BEAT_MATCH_SYSTEM}, {
"role": "system",
"content": BEAT_MATCH_SYSTEM + "\n" + locale_instruction(lang),
},
{"role": "user", "content": user}, {"role": "user", "content": user},
] ]
try: try:
@@ -232,10 +346,100 @@ def beat_title(beat: dict) -> str:
return ((beat.get("title") or beat.get("injection") or "")[:120]).strip() 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: def count_active_quests(quests: list | None) -> int:
return sum(1 for q in (quests or []) if q.get("status") == "active") 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]]: 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).""" """Drop beats whose title already matches a done/failed quest (manual quest close desync)."""
done_titles = { done_titles = {
@@ -272,53 +476,79 @@ async def process_arc_beats(
*, *,
recent_context: str = "", recent_context: str = "",
last_dice_outcome: str | None = None, last_dice_outcome: str | None = None,
needs_check: bool = False,
user_turn: int = 0,
allow_stuck_recovery: bool = True, 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. 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' mode: '' | 'after_dice' | 'llm' | 'trigger' | 'stuck_recovery' | 'pruned'
""" """
extras: dict = {"cooldown_skipped": False}
if not arc: if not arc:
return arc, [], [], "" return arc, [], [], "", extras
arc, pruned = prune_beats_for_done_quests(arc, quests) arc, pruned = prune_beats_for_done_quests(arc, quests)
beats_pending = arc.get("beats") or [] 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) 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) arc, fired = pop_matching_beats(arc, dice_trig, max_beats=1)
if fired: if fired:
record_beat_fired(arc, fired[0], user_turn)
logger.info( logger.info(
"process_arc_beats: after_dice %s -> %s", "process_arc_beats: after_dice %s -> %s",
last_dice_outcome, last_dice_outcome,
fired[0].get("id"), fired[0].get("id"),
) )
return arc, fired, pruned, "after_dice" return arc, fired, pruned, "after_dice", extras
if beats_pending: if beats_pending:
beat_id = await classify_plot_beat( 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: if beat_id:
arc, fired = pop_beat_by_id(arc, beat_id) arc, fired = pop_beat_by_id(arc, beat_id)
if fired: 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) trig = should_advance_arc_keywords(user_text)
if trig: if trig:
arc, fired = pop_matching_beats(arc, trig, max_beats=1) arc, fired = pop_matching_beats(arc, trig, max_beats=1)
if fired: 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) arc, fired = pop_next_beats(arc, 1)
if fired: 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: if pruned:
return arc, [], pruned, "pruned" return arc, [], pruned, "pruned", extras
return arc, [], [], "" return arc, [], [], "", extras
PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"] PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"]
@@ -360,6 +590,8 @@ async def replenish_arc_beats(
recent_context: str, recent_context: str,
quests: list, quests: list,
genre: str = "adventure", genre: str = "adventure",
*,
lang: str = "ru",
) -> dict: ) -> dict:
"""Append new beats when arc.beats is empty so plot/quest engine can continue.""" """Append new beats when arc.beats is empty so plot/quest engine can continue."""
if arc.get("beats"): if arc.get("beats"):
@@ -368,9 +600,12 @@ async def replenish_arc_beats(
quest_lines = "\n".join( quest_lines = "\n".join(
f" [{q.get('status')}] {q.get('title')}" for q in (quests or []) f" [{q.get('status')}] {q.get('title')}" for q in (quests or [])
) or " (none)" ) or " (none)"
from services.rpg_locale import locale_instruction, locale_label
user = ( user = (
f"Character: {persona_name}\n" f"Character: {persona_name}\n"
f"Genre: {format_genres(genre)}\n" f"Genre: {format_genres(genre)}\n"
f"Session language: {locale_label(lang)}\n"
f"Current arc title: {arc.get('title', '')}\n" f"Current arc title: {arc.get('title', '')}\n"
f"Phase: {arc.get('phase', 'aftermath')}\n" f"Phase: {arc.get('phase', 'aftermath')}\n"
f"Boundaries: {json.dumps(arc.get('boundaries', []), ensure_ascii=False)}\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" f"Recent chat:\n{recent_context[-4000:]}\n"
) )
messages = [ messages = [
{"role": "system", "content": BEATS_APPEND_SYSTEM}, {"role": "system", "content": BEATS_APPEND_SYSTEM + "\n" + locale_instruction(lang)},
{"role": "user", "content": user}, {"role": "user", "content": user},
] ]
try: try:
@@ -425,41 +660,14 @@ async def reconcile_plot_arc(
persona_name: str = "Character", persona_name: str = "Character",
genre: str = "adventure", genre: str = "adventure",
) -> tuple[dict, bool]: ) -> tuple[dict, bool]:
""" """Sync linear story arc and single active quest. replenish_if_empty ignored (legacy)."""
Prune beats that match done quests; replenish if empty. Persists arc when changed. from services.rpg_story import reconcile_story_arc
Returns (arc, changed).
"""
from services.memory import get_session, get_quests, update_session_plot_arc, seed_quests_from_arc
session = await get_session(session_id) return await reconcile_story_arc(
if not session or not session.get("rpg_enabled"): session_id,
return {}, False persona_name=persona_name,
try: genre=genre,
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
def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]: 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]: 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 []
return [ out = []
c for c in ( for item in (step.get("choices") or []):
normalize_choice(item, source="plot_beat", beat=beat) c = normalize_choice(item, source="plot_step", beat=step)
for item in (beat.get("choices") or []) if c:
) if step.get("id"):
if c 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]: def choices_from_narrator(raw_choices: list) -> list[dict]:
+44 -6
View File
@@ -186,11 +186,27 @@ 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.""" """Turn stored narrator JSON into a binding user-turn for the character model."""
roll = data.get("roll") roll = data.get("roll")
outcome = (data.get("outcome") or "").strip().lower() outcome = (data.get("outcome") or "").strip().lower()
text = (data.get("text") or "").strip() text = (data.get("text") or "").strip()
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 = [ lines = [
"--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---", "--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
f"Roll d20={roll}. Outcome: {outcome}.", f"Roll d20={roll}. Outcome: {outcome}.",
@@ -198,17 +214,15 @@ def format_narrator_outcome_for_llm(data: dict) -> str:
] ]
if outcome in ("failure", "critical failure"): if outcome in ("failure", "critical failure"):
lines.append( lines.append(
"The player's action FAILED as they imagined it. " "The player's action FAILED. Do NOT write a success version; show failure per above."
"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": elif outcome == "critical success":
lines.append( lines.append(
"The attempt succeeded dramatically. You may show amplified success aligned with the outcome above." "The attempt succeeded dramatically. Align with the outcome above."
) )
else: else:
lines.append( lines.append(
"The attempt succeeded. Your reply must align with the narrator outcome above, not contradict it." "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("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
lines.append("---") lines.append("---")
@@ -319,3 +333,27 @@ async def apply_narrator_post(session_id: str, post: dict, rpg_settings: dict, s
applied["quests_updated"] += 1 applied["quests_updated"] += 1
return applied return applied
async def apply_narrator_post_with_story(
session_id: str,
post: dict,
rpg_settings: dict,
session: dict | None = None,
arc: dict | None = None,
) -> dict:
"""apply_narrator_post + linear story step advance."""
from services.rpg_story import apply_story_post, normalize_story_arc
applied = await apply_narrator_post(session_id, post, rpg_settings, session)
arc = normalize_story_arc(arc or {})
story = await apply_story_post(session_id, post, arc, rpg_settings)
applied.update({
"step_advanced": story.get("step_advanced", False),
"arc_completed": story.get("arc_completed", False),
"new_step_title": story.get("new_step_title", ""),
"step_injection": story.get("step_injection", ""),
})
if story.get("arc"):
applied["arc"] = story["arc"]
return applied
+567
View File
@@ -0,0 +1,567 @@
"""Linear story arc: global plot, steps, one active quest."""
import json
import logging
logger = logging.getLogger(__name__)
def migrate_beats_to_steps(arc: dict) -> dict:
"""One-time: collapse legacy beats[] into steps[]."""
if not isinstance(arc, dict):
return {}
if arc.get("steps"):
return arc
beats = arc.get("beats") or []
if not isinstance(beats, list) or not beats:
return arc
steps = []
for i, b in enumerate(beats):
if not isinstance(b, dict):
continue
title = (b.get("title") or b.get("injection") or f"Step {i + 1}").strip()[:120]
steps.append({
"id": b.get("id") or f"s_m{i + 1}",
"title": title,
"goal": title,
"completion_criteria": f"Player and character naturally complete: {title}",
"character_guidance": "Stay in character; move toward the goal without teleporting.",
"injection": (b.get("injection") or "").strip(),
"choices": b.get("choices") or [],
})
arc = dict(arc)
arc["steps"] = steps
arc.pop("beats", None)
arc.setdefault("current_step_index", 0)
arc.setdefault("status", "active")
arc.setdefault("global_story", arc.get("next_beat_hint") or arc.get("title") or "")
arc.setdefault("ending", "")
arc.setdefault("reward", "")
logger.info("migrate_beats_to_steps: %d steps", len(steps))
return arc
def normalize_story_arc(raw: dict, genre: str = "adventure") -> dict:
"""Ensure required fields on a story arc from LLM or migration."""
from services.rpg_plot import format_genres
if not isinstance(raw, dict):
return {}
arc = migrate_beats_to_steps(raw)
arc.setdefault("title", "Story arc")
arc.setdefault("genre_blend", format_genres(genre))
arc.setdefault("global_story", "")
arc.setdefault("ending", "")
arc.setdefault("reward", "")
arc.setdefault("boundaries", [])
arc.setdefault("current_step_index", 0)
arc.setdefault("status", "active")
if not isinstance(arc.get("steps"), list):
arc["steps"] = []
arc["steps"] = [
normalize_step(s, i) for i, s in enumerate(arc.get("steps") or []) if isinstance(s, dict)
]
meta = arc.get("meta")
if not isinstance(meta, dict):
arc["meta"] = {"arc_number": 1, "previous_arc_summary": ""}
else:
meta.setdefault("arc_number", 1)
meta.setdefault("previous_arc_summary", "")
try:
arc["current_step_index"] = max(0, int(arc.get("current_step_index") or 0))
except (TypeError, ValueError):
arc["current_step_index"] = 0
return arc
def normalize_step(step: dict, index: int = 0) -> dict:
"""Repair legacy beat-shaped steps (status_quo / choices[].injection)."""
s = dict(step)
title = (s.get("title") or s.get("goal") or "").strip()
if not title:
sq = (s.get("status_quo") or "").strip()
if sq:
title = sq.split(".")[0].strip()[:120] or sq[:120]
else:
title = f"Шаг {index + 1}"
s["id"] = (s.get("id") or f"s{index + 1}").strip()
s["title"] = title[:120]
s["goal"] = (s.get("goal") or title).strip()[:200]
s.setdefault(
"completion_criteria",
f"Сцена естественно завершает эпизод: {title}",
)
s.setdefault(
"character_guidance",
"Оставайся в роли; веди сцену к цели шага без телепортов.",
)
inj = resolve_step_injection(s)
if inj:
s["injection"] = inj
return s
def resolve_step_injection(step: dict | None) -> str:
if not isinstance(step, dict):
return ""
inj = (step.get("injection") or "").strip()
if inj:
return inj
for item in step.get("choices") or []:
if not isinstance(item, dict):
continue
c_inj = (item.get("injection") or "").strip()
if c_inj:
return c_inj
return (step.get("status_quo") or "").strip()
def format_new_arc_opening(arc: dict | None, step: dict | None, *, lang: str = "ru") -> str:
arc = arc or {}
title = (arc.get("title") or "Новая арка").strip()
inj = resolve_step_injection(step)
if not inj:
inj = (arc.get("global_story") or "").strip()[:400]
if lang == "ru":
header = f"📖 Новая арка: «{title}»"
else:
header = f"📖 New arc: «{title}»"
return f"{header}\n\n{inj}".strip() if inj else header
def get_current_step(arc: dict | None) -> dict | None:
arc = arc or {}
steps = arc.get("steps") or []
if not isinstance(steps, list) or not steps:
return None
idx = int(arc.get("current_step_index") or 0)
if idx < 0 or idx >= len(steps):
return None
step = steps[idx]
return step if isinstance(step, dict) else None
def step_progress(arc: dict | None) -> tuple[int, int]:
arc = arc or {}
steps = arc.get("steps") or []
total = len(steps) if isinstance(steps, list) else 0
idx = int(arc.get("current_step_index") or 0)
if total == 0:
return 0, 0
return min(idx + 1, total), total
def is_arc_completed(arc: dict | None) -> bool:
arc = arc or {}
if arc.get("status") == "completed":
return True
steps = arc.get("steps") or []
idx = int(arc.get("current_step_index") or 0)
return bool(steps) and idx >= len(steps)
def should_show_step_injection(arc: dict) -> bool:
if is_arc_completed(arc):
return False
meta = arc.get("meta") if isinstance(arc.get("meta"), dict) else {}
shown = meta.get("injection_shown_for_step")
idx = int(arc.get("current_step_index") or 0)
return shown != idx
def mark_injection_shown(arc: dict) -> None:
meta = arc.get("meta")
if not isinstance(meta, dict):
meta = {}
arc["meta"] = meta
meta["injection_shown_for_step"] = int(arc.get("current_step_index") or 0)
def format_step_guidance_for_character(
step: dict, arc: dict | None = None, *, lang: str = "ru"
) -> str:
if not step:
return ""
goal = (step.get("goal") or step.get("title") or "").strip()
guidance = (step.get("character_guidance") or "").strip()
title = (step.get("title") or "").strip()
cur, total = step_progress(arc or {})
global_story = ((arc or {}).get("global_story") or "").strip()
ending = ((arc or {}).get("ending") or "").strip()
if lang == "ru":
lines = [
"--- Текущий шаг сюжета (ОБЯЗАТЕЛЬНОЕ направление) ---",
f"Шаг {cur}/{total}: {title}" if total else f"Шаг: {title}",
f"Цель эпизода: {goal}",
]
if guidance:
lines.append(f"Поведение персонажа: {guidance}")
if global_story:
lines.append(f"Глобальный конвой: {global_story[:400]}")
if ending:
lines.append(f"Финал арки (не раскрывать досрочно): {ending[:300]}")
lines.append(
"Веди сцену к цели шага естественно, в роли, без телепортов и смены локации без игрока."
)
lines.append("---")
else:
lines = [
"--- Current story step (MANDATORY direction) ---",
f"Step {cur}/{total}: {title}" if total else f"Step: {title}",
f"Episode goal: {goal}",
]
if guidance:
lines.append(f"Character behavior: {guidance}")
if global_story:
lines.append(f"Global arc: {global_story[:400]}")
if ending:
lines.append(f"Arc ending (do not spoil early): {ending[:300]}")
lines.append(
"Move the scene toward the step goal naturally, in character, no teleporting."
)
lines.append("---")
return "\n\n" + "\n".join(lines) + "\n"
def format_step_hint_for_character(injection: str, *, lang: str = "ru") -> str:
inj = (injection or "").strip()
if not inj:
return ""
if lang == "ru":
header = "--- Сюжетная подсказка (не цитируй дословно) ---"
footer = (
"Продолжи текущую сцену естественно по-русски; не цитируй подсказку дословно; "
"не меняй локацию без согласия игрока."
)
else:
header = "--- Plot hint (do not quote verbatim) ---"
footer = (
"Continue naturally in English; do not quote verbatim; "
"do not change location without player consent."
)
return f"\n\n{header}\n{inj}\n{footer}\n---"
def format_step_for_narrator(
arc: dict | None,
quests: list | None = None,
status_quo: str = "",
) -> str:
arc = normalize_story_arc(arc or {}) if arc else {}
parts: list[str] = []
parts.append(f"Story arc: {arc.get('title', '')} [{arc.get('status', 'active')}]")
if arc.get("global_story"):
parts.append(f"Global story: {arc['global_story'][:500]}")
if arc.get("ending"):
parts.append(f"Planned ending: {arc['ending'][:300]}")
if arc.get("reward"):
parts.append(f"Reward hook: {arc['reward'][:200]}")
cur, total = step_progress(arc)
step = get_current_step(arc)
if is_arc_completed(arc):
parts.append(
"IMPORTANT: Arc COMPLETED. Offer 2-4 choices including starting a NEW story arc "
"(e.g. label about a new adventure/chapter) while keeping character continuity."
)
elif step:
parts.append(f"Current step: {cur}/{total} — «{step.get('title', '')}»")
parts.append(f"Step goal: {step.get('goal', '')}")
parts.append(f"Completion criteria: {step.get('completion_criteria', '')}")
parts.append(
"Set step_complete:true ONLY when completion_criteria are clearly met in recent chat. "
"Do NOT use quest_updates for step progression — the engine handles quests."
)
else:
parts.append("No active step — arc may need rollover.")
if quests:
parts.append("Quest log:")
for q in quests:
parts.append(f" [{q.get('status', 'active')}] {q.get('title', '')}")
sq = (status_quo or "").strip()
if sq:
parts.append(f"Status quo: {sq[:400]}")
return "\n".join(parts)
async def sync_quest_to_current_step(session_id: str, arc: dict) -> int:
from services.memory import get_quests, upsert_quest
arc = normalize_story_arc(arc)
quests = await get_quests(session_id)
step = get_current_step(arc)
closed = 0
if is_arc_completed(arc) or not step:
for q in quests:
if q.get("status") == "active":
await upsert_quest(session_id, q["title"], "done")
closed += 1
return closed
target_title = (step.get("title") or "Current step").strip()[:120]
for q in quests:
if q.get("status") == "active" and (q.get("title") or "").strip() != target_title:
await upsert_quest(session_id, q["title"], "done")
closed += 1
existing_titles = {(q.get("title") or "").strip() for q in quests}
if target_title not in existing_titles:
await upsert_quest(session_id, target_title, "active")
else:
for q in quests:
if (q.get("title") or "").strip() == target_title and q.get("status") != "active":
await upsert_quest(session_id, target_title, "active")
return closed
async def apply_step_advance(session_id: str, arc: dict) -> dict:
from services.memory import update_session_plot_arc, upsert_quest
arc = normalize_story_arc(arc)
steps = arc.get("steps") or []
idx = int(arc.get("current_step_index") or 0)
result = {
"advanced": False,
"arc_completed": False,
"new_step": None,
"injection": "",
"step_index": idx,
}
if is_arc_completed(arc) or idx >= len(steps):
arc["status"] = "completed"
result["arc_completed"] = True
await sync_quest_to_current_step(session_id, arc)
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
return result
old_step = steps[idx] if idx < len(steps) else None
if old_step and isinstance(old_step, dict):
title = (old_step.get("title") or "").strip()
if title:
await upsert_quest(session_id, title, "done")
idx += 1
arc["current_step_index"] = idx
if idx >= len(steps):
arc["status"] = "completed"
result["arc_completed"] = True
await sync_quest_to_current_step(session_id, arc)
else:
new_step = steps[idx]
result["advanced"] = True
result["new_step"] = new_step
result["step_index"] = idx
if isinstance(new_step, dict):
result["injection"] = (new_step.get("injection") or "").strip()
await sync_quest_to_current_step(session_id, arc)
meta = arc.get("meta")
if not isinstance(meta, dict):
meta = {}
arc["meta"] = meta
meta.pop("injection_shown_for_step", None)
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
logger.info("apply_step_advance: idx=%s completed=%s", idx, result["arc_completed"])
return result
async def reconcile_story_arc(
session_id: str,
*,
persona_name: str = "Character",
genre: str = "adventure",
) -> tuple[dict, bool]:
from services.memory import get_session, update_session_plot_arc
session = await get_session(session_id)
if not session or not session.get("rpg_enabled"):
return {}, False
try:
arc = json.loads(session.get("plot_arc_json") or "{}")
except (json.JSONDecodeError, TypeError):
arc = {}
if not isinstance(arc, dict):
arc = {}
before = json.dumps(arc, sort_keys=True)
arc = normalize_story_arc(arc, genre=session.get("genre") or genre)
changed = before != json.dumps(arc, sort_keys=True)
closed = await sync_quest_to_current_step(session_id, arc)
if closed:
changed = True
if changed:
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
return arc, changed
async def apply_story_post(
session_id: str,
post: dict,
arc: dict,
rpg_settings: dict,
) -> dict:
from services.memory import get_session
arc = normalize_story_arc(arc)
out = {
"step_advanced": False,
"arc_completed": False,
"step_injection": "",
"new_step_title": "",
"arc": arc,
}
if not rpg_settings.get("quests", True) or is_arc_completed(arc):
return out
note = (post.get("step_completion_note") or "").strip()
step_complete = bool(post.get("step_complete"))
# LLM sometimes provides a completion note but misses the boolean.
# Treat a non-empty note as a conservative signal to advance.
if not step_complete and note:
step_complete = True
logger.info("step_complete fallback via note: %s", note[:200])
if not step_complete:
return out
if note:
logger.info("step_complete: %s", note[:200])
advance = await apply_step_advance(session_id, arc)
out["step_advanced"] = advance.get("advanced", False)
out["arc_completed"] = advance.get("arc_completed", False)
out["step_injection"] = advance.get("injection") or ""
new_step = advance.get("new_step")
if isinstance(new_step, dict):
out["new_step_title"] = (new_step.get("title") or "").strip()
session = await get_session(session_id) or {}
try:
out["arc"] = json.loads(session.get("plot_arc_json") or "{}")
except (json.JSONDecodeError, TypeError):
pass
return out
NEW_ARC_CHOICE_MARKERS = (
"новая арка",
"новую арку",
"new arc",
"new chapter",
"следующая арка",
"начать новую",
# LLM/UI sometimes label next-arc choices as "new quest" instead of "new arc".
# The router still keys off this function, so we recognize both.
"новый квест",
"новую квест",
"начать новый квест",
"new quest",
"start new quest",
)
def is_new_arc_request(message: str) -> bool:
t = (message or "").lower()
return any(m in t for m in NEW_ARC_CHOICE_MARKERS)
def normalize_new_arc_first(value: str | None) -> str | None:
v = (value or "").strip().lower()
return v if v in ("user", "character") else None
def new_arc_roll_choice(*, lang: str = "ru") -> dict:
label = "Начать новую арку" if lang == "ru" else "Start new arc"
return {
"id": "new_arc",
"type": "new_arc_roll",
"label": label,
"source": "story_engine",
}
def filter_new_arc_noise_choices(choices: list) -> list:
"""Drop LLM 'start new quest' duplicates when we show the structured roll button."""
out = []
for c in choices or []:
if not isinstance(c, dict):
continue
if c.get("type") == "new_arc_roll":
out.append(c)
continue
if is_new_arc_request(c.get("label", "")):
continue
out.append(c)
return out
def append_new_arc_roll_choice(choices: list, *, lang: str = "ru") -> list:
choices = filter_new_arc_noise_choices(choices)
if any(c.get("type") == "new_arc_roll" for c in choices):
return choices
return [*choices, new_arc_roll_choice(lang=lang)]
async def roll_next_arc(
session_id: str,
persona: dict,
greeting_or_context: str,
genre: str,
*,
lang: str = "ru",
recent_context: str = "",
facts_block: str = "",
) -> dict:
from services.memory import get_session, update_session_plot_arc
from services.rpg_plot import generate_next_arc
session = await get_session(session_id) or {}
try:
old_arc = json.loads(session.get("plot_arc_json") or "{}")
except (json.JSONDecodeError, TypeError):
old_arc = {}
summary = (
f"Title: {old_arc.get('title', '')}. "
f"Story: {(old_arc.get('global_story') or '')[:500]}. "
f"Ending: {(old_arc.get('ending') or '')[:300]}. "
f"Reward: {(old_arc.get('reward') or '')[:200]}."
)
arc_num = int((old_arc.get("meta") or {}).get("arc_number") or 1) + 1
ctx_parts = [recent_context.strip(), greeting_or_context.strip()]
combined_context = "\n".join(p for p in ctx_parts if p)
new_arc = await generate_next_arc(
persona.get("name", "Character"),
persona.get("description", ""),
persona.get("scenario", ""),
combined_context,
previous_arc_summary=summary,
facts_block=facts_block,
genre=genre,
lang=lang,
)
if not new_arc:
return old_arc
new_arc = normalize_story_arc(new_arc, genre=genre)
meta = new_arc.get("meta") or {}
meta["arc_number"] = arc_num
meta["previous_arc_summary"] = summary[:800]
new_arc["meta"] = meta
new_arc["current_step_index"] = 0
new_arc["status"] = "active"
await update_session_plot_arc(session_id, json.dumps(new_arc, ensure_ascii=False))
await sync_quest_to_current_step(session_id, new_arc)
logger.info("roll_next_arc: arc #%s «%s»", arc_num, new_arc.get("title"))
return new_arc
+74
View File
@@ -463,6 +463,73 @@ header h1 { font-size: 1.1rem; color: #e94560; }
background: rgba(201, 162, 39, 0.2); 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 { .typing {
align-self: flex-start; align-self: flex-start;
@@ -763,6 +830,13 @@ textarea:focus { border-color: #e94560; }
text-transform: uppercase; letter-spacing: 0.05em; text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 4px; 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 { .quest-panel-hint {
font-size: 0.68rem; font-size: 0.68rem;
color: #555; color: #555;
+1
View File
@@ -28,6 +28,7 @@
<div class="session-list" id="sessionList"></div> <div class="session-list" id="sessionList"></div>
<div class="quest-panel hidden" id="questPanel"> <div class="quest-panel hidden" id="questPanel">
<div class="quest-panel-header">Квесты</div> <div class="quest-panel-header">Квесты</div>
<div class="quest-panel-arc-header hidden" id="questPanelHeader"></div>
<p class="quest-panel-hint" id="questPanelHint">Клик по 🔸 — выбор, затем кнопка ниже</p> <p class="quest-panel-hint" id="questPanelHint">Клик по 🔸 — выбор, затем кнопка ниже</p>
<div id="questList"></div> <div id="questList"></div>
<div class="quest-panel-actions" id="questPanelActions"> <div class="quest-panel-actions" id="questPanelActions">
+111 -16
View File
@@ -154,12 +154,63 @@ export function ensureMessageActionsLast(wrapper) {
if (actions) wrapper.appendChild(actions); if (actions) wrapper.appendChild(actions);
} }
function renderNewArcRollButton(wrapper, choice) {
removeChoiceRows(wrapper);
const row = document.createElement('div');
row.className = 'choice-row choice-row-new-arc';
const panel = document.createElement('div');
panel.className = 'new-arc-roll';
const hdr = document.createElement('div');
hdr.className = 'new-arc-roll-header';
hdr.textContent = choice?.label || 'Начать новую арку';
panel.appendChild(hdr);
const hint = document.createElement('div');
hint.className = 'new-arc-roll-hint';
hint.textContent = 'Арка завершена. Можно продолжить эпилог или начать новую — кто ходит первым после инъекта рассказчика?';
panel.appendChild(hint);
const split = document.createElement('div');
split.className = 'new-arc-roll-split';
const mkHalf = (side, icon, title, subtitle, first) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `new-arc-half new-arc-half-${side}`;
btn.innerHTML = `<span class="new-arc-half-icon">${icon}</span><span class="new-arc-half-title">${title}</span><span class="new-arc-half-sub">${subtitle}</span>`;
btn.addEventListener('click', () => {
removeChoiceRows(wrapper);
sendMessage(choice?.label || 'Начать новую арку', {
isNarratorChoice: true,
newArcFirst: first,
});
});
return btn;
};
split.appendChild(mkHalf('user', '👤', 'Игрок', 'первый ход после инъекта', 'user'));
split.appendChild(mkHalf('character', '🤖', 'Персонаж', 'открывает новую арку', 'character'));
panel.appendChild(split);
row.appendChild(panel);
wrapper.appendChild(row);
ensureMessageActionsLast(wrapper);
}
export function renderChoices(wrapper, choices) { export function renderChoices(wrapper, choices) {
if (!choices?.length || !wrapper) return; if (!choices?.length || !wrapper) return;
removeChoiceRows(wrapper); removeChoiceRows(wrapper);
const plotChoices = choices.filter(c => c?.source === 'plot_beat'); const newArc = choices.find(c => c?.type === 'new_arc_roll');
const otherChoices = choices.filter(c => c?.source !== 'plot_beat'); if (newArc) {
renderNewArcRollButton(wrapper, newArc);
return;
}
const isPlot = c => c?.source === 'plot_beat' || c?.source === 'plot_step';
const plotChoices = choices.filter(isPlot);
const otherChoices = choices.filter(c => !isPlot(c));
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'choice-row'; row.className = 'choice-row';
@@ -167,7 +218,7 @@ export function renderChoices(wrapper, choices) {
const appendBtn = (container, c) => { const appendBtn = (container, c) => {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = c.source === 'plot_beat' ? 'choice-btn choice-btn-plot' : 'choice-btn'; btn.className = isPlot(c) ? 'choice-btn choice-btn-plot' : 'choice-btn';
const label = c.label || ''; const label = c.label || '';
btn.textContent = label; btn.textContent = label;
if (c.beat_title) btn.title = c.beat_title; if (c.beat_title) btn.title = c.beat_title;
@@ -251,10 +302,11 @@ function showNarratorActivityHint(wrapper, meta) {
if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`); if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`);
if (meta.status_quo) parts.push('🌍 status_quo'); if (meta.status_quo) parts.push('🌍 status_quo');
if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`); if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`);
if (meta.beat_mode === 'after_dice') parts.push('📜 beat (d20)'); if (meta.step_advanced) parts.push('📜 шаг +1');
if (meta.beat_mode === 'llm') parts.push('📜 beat (AI)'); if (meta.arc_completed) parts.push('🏁 арка завершена');
if (meta.beat_mode === 'stuck_recovery') parts.push('📜 beat (recovery)'); if (meta.new_arc_rolled) parts.push('📖 новая арка');
if (meta.beat_mode === 'trigger') parts.push('📜 beat (keywords)'); if (meta.story_step) parts.push(`📜 ${meta.story_step}`);
if (meta.rp_language) parts.push(`🌐 ${meta.rp_language}`);
if (meta.arc_pruned) parts.push(`🧹 ${meta.arc_pruned} beat`); if (meta.arc_pruned) parts.push(`🧹 ${meta.arc_pruned} beat`);
if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`); if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`);
} }
@@ -299,13 +351,27 @@ function syncQuestActionButtons() {
if (failBtn) failBtn.disabled = !active; if (failBtn) failBtn.disabled = !active;
} }
export function updateQuestPanel(quests) { export function updateQuestPanel(quests, storyArc = null) {
const list = document.getElementById('questList'); const list = document.getElementById('questList');
const actions = document.getElementById('questPanelActions'); const actions = document.getElementById('questPanelActions');
const header = document.getElementById('questPanelHeader');
if (!list) return; if (!list) return;
_questsCache = quests || []; _questsCache = quests || [];
list.innerHTML = ''; list.innerHTML = '';
if (header && storyArc) {
const steps = storyArc.steps || [];
const idx = Number(storyArc.current_step_index ?? 0);
const cur = steps.length ? Math.min(idx + 1, steps.length) : 0;
const title = storyArc.title || 'Арка';
const status = storyArc.status === 'completed' ? ' · завершена' : '';
header.textContent = `Арка: ${title} · шаг ${cur}/${steps.length || '?'}${status}`;
header.classList.remove('hidden');
} else if (header) {
header.textContent = '';
header.classList.add('hidden');
}
if (!_questsCache.length) { if (!_questsCache.length) {
_selectedQuestId = null; _selectedQuestId = null;
syncQuestActionButtons(); syncQuestActionButtons();
@@ -577,7 +643,10 @@ export async function reloadChatFromServer(id) {
try { try {
const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
if (data?.text) renderNarratorMessage(data); if (data?.text) renderNarratorMessage(data);
} catch { /* ignore bad narrator payload */ } } catch {
const plain = (m.content || '').trim();
if (plain) renderNarratorMessage({ text: plain });
}
return; return;
} }
addMessage( addMessage(
@@ -694,8 +763,11 @@ async function consumeStream(res) {
data.assistant_message_id, data.assistant_message_id,
data.choices, data.choices,
); );
} else if (data.choices?.length && bubble) { } else if (data.choices?.length) {
renderChoices(bubble.parentElement, data.choices); const choiceHost = bubble?.parentElement
|| [...dom.messagesEl.querySelectorAll('.message.narrator')].pop()
|| [...dom.messagesEl.querySelectorAll('.message.assistant')].pop();
if (choiceHost) renderChoices(choiceHost, data.choices);
} }
if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug); if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
if (data.narrator_meta && bubble?.parentElement) { if (data.narrator_meta && bubble?.parentElement) {
@@ -703,7 +775,9 @@ async function consumeStream(res) {
} }
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity); if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats); if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (data.quests?.length) updateQuestPanel(data.quests); if (data.quests !== undefined) {
updateQuestPanel(data.quests, data.story_arc ?? null);
}
_pendingUserBubble = null; _pendingUserBubble = null;
@@ -822,21 +896,42 @@ export function clearMessages() {
} }
} }
export async function sendMessage(text, isNarratorChoice = false) { export async function sendMessage(text, choiceOpts = false) {
if (typeof text !== 'string') text = dom.inputEl.value.trim(); if (typeof text !== 'string') text = dom.inputEl.value.trim();
if (!text || !sessionId) return; if (!text || !sessionId) return;
let isNarratorChoice = false;
let newArcFirst = null;
if (typeof choiceOpts === 'boolean') {
isNarratorChoice = choiceOpts;
} else if (choiceOpts && typeof choiceOpts === 'object') {
isNarratorChoice = !!choiceOpts.isNarratorChoice;
newArcFirst = choiceOpts.newArcFirst || null;
}
dom.inputEl.value = ''; dom.inputEl.value = '';
dom.inputEl.style.height = 'auto'; dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true; dom.sendBtn.disabled = true;
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text; let userContent = text;
dom.messagesEl.querySelectorAll('.message.assistant').forEach(w => removeChoiceRows(w)); if (isNarratorChoice && newArcFirst) {
const who = newArcFirst === 'user' ? 'игрок' : 'персонаж';
userContent = `[Player chose: Начать новую арку — первый ход: ${who}]`;
} else if (isNarratorChoice) {
userContent = `[Player chose: ${text}]`;
}
dom.messagesEl.querySelectorAll('.message.assistant, .message.narrator').forEach(w => removeChoiceRows(w));
_pendingUserBubble = addMessage('user', userContent); _pendingUserBubble = addMessage('user', userContent);
showTyping(); showTyping();
try { try {
const res = await fetch('/chat/stream', { const res = await fetch('/chat/stream', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }), body: JSON.stringify({
message: text,
session_id: sessionId,
is_narrator_choice: isNarratorChoice,
new_arc_first: newArcFirst,
}),
}); });
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status); if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
removeTyping(); removeTyping();
+1 -1
View File
@@ -80,7 +80,7 @@ async function bootstrapRpg(sid, personaId, genreValue, settings) {
const { updateAffinityDisplay } = await import('./chat.js'); const { updateAffinityDisplay } = await import('./chat.js');
updateAffinityDisplay(data.affinity); updateAffinityDisplay(data.affinity);
} }
if (data.quests) updateQuestPanel(data.quests); if (data.quests) updateQuestPanel(data.quests, data.plot_arc ?? null);
if (data.plot_arc) { if (data.plot_arc) {
const title = data.plot_arc.title || ''; const title = data.plot_arc.title || '';
const hint = data.plot_arc.next_beat_hint || ''; const hint = data.plot_arc.next_beat_hint || '';
+1 -1
View File
@@ -225,7 +225,7 @@ export async function createNewChatFromWizard() {
await reloadChatFromServer(sid); await reloadChatFromServer(sid);
if (openingData?.quests?.length) { if (openingData?.quests?.length) {
updateQuestPanel(openingData.quests); updateQuestPanel(openingData.quests, openingData.plot_arc ?? null);
} }
if (openingData?.affinity !== undefined) { if (openingData?.affinity !== undefined) {
updateAffinityDisplay(openingData.affinity); updateAffinityDisplay(openingData.affinity);
+128 -15
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
from unittest.mock import AsyncMock, patch
from services.rpg_plot import ( from services.rpg_plot import (
prune_beats_for_done_quests, prune_beats_for_done_quests,
@@ -6,6 +7,10 @@ from services.rpg_plot import (
should_advance_arc_keywords, should_advance_arc_keywords,
pop_matching_beats, pop_matching_beats,
dice_outcome_to_beat_trigger, dice_outcome_to_beat_trigger,
active_quest_titles_to_close,
can_fire_beat,
record_beat_fired,
complete_quest_for_fired_beat,
) )
@@ -21,21 +26,60 @@ def test_prune_removes_beat_when_quest_done():
assert arc["beats"] == [] assert arc["beats"] == []
def test_stuck_recovery_fires_when_no_active_quests(): def test_stuck_recovery_fires_when_no_active_quests_and_cooldown_ok():
arc = { arc = {
"beats": [ "beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]}, {"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
] ],
"meta": {"last_beat_fired_at_user_turn": 1},
} }
quests = [{"title": "Old", "status": "done"}] quests = [{"title": "Old", "status": "done"}]
async def run(): async def run():
return await process_arc_beats(arc, quests, "hello") return await process_arc_beats(arc, quests, "hello", user_turn=5)
arc2, fired, pruned, mode = asyncio.run(run()) arc2, fired, pruned, mode, extras = asyncio.run(run())
assert mode == "stuck_recovery" assert mode == "stuck_recovery"
assert fired[0]["title"] == "New Beat" assert fired[0]["title"] == "New Beat"
assert arc2["beats"] == [] assert arc2["beats"] == []
assert extras.get("cooldown_skipped") is False
def test_stuck_recovery_skipped_when_cooldown_active():
arc = {
"beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
],
"meta": {"last_beat_fired_at_user_turn": 4},
}
quests = []
async def run():
return await process_arc_beats(arc, quests, "hello", user_turn=5)
arc2, fired, _, mode, extras = asyncio.run(run())
assert mode == ""
assert fired == []
assert extras.get("cooldown_skipped") is True
assert len(arc2["beats"]) == 1
def test_stuck_recovery_skipped_after_reconcile_closed_quests():
arc = {
"beats": [
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
],
}
quests = []
async def run():
return await process_arc_beats(
arc, quests, "hello", user_turn=10, reconcile_closed_count=2
)
_, fired, _, mode, _ = asyncio.run(run())
assert mode == ""
assert fired == []
def test_dice_outcome_maps_to_after_fail(): def test_dice_outcome_maps_to_after_fail():
@@ -44,7 +88,7 @@ def test_dice_outcome_maps_to_after_fail():
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success" assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
def test_after_fail_beat_fires_on_dice_failure(): def test_after_fail_beat_fires_on_dice_failure_only_with_needs_check():
arc = { arc = {
"beats": [ "beats": [
{ {
@@ -54,25 +98,94 @@ def test_after_fail_beat_fires_on_dice_failure():
"injection": "The stumble leaves you both shaken.", "injection": "The stumble leaves you both shaken.",
"choices": [{"id": "a", "label": "Try again"}], "choices": [{"id": "a", "label": "Try again"}],
}, },
]
}
async def run(needs_check: bool):
return await process_arc_beats(
arc, [], "продолжаем", last_dice_outcome="failure", needs_check=needs_check, user_turn=3
)
arc_ok, fired, _, mode, _ = asyncio.run(run(True))
assert mode == "after_dice"
assert fired[0]["id"] == "b_fail"
assert arc_ok["beats"] == []
arc2 = {
"beats": [
{ {
"id": "b_ok", "id": "b_fail",
"title": "Victory Lap", "title": "Dust Yourself Off",
"trigger": "event_driven:after_success", "trigger": "event_driven:after_fail",
"choices": [], "choices": [],
}, },
] ]
} }
async def run(): async def run_no_check():
with patch(
"services.rpg_plot.classify_plot_beat",
new_callable=AsyncMock,
return_value=None,
):
return await process_arc_beats( return await process_arc_beats(
arc, [], "продолжаем разговор", last_dice_outcome="failure" arc2,
[],
"продолжаем",
last_dice_outcome="failure",
needs_check=False,
user_turn=3,
allow_stuck_recovery=False,
) )
arc2, fired, _, mode = asyncio.run(run()) _, fired2, _, mode2, _ = asyncio.run(run_no_check())
assert mode == "after_dice" assert mode2 == ""
assert fired[0]["id"] == "b_fail" assert fired2 == []
assert len(arc2["beats"]) == 1
assert arc2["beats"][0]["id"] == "b_ok"
def test_orphan_active_quests_closed_when_not_in_arc():
arc = {
"beats": [
{
"id": "b_new_3",
"title": "Defensive Magic",
"trigger": "event_driven:after_fail",
"choices": [],
}
]
}
quests = [
{"title": "Floral Whispers", "status": "active"},
{"title": "Gift from the Glade", "status": "active"},
{"title": "Defensive Magic", "status": "active"},
{"title": "Old Done", "status": "done"},
]
closed = active_quest_titles_to_close(arc, quests)
assert set(closed) == {"Floral Whispers", "Gift from the Glade"}
def test_can_fire_beat_respects_min_gap():
arc = {"meta": {"last_beat_fired_at_user_turn": 5}}
assert can_fire_beat(arc, 6, min_gap=2) is False
assert can_fire_beat(arc, 7, min_gap=2) is True
def test_record_beat_fired_updates_meta():
arc = {}
record_beat_fired(arc, {"id": "b1"}, 8)
assert arc["meta"]["last_beat_fired_at_user_turn"] == 8
assert arc["meta"]["last_beat_id"] == "b1"
def test_complete_quest_for_fired_beat():
beat = {"id": "b1", "title": "Pancake Talk", "injection": "..."}
async def run():
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as mock:
await complete_quest_for_fired_beat("sess-1", beat)
mock.assert_called_once_with("sess-1", "Pancake Talk", "done")
asyncio.run(run())
def test_keyword_fallback_travel(): def test_keyword_fallback_travel():
+13 -3
View File
@@ -29,7 +29,7 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
}, },
{"role": "narrator", "content": narrator_json}, {"role": "narrator", "content": narrator_json},
] ]
llm = messages_for_llm(history, "system+runtime") llm = messages_for_llm(history, "system+runtime", rp_lang="en")
user_msgs = [m for m in llm if m["role"] == "user"] user_msgs = [m for m in llm if m["role"] == "user"]
assert len(user_msgs) == 2 assert len(user_msgs) == 2
assert "canonical outcome" in user_msgs[0]["content"].lower() assert "canonical outcome" in user_msgs[0]["content"].lower()
@@ -39,14 +39,24 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
assert "Do NOT write a success version" in user_msgs[1]["content"] assert "Do NOT write a success version" in user_msgs[1]["content"]
def test_format_narrator_failure_wording(): def test_format_narrator_failure_wording_en():
text = format_narrator_outcome_for_llm( text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "It failed."} {"roll": 5, "outcome": "failure", "text": "It failed."},
lang="en",
) )
assert "FAILED" in text assert "FAILED" in text
assert "Do NOT write a success version" in text assert "Do NOT write a success version" in text
def test_format_narrator_failure_wording_ru():
text = format_narrator_outcome_for_llm(
{"roll": 5, "outcome": "failure", "text": "Не вышло."},
lang="ru",
)
assert "ОБЯЗАТЕЛЬНО" in text
assert "Не удалось" in text or "НЕ удалось" in text
def test_parse_narrator_message_roundtrip(): def test_parse_narrator_message_roundtrip():
raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"}) raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"})
data = parse_narrator_message(raw) data = parse_narrator_message(raw)
+26 -2
View File
@@ -1,4 +1,9 @@
from services.rpg_plot import choices_from_beat, choices_from_narrator, normalize_choice from services.rpg_plot import (
choices_from_beat,
choices_from_narrator,
normalize_choice,
format_beat_injection_for_character,
)
def test_choices_from_beat_tags_source(): def test_choices_from_beat_tags_source():
@@ -13,7 +18,7 @@ def test_choices_from_beat_tags_source():
} }
out = choices_from_beat(beat) out = choices_from_beat(beat)
assert len(out) == 2 assert len(out) == 2
assert out[0]["source"] == "plot_beat" assert out[0]["source"] == "plot_step"
assert out[0]["beat_title"] == "Rest Stop Confession" assert out[0]["beat_title"] == "Rest Stop Confession"
assert out[0]["beat_id"] == "b_new_1" assert out[0]["beat_id"] == "b_new_1"
assert "beat_injection" in out[0] assert "beat_injection" in out[0]
@@ -25,5 +30,24 @@ def test_choices_from_narrator_tags_source():
assert "beat_title" not in out[0] assert "beat_title" not in out[0]
def test_beat_injection_block_for_character_prompt():
block = format_beat_injection_for_character(
"Over pancakes, Luna steals glances at you.",
lang="en",
)
assert "Plot hint" in block
assert "Over pancakes" in block
assert format_beat_injection_for_character("") == ""
def test_beat_injection_block_ru():
block = format_beat_injection_for_character(
"Луна крадёт взгляды через блины.",
lang="ru",
)
assert "Сюжетная подсказка" in block
assert "по-русски" in block
def test_normalize_choice_skips_empty_label(): def test_normalize_choice_skips_empty_label():
assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None
+31
View File
@@ -1,10 +1,14 @@
from services.rpg_facts import ( from services.rpg_facts import (
FACTS_COMPRESS_SYSTEM,
merge_facts, merge_facts,
parse_facts_list, parse_facts_list,
facts_to_prompt, facts_to_prompt,
facts_list_to_json, facts_list_to_json,
dedupe_facts_fuzzy, dedupe_facts_fuzzy,
facts_are_similar, facts_are_similar,
is_likely_narrative_event,
filter_durable_facts,
validate_compressed_against_source,
) )
@@ -34,6 +38,33 @@ def test_dedupe_collapses_duplicates():
assert len(out) == 2 assert len(out) == 2
def test_facts_compress_system_format_escapes_json_braces():
system = FACTS_COMPRESS_SYSTEM.format(target=22)
assert "Target at most 22 facts" in system
assert '{"text":' in system
def test_narrative_event_facts_filtered():
assert is_likely_narrative_event("Сара и Григо отправились через портал")
assert not is_likely_narrative_event("Сара носит красный спортивный костюм")
facts = filter_durable_facts([
{"text": "Сара любит шоколад", "rp_day": "день 1"},
{"text": "Сара и Григо решили вызвать СОДП", "rp_day": "день 2"},
])
assert len(facts) == 1
def test_validate_compressed_rejects_hallucinations():
original = [{"text": "Сара любит шоколад", "rp_day": "день 1"}]
compressed = [
{"text": "Сара любит шоколад", "rp_day": "день 1"},
{"text": "Сара — королева галактики", "rp_day": "день 9"},
]
out = validate_compressed_against_source(original, compressed)
assert len(out) == 1
assert "шоколад" in out[0]["text"]
def test_merge_with_rp_day(): def test_merge_with_rp_day():
existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}]) existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}])
merged = parse_facts_list( merged = parse_facts_list(
+22
View File
@@ -0,0 +1,22 @@
from services.rpg_locale import infer_rp_language, locale_instruction
def test_infer_ru_from_cyrillic_chat():
msgs = [
{"role": "user", "content": "Привет, как дела?"},
{"role": "assistant", "content": "Нормально, идём дальше."},
]
assert infer_rp_language(msgs) == "ru"
def test_infer_en_from_latin_chat():
msgs = [
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "Fine, let's continue."},
]
assert infer_rp_language(msgs) == "en"
def test_locale_instruction_ru():
assert "Russian" in locale_instruction("ru")
assert "MUST be in Russian" in locale_instruction("ru")
+195
View File
@@ -0,0 +1,195 @@
import asyncio
import json
from unittest.mock import AsyncMock, patch
from services.rpg_story import (
migrate_beats_to_steps,
normalize_story_arc,
get_current_step,
step_progress,
is_arc_completed,
sync_quest_to_current_step,
apply_step_advance,
apply_story_post,
is_new_arc_request,
normalize_new_arc_first,
new_arc_roll_choice,
append_new_arc_roll_choice,
format_step_guidance_for_character,
format_new_arc_opening,
normalize_step,
resolve_step_injection,
roll_next_arc,
)
def test_migrate_beats_to_steps():
arc = {
"beats": [
{"id": "b1", "title": "Meet Yuki", "injection": "Door opens.", "choices": []},
]
}
out = migrate_beats_to_steps(arc)
assert len(out["steps"]) == 1
assert "beats" not in out
assert out["steps"][0]["title"] == "Meet Yuki"
def test_step_progress():
arc = normalize_story_arc({
"steps": [{"id": "s1", "title": "A"}, {"id": "s2", "title": "B"}],
"current_step_index": 1,
})
assert step_progress(arc) == (2, 2)
def test_is_arc_completed():
arc = normalize_story_arc({
"steps": [{"id": "s1", "title": "A"}],
"current_step_index": 1,
"status": "active",
})
assert is_arc_completed(arc)
def test_new_arc_request_detected():
assert is_new_arc_request("Начать новую арку")
assert is_new_arc_request("Начать новый квест: 'Тайна плазменных шаров'")
assert is_new_arc_request("Start new quest")
assert not is_new_arc_request("привет")
def test_normalize_new_arc_first():
assert normalize_new_arc_first("user") == "user"
assert normalize_new_arc_first("Character") == "character"
assert normalize_new_arc_first("other") is None
def test_new_arc_roll_choice_and_filter():
base = new_arc_roll_choice()
assert base["type"] == "new_arc_roll"
merged = append_new_arc_roll_choice([
{"id": "x", "label": "Начать новый квест: Foo", "source": "narrator"},
{"id": "y", "label": "Пойти спать", "source": "narrator"},
])
assert len(merged) == 2
assert merged[-1]["type"] == "new_arc_roll"
assert not any("новый квест" in (c.get("label") or "").lower() for c in merged[:-1])
def test_normalize_step_repairs_legacy_beat_shape():
step = normalize_step({
"status_quo": "Сара и Григо прибывают к лаборатории.",
"choices": [{"label": "Войти", "injection": "*Дверь скрипит*"}],
}, 0)
assert step["title"]
assert step["completion_criteria"]
assert resolve_step_injection(step) == "*Дверь скрипит*"
def test_format_new_arc_opening():
arc = {"title": "Тайна", "global_story": "Расследование шаров."}
step = {"status_quo": "У ворот лаборатории тихо.", "choices": []}
text = format_new_arc_opening(arc, step, lang="ru")
assert "Тайна" in text
assert "лаборатории" in text or "Расследование" in text
def test_guidance_block_ru():
step = {"title": "Приютить", "goal": "Впустить внутрь", "character_guidance": "Робость"}
block = format_step_guidance_for_character(step, {"global_story": "x", "steps": [step]}, lang="ru")
assert "Приютить" in block
assert "ОБЯЗАТЕЛЬНОЕ" in block
def test_apply_step_advance():
arc = normalize_story_arc({
"title": "Test",
"steps": [
{"id": "s1", "title": "Step One", "choices": []},
{"id": "s2", "title": "Step Two", "injection": "Next scene.", "choices": []},
],
"current_step_index": 0,
})
async def run():
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as uq:
with patch("services.memory.update_session_plot_arc", new_callable=AsyncMock):
with patch(
"services.rpg_story.sync_quest_to_current_step",
new_callable=AsyncMock,
):
return await apply_step_advance("sess", arc), uq
result, mock_uq = asyncio.run(run())
assert result["advanced"] is True
assert result["injection"] == "Next scene."
mock_uq.assert_called()
def test_apply_story_post_step_complete_fallback_via_note():
arc = normalize_story_arc({
"title": "Test",
"steps": [
{"id": "s1", "title": "Step One", "choices": []},
{"id": "s2", "title": "Step Two", "choices": []},
],
"current_step_index": 0,
})
post = {
"step_complete": False,
"step_completion_note": "The scene clearly satisfies the completion criteria.",
}
async def run():
with patch("services.memory.get_session", new_callable=AsyncMock) as gs:
gs.return_value = {"plot_arc_json": json.dumps(arc, ensure_ascii=False)}
with patch(
"services.rpg_story.apply_step_advance",
new_callable=AsyncMock,
) as asa:
asa.return_value = {"advanced": True, "arc_completed": False, "new_step": arc["steps"][1], "injection": ""}
r = await apply_story_post("sess", post, arc, {"quests": True})
return r, asa
r, asa = asyncio.run(run())
assert r["step_advanced"] is True
assert asa.await_count == 1
def test_roll_next_arc_combines_context_for_generate_next_arc():
old_arc = normalize_story_arc({
"title": "Arc One",
"steps": [{"id": "s1", "title": "Done"}],
"current_step_index": 1,
"status": "completed",
})
new_arc = normalize_story_arc({
"title": "Arc Two",
"steps": [{"id": "s1", "title": "Start"}],
"current_step_index": 0,
})
async def run():
with patch("services.memory.get_session", new_callable=AsyncMock) as gs:
gs.return_value = {"plot_arc_json": json.dumps(old_arc, ensure_ascii=False)}
with patch("services.rpg_plot.generate_next_arc", new_callable=AsyncMock) as gna:
gna.return_value = new_arc
with patch("services.memory.update_session_plot_arc", new_callable=AsyncMock):
with patch(
"services.rpg_story.sync_quest_to_current_step",
new_callable=AsyncMock,
):
return await roll_next_arc(
"sess",
{"name": "Sarah", "description": "", "scenario": ""},
"Начать новую арку",
"adventure",
recent_context="assistant: goodbye",
), gna
result, gna = asyncio.run(run())
assert result["title"] == "Arc Two"
gna.assert_awaited_once()
assert gna.await_args.args[3] == "assistant: goodbye\nНачать новую арку"
+80
View File
@@ -0,0 +1,80 @@
import asyncio
import json
import pytest
import database.db as dbmod
import services.memory as memory
@pytest.fixture
def snapshot_db(tmp_path, monkeypatch):
db_file = tmp_path / "snapshots.db"
monkeypatch.setenv("DB_PATH", str(db_file))
monkeypatch.setattr(dbmod, "DB_PATH", str(db_file))
monkeypatch.setattr(memory, "DB_PATH", str(db_file))
asyncio.run(dbmod.init_db())
return str(db_file)
def test_snapshot_save_restore_and_delete_rollback(snapshot_db):
asyncio.run(_test_snapshot_save_restore_and_delete_rollback())
async def _test_snapshot_save_restore_and_delete_rollback():
sid = "sess_snap_test"
await memory.get_or_create_session(sid, "default")
await memory.update_session_rpg(sid, True)
await memory.update_session_facts(sid, json.dumps([{"text": "Fact A", "rp_day": "день 1"}]))
await memory.update_session_plot_arc(
sid, json.dumps({"title": "Arc", "steps": [{"id": "s1", "title": "One"}], "current_step_index": 0})
)
await memory.set_session_affinity(sid, 3)
await memory.upsert_quest(sid, "Quest one", "active")
u1 = await memory.add_message(sid, "user", "hello")
await memory.save_state_snapshot(sid, u1)
await memory.update_session_facts(sid, json.dumps([{"text": "Fact B", "rp_day": "день 2"}]))
await memory.set_session_affinity(sid, 9)
a1 = await memory.add_message(sid, "assistant", "hi")
await memory.save_state_snapshot(sid, a1)
session = await memory.get_session(sid)
assert json.loads(session["facts_json"])[0]["text"] == "Fact B"
assert session["affinity"] == 9
await memory.delete_message_and_following(sid, a1)
session = await memory.get_session(sid)
assert json.loads(session["facts_json"])[0]["text"] == "Fact A"
assert session["affinity"] == 3
quests = await memory.get_quests(sid)
assert len(quests) == 1
def test_fork_uses_snapshot_not_current_state(snapshot_db):
asyncio.run(_test_fork_uses_snapshot_not_current_state())
async def _test_fork_uses_snapshot_not_current_state():
sid = "sess_fork_src"
await memory.get_or_create_session(sid, "default")
await memory.update_session_rpg(sid, True)
u1 = await memory.add_message(sid, "user", "start")
await memory.update_session_facts(sid, json.dumps([{"text": "Early", "rp_day": "1"}]))
await memory.save_state_snapshot(sid, u1)
a1 = await memory.add_message(sid, "assistant", "reply")
await memory.update_session_facts(sid, json.dumps([{"text": "Late", "rp_day": "2"}]))
await memory.save_state_snapshot(sid, a1)
new_id = await memory.fork_session(sid, u1)
assert new_id
forked = await memory.get_session(new_id)
assert json.loads(forked["facts_json"])[0]["text"] == "Early"
hist = await memory.get_history(new_id)
roles = [m["role"] for m in hist if m["role"] != "system"]
assert roles == ["user"]
+2
View File
@@ -0,0 +1,2 @@
# generated module - copied to services/rpg_story.py by apply script
PLACEHOLDER = True
+2
View File
@@ -0,0 +1,2 @@
"""Apply linear story refactor — run once: python tools/apply_linear_story.py"""
print("placeholder")
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
Path("../services/rpg_context.py").write_text('''"""Shared context blocks for RPG narrator / plot LLM calls."""
import json
from services.rpg_story import (
format_step_for_narrator,
normalize_story_arc,
step_progress,
)
def format_narrator_context(
arc: dict | None,
quests: list | None,
status_quo: str = "",
) -> str:
arc = normalize_story_arc(arc or {}) if arc else {}
return format_step_for_narrator(arc, quests, status_quo)
def format_arc_summary_for_runtime(arc: dict | None) -> str:
arc = normalize_story_arc(arc or {}) if arc else {}
if not arc:
return ""
cur, total = step_progress(arc)
return json.dumps(
{
"title": arc.get("title"),
"global_story": (arc.get("global_story") or "")[:300],
"step": f"{cur}/{total}",
"status": arc.get("status", "active"),
},
ensure_ascii=False,
)
''', encoding='utf-8')
print('done')