Compare commits
2 Commits
6189a5fb74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f3599a859 | |||
| 01b16dbeaa |
+14
-11
@@ -165,11 +165,15 @@ sequenceDiagram
|
||||
- `status_quo_update` → `UPDATE sessions.status_quo`.
|
||||
- `scene_update` (partial) → merge в `scene_json`.
|
||||
- **Нет** d20, **нет** bubble «Рассказчик».
|
||||
3. **runtime_suffix** = `build_rpg_runtime_suffix(session)` + `narrator_extra` (directives только).
|
||||
4. `upsert_static_system_message(static)` — в БД system без RPG-блоков.
|
||||
5. `add_message(user, message)`.
|
||||
6. **context_usage** — если > 85%, в конец system добавляется `[Context: ~N% …]`.
|
||||
7. **llm_messages** = system(static+runtime) + вся история user/assistant.
|
||||
3. **Linear story (pre-stream):** `reconcile_story_arc` → один active-квест = текущий `steps[i]`:
|
||||
- `format_step_guidance_for_character` — цель шага в system CHAT.
|
||||
- При первом входе в шаг: `injection` как мягкая подсказка (`format_step_hint_for_character`).
|
||||
- Если арка завершена и игрок выбрал «новую арку» → `roll_next_arc`.
|
||||
4. **runtime_suffix** = `build_rpg_runtime_suffix(session)` + `narrator_extra` (directives + step guidance/hint).
|
||||
5. `upsert_static_system_message(static)` — в БД system без RPG-блоков.
|
||||
6. `add_message(user, message)`.
|
||||
7. **context_usage** — если > 85%, в конец system добавляется `[Context: ~N% …]`.
|
||||
8. **llm_messages** = system(static+runtime) + история; язык сессии (`infer_rp_language`) в narrator/plot/injection.
|
||||
|
||||
### Этап B — SSE stream
|
||||
|
||||
@@ -187,12 +191,11 @@ sequenceDiagram
|
||||
| # | Действие | Модель | Условие |
|
||||
|---|----------|--------|---------|
|
||||
| C1 | `generate_plot_arc` | PLOT | Только если `plot_arc_json` пуст (редко после opening) |
|
||||
| C2 | `should_advance_arc(user_message)` | код | Ключевые слова: отдых → `event_driven:rest`, путь → `travel`, помощь → `help_request` |
|
||||
| C3 | `pop_matching_beats` + injection | — | Если trig совпал с beat; choices из beat |
|
||||
| C4 | `advance_phase` | — | Если beats пусты — фаза opening→hook→… |
|
||||
| C5 | `extract_facts` | FACTS | Последние 10 реплик; merge до 80, в промпт 20 |
|
||||
| C6 | **`narrator_post`** | NARRATOR | Контекст: последние 8 реплик **включая новый ответ** |
|
||||
| C7 | **`apply_narrator_post`** | — | status_quo, affinity_delta, scene, outfit, quests, stats_delta* |
|
||||
| C2 | **`narrator_post`** | NARRATOR | Контекст: шаг N/M, completion_criteria, последние 8 реплик |
|
||||
| C3 | **`apply_narrator_post_with_story`** | — | facts, affinity, scene; `step_complete` → advance step, sync quest |
|
||||
| C4 | step choices / new arc | — | choices из нового шага; при `arc_completed` — «Начать новую арку» |
|
||||
| C5 | `extract_facts` | FACTS | Последние 10 реплик |
|
||||
| C6 | SD + SSE `done` | — | **всегда** `quests` + `story_arc` в payload (live UI) |
|
||||
| C8 | `generate_sd_prompt` + Comfy | SD | outfit + scene_json + последние 6 реплик |
|
||||
| C9 | SSE `done` | — | choices, affinity, quests, image_*, debug |
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
print('bootstrap')
|
||||
@@ -76,6 +76,7 @@ async def init_db():
|
||||
await _migrate_characters_columns(db)
|
||||
await _migrate_rpg_quests(db)
|
||||
await _migrate_action_resolutions(db)
|
||||
await _migrate_state_snapshots(db)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -178,6 +179,31 @@ async def _migrate_action_resolutions(db):
|
||||
)
|
||||
|
||||
|
||||
async def _migrate_state_snapshots(db):
|
||||
await db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS session_state_snapshots (
|
||||
message_id INTEGER PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
facts_json TEXT NOT NULL DEFAULT '[]',
|
||||
global_plot TEXT NOT NULL DEFAULT '',
|
||||
status_quo TEXT NOT NULL DEFAULT '',
|
||||
plot_arc_json TEXT NOT NULL DEFAULT '{}',
|
||||
affinity INTEGER NOT NULL DEFAULT 0,
|
||||
outfit_json TEXT NOT NULL DEFAULT '[]',
|
||||
scene_json TEXT NOT NULL DEFAULT '{}',
|
||||
narrative_stats_json TEXT NOT NULL DEFAULT '{"lust":0,"stamina":10,"tension":0}',
|
||||
quests_json TEXT NOT NULL DEFAULT '[]',
|
||||
action_resolutions_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_state_snapshots_session
|
||||
ON session_state_snapshots(session_id);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def _migrate_characters_columns(db):
|
||||
async with db.execute("PRAGMA table_info(characters)") as cur:
|
||||
cols = {row[1] for row in await cur.fetchall()}
|
||||
|
||||
@@ -8,6 +8,9 @@ class ChatRequest(BaseModel):
|
||||
is_narrator_choice: bool = False
|
||||
skip_user_add: bool = False
|
||||
first_mes_override: Optional[str] = None
|
||||
# After arc completion: "user" = injection only, player speaks next;
|
||||
# "character" = injection then character opens the new arc.
|
||||
new_arc_first: Optional[str] = None
|
||||
|
||||
|
||||
class MessageEditRequest(BaseModel):
|
||||
|
||||
+219
-87
@@ -24,7 +24,6 @@ from services.memory import (
|
||||
update_session_genre,
|
||||
update_session_plot_arc,
|
||||
get_quests,
|
||||
seed_quests_from_arc,
|
||||
narrator_message_content,
|
||||
parse_narrator_message,
|
||||
add_action_resolution,
|
||||
@@ -36,10 +35,13 @@ from services.memory import (
|
||||
update_message_choices,
|
||||
clear_choices_for_session,
|
||||
upsert_static_system_message,
|
||||
save_state_snapshot,
|
||||
get_last_message_id,
|
||||
)
|
||||
from services.context_budget import compute_payload_usage, context_warning_line
|
||||
from services.rpg_state import (
|
||||
apply_narrator_post,
|
||||
apply_narrator_post_with_story,
|
||||
parse_scene_json,
|
||||
parse_stats_json,
|
||||
scene_prompt_block,
|
||||
@@ -57,17 +59,30 @@ from services.sd_images import run_sd_for_message
|
||||
from services.character_card import get_character
|
||||
from services import sdbackend as sd_service
|
||||
from services.rpg_facts import extract_facts, merge_facts_persist, facts_to_prompt, rp_day_from_scene
|
||||
from services.rpg_context import format_narrator_context
|
||||
from services.rpg_context import format_narrator_context, format_arc_summary_for_runtime
|
||||
from services.rpg_plot import (
|
||||
generate_plot_arc,
|
||||
process_arc_beats,
|
||||
advance_phase,
|
||||
replenish_arc_beats,
|
||||
reconcile_plot_arc,
|
||||
reconcile_plot_arc,
|
||||
choices_from_beat,
|
||||
choices_from_step,
|
||||
choices_from_narrator,
|
||||
)
|
||||
from services.rpg_story import (
|
||||
normalize_story_arc,
|
||||
get_current_step,
|
||||
format_step_guidance_for_character,
|
||||
format_step_hint_for_character,
|
||||
format_new_arc_opening,
|
||||
should_show_step_injection,
|
||||
mark_injection_shown,
|
||||
reconcile_story_arc,
|
||||
sync_quest_to_current_step,
|
||||
is_arc_completed,
|
||||
is_new_arc_request,
|
||||
normalize_new_arc_first,
|
||||
append_new_arc_roll_choice,
|
||||
roll_next_arc,
|
||||
step_progress,
|
||||
)
|
||||
from services.rpg_narrator import narrator_pre, narrator_post
|
||||
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening
|
||||
|
||||
@@ -102,9 +117,9 @@ def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str
|
||||
except Exception:
|
||||
arc = {}
|
||||
if arc:
|
||||
runtime_suffix += "\n\n--- PlotArc ---\n" + json.dumps(
|
||||
{k: arc.get(k) for k in ("title", "phase", "next_beat_hint")}, ensure_ascii=False
|
||||
) + "\n---"
|
||||
summary = format_arc_summary_for_runtime(arc)
|
||||
if summary:
|
||||
runtime_suffix += "\n\n--- Story arc ---\n" + summary + "\n---"
|
||||
status_quo = (session.get("status_quo") or "").strip()
|
||||
if status_quo:
|
||||
from services.rp_sanitize import status_quo_prompt_block
|
||||
@@ -122,7 +137,9 @@ def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str
|
||||
return runtime_suffix
|
||||
|
||||
|
||||
def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
|
||||
def messages_for_llm(
|
||||
history: list, llm_system_content: str, *, rp_lang: str = "ru"
|
||||
) -> list[dict]:
|
||||
"""Build LLM payload: one system message (static + runtime), no duplicate system rows."""
|
||||
out: list[dict] = []
|
||||
system_used = False
|
||||
@@ -134,7 +151,10 @@ def messages_for_llm(history: list, llm_system_content: str) -> list[dict]:
|
||||
elif m["role"] == "narrator":
|
||||
data = parse_narrator_message(m.get("content") or "")
|
||||
if data:
|
||||
out.append({"role": "user", "content": format_narrator_outcome_for_llm(data)})
|
||||
out.append({
|
||||
"role": "user",
|
||||
"content": format_narrator_outcome_for_llm(data, lang=rp_lang),
|
||||
})
|
||||
elif m["role"] == "user":
|
||||
has_res = bool(m.get("action_resolution"))
|
||||
out.append({
|
||||
@@ -272,14 +292,18 @@ async def rpg_bootstrap(req: RpgBootstrapRequest):
|
||||
session = await get_session(req.session_id) or {}
|
||||
rpg_settings = get_rpg_settings(session)
|
||||
if rpg_settings.get("narrator", True) and greeting:
|
||||
from services.rpg_locale import infer_rp_language
|
||||
|
||||
arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
|
||||
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
||||
b_lang = infer_rp_language([{"role": "assistant", "content": greeting}])
|
||||
post = await narrator_post(
|
||||
persona.get("name", persona_id),
|
||||
f"assistant: {greeting}",
|
||||
arc_json,
|
||||
facts_block,
|
||||
is_opening=True,
|
||||
lang=b_lang,
|
||||
)
|
||||
await apply_narrator_post(req.session_id, post, rpg_settings, session)
|
||||
quests = await get_quests(req.session_id)
|
||||
@@ -319,20 +343,70 @@ async def chat_stream(request: ChatRequest):
|
||||
pre = {}
|
||||
directives: list = []
|
||||
pre_ok = False
|
||||
needs_check = False
|
||||
rp_lang = "ru"
|
||||
story_arc_meta: dict = {}
|
||||
new_arc_first = normalize_new_arc_first(request.new_arc_first)
|
||||
skip_character_reply = False
|
||||
new_arc_injection_text = ""
|
||||
if session and session.get("rpg_enabled"):
|
||||
from services.rpg_locale import infer_rp_language
|
||||
|
||||
rpg_settings = get_rpg_settings(session)
|
||||
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
||||
rp_lang = infer_rp_language(history)
|
||||
try:
|
||||
arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except Exception:
|
||||
arc = {}
|
||||
arc = normalize_story_arc(arc, genre=session.get("genre") or "adventure")
|
||||
|
||||
wants_new_arc_roll = is_arc_completed(arc) and (
|
||||
new_arc_first or is_new_arc_request(request.message)
|
||||
)
|
||||
if wants_new_arc_roll:
|
||||
if not new_arc_first:
|
||||
new_arc_first = "character"
|
||||
persona = await get_persona(persona_id) or {}
|
||||
recent_roll = "\n".join(
|
||||
f"{m['role']}: {m['content']}" for m in history[-8:]
|
||||
if m.get("role") in ("user", "assistant")
|
||||
)
|
||||
rolled = await roll_next_arc(
|
||||
request.session_id,
|
||||
persona,
|
||||
request.message,
|
||||
session.get("genre") or "adventure",
|
||||
lang=rp_lang,
|
||||
recent_context=recent_roll,
|
||||
facts_block=facts_block,
|
||||
)
|
||||
if rolled and not is_arc_completed(rolled):
|
||||
arc = rolled
|
||||
story_arc_meta["new_arc_rolled"] = True
|
||||
story_arc_meta["new_arc_first"] = new_arc_first
|
||||
skip_character_reply = new_arc_first == "user"
|
||||
step0 = get_current_step(arc)
|
||||
if step0:
|
||||
new_arc_injection_text = format_new_arc_opening(arc, step0, lang=rp_lang)
|
||||
mark_injection_shown(arc)
|
||||
await update_session_plot_arc(
|
||||
request.session_id, json.dumps(arc, ensure_ascii=False)
|
||||
)
|
||||
|
||||
arc, _ = await reconcile_story_arc(
|
||||
request.session_id,
|
||||
persona_name=(await get_persona(persona_id) or {}).get("name", persona_id),
|
||||
genre=session.get("genre") or "adventure",
|
||||
)
|
||||
session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
|
||||
|
||||
quests_list = await get_quests(request.session_id)
|
||||
narr_ctx = format_narrator_context(
|
||||
arc, quests_list, session.get("status_quo") or ""
|
||||
)
|
||||
|
||||
if rpg_settings.get("narrator", True):
|
||||
if rpg_settings.get("narrator", True) and not story_arc_meta.get("new_arc_rolled"):
|
||||
persona = await get_persona(persona_id) or {}
|
||||
recent_txt = "\n".join(
|
||||
f"{m['role']}: {m['content']}" for m in history[-8:]
|
||||
@@ -347,6 +421,7 @@ async def chat_stream(request: ChatRequest):
|
||||
facts_block,
|
||||
request.message,
|
||||
extra_context=narr_ctx,
|
||||
lang=rp_lang,
|
||||
)
|
||||
pre_ok = bool(pre.get("_ok"))
|
||||
|
||||
@@ -373,6 +448,7 @@ async def chat_stream(request: ChatRequest):
|
||||
roll=roll,
|
||||
outcome=outcome,
|
||||
extra_context=narr_ctx,
|
||||
lang=rp_lang,
|
||||
)
|
||||
resolution_text = (pre2.get("resolution_text") or "").strip()
|
||||
directives = pre2.get("directives") or []
|
||||
@@ -422,6 +498,28 @@ async def chat_stream(request: ChatRequest):
|
||||
"---"
|
||||
)
|
||||
|
||||
step = get_current_step(arc)
|
||||
if step:
|
||||
narrator_extra += format_step_guidance_for_character(step, arc, lang=rp_lang)
|
||||
if (
|
||||
not story_arc_meta.get("new_arc_rolled")
|
||||
and should_show_step_injection(arc)
|
||||
):
|
||||
inj = (step.get("injection") or "").strip()
|
||||
if inj:
|
||||
narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
|
||||
mark_injection_shown(arc)
|
||||
await update_session_plot_arc(
|
||||
request.session_id, json.dumps(arc, ensure_ascii=False)
|
||||
)
|
||||
session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
|
||||
elif story_arc_meta.get("new_arc_rolled") and new_arc_first == "character":
|
||||
inj = (step.get("injection") or "").strip()
|
||||
if inj:
|
||||
narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
|
||||
cur, total = step_progress(arc)
|
||||
story_arc_meta["story_step"] = f"{cur}/{total}"
|
||||
|
||||
runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra
|
||||
|
||||
llm_system = static_prompt + runtime_suffix
|
||||
@@ -429,7 +527,12 @@ async def chat_stream(request: ChatRequest):
|
||||
llm_system += RP_OUTPUT_REMINDER
|
||||
|
||||
user_message_content = request.message
|
||||
if request.is_narrator_choice:
|
||||
if request.is_narrator_choice and new_arc_first:
|
||||
first_who = "игрок" if new_arc_first == "user" else "персонаж"
|
||||
user_message_content = (
|
||||
f"[Player chose: Начать новую арку — первый ход: {first_who}]"
|
||||
)
|
||||
elif request.is_narrator_choice:
|
||||
user_message_content = f"[Player chose: {request.message}]"
|
||||
|
||||
await upsert_static_system_message(request.session_id, static_prompt, history)
|
||||
@@ -438,6 +541,8 @@ async def chat_stream(request: ChatRequest):
|
||||
if not request.skip_user_add:
|
||||
await clear_choices_for_session(request.session_id)
|
||||
user_msg_id = await add_message(request.session_id, "user", user_message_content)
|
||||
if user_msg_id and session and session.get("rpg_enabled"):
|
||||
await save_state_snapshot(request.session_id, user_msg_id)
|
||||
if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id:
|
||||
await add_action_resolution(
|
||||
request.session_id,
|
||||
@@ -449,23 +554,71 @@ async def chat_stream(request: ChatRequest):
|
||||
)
|
||||
narrator_msg["user_message_id"] = user_msg_id
|
||||
if narrator_msg and (narrator_msg.get("text") or "").strip():
|
||||
await add_message(
|
||||
narr_id = await add_message(
|
||||
request.session_id,
|
||||
"narrator",
|
||||
narrator_message_content(narrator_msg),
|
||||
)
|
||||
if narr_id and session and session.get("rpg_enabled"):
|
||||
await save_state_snapshot(request.session_id, narr_id)
|
||||
messages = await get_history(request.session_id)
|
||||
usage = compute_payload_usage(messages, llm_system)
|
||||
warn = context_warning_line(usage.get("percent", 0))
|
||||
if warn:
|
||||
llm_system += warn
|
||||
llm_messages = messages_for_llm(messages, llm_system)
|
||||
llm_messages = messages_for_llm(messages, llm_system, rp_lang=rp_lang)
|
||||
|
||||
full_reply = []
|
||||
|
||||
async def generate():
|
||||
nonlocal arc
|
||||
|
||||
if new_arc_injection_text:
|
||||
new_arc_narrator = {"text": new_arc_injection_text}
|
||||
narr_inj_id = await add_message(
|
||||
request.session_id,
|
||||
"narrator",
|
||||
narrator_message_content(new_arc_narrator),
|
||||
)
|
||||
if narr_inj_id and session and session.get("rpg_enabled"):
|
||||
await save_state_snapshot(request.session_id, narr_inj_id)
|
||||
yield f"data: {json.dumps({'narrator': new_arc_narrator})}\n\n"
|
||||
|
||||
if skip_character_reply:
|
||||
choices = []
|
||||
step = get_current_step(arc)
|
||||
if step and rpg_settings.get("choices", True):
|
||||
choices += choices_from_step(step)
|
||||
quests_updated = await get_quests(request.session_id)
|
||||
updated_session = await get_session(request.session_id) or session
|
||||
narrator_meta = {
|
||||
"new_arc_rolled": True,
|
||||
"new_arc_first": new_arc_first,
|
||||
"story_step": story_arc_meta.get("story_step", ""),
|
||||
"rp_language": rp_lang,
|
||||
}
|
||||
done_payload = {
|
||||
"done": True,
|
||||
"assistant_message_id": None,
|
||||
"assistant_content": "",
|
||||
"choices": choices,
|
||||
"debug": [],
|
||||
"affinity": updated_session.get("affinity", 0) if updated_session else 0,
|
||||
"quests": quests_updated if session and session.get("rpg_enabled") else [],
|
||||
"story_arc": arc if session and session.get("rpg_enabled") else None,
|
||||
"narrator_meta": narrator_meta,
|
||||
}
|
||||
if rpg_settings.get("stats") and updated_session:
|
||||
done_payload["narrative_stats"] = parse_stats_json(
|
||||
updated_session.get("narrative_stats_json")
|
||||
)
|
||||
if session and session.get("rpg_enabled"):
|
||||
last_id = await get_last_message_id(request.session_id)
|
||||
if last_id:
|
||||
await save_state_snapshot(request.session_id, last_id)
|
||||
yield f"data: {json.dumps(done_payload)}\n\n"
|
||||
return
|
||||
|
||||
if narrator_msg:
|
||||
yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"
|
||||
|
||||
@@ -492,8 +645,13 @@ async def chat_stream(request: ChatRequest):
|
||||
|
||||
if session and session.get("rpg_enabled"):
|
||||
try:
|
||||
if not arc:
|
||||
if not arc or not arc.get("steps"):
|
||||
persona = await get_persona(persona_id) or {}
|
||||
gen_ctx = "\n".join(
|
||||
f"{m['role']}: {m['content']}"
|
||||
for m in (await get_history(request.session_id))[-6:]
|
||||
if m.get("role") in ("user", "assistant")
|
||||
)
|
||||
arc = await generate_plot_arc(
|
||||
persona.get("name", persona_id),
|
||||
persona.get("description", ""),
|
||||
@@ -501,6 +659,8 @@ async def chat_stream(request: ChatRequest):
|
||||
persona.get("first_mes", ""),
|
||||
facts_block=facts_to_prompt(session.get("facts_json", "[]")),
|
||||
genre=session.get("genre") or "adventure",
|
||||
lang=rp_lang,
|
||||
recent_context=gen_ctx,
|
||||
)
|
||||
if arc:
|
||||
await update_session_plot_arc(
|
||||
@@ -511,54 +671,16 @@ async def chat_stream(request: ChatRequest):
|
||||
"text": json.dumps(arc, ensure_ascii=False, indent=2),
|
||||
})
|
||||
if rpg_settings.get("quests", True):
|
||||
await seed_quests_from_arc(request.session_id, arc)
|
||||
await sync_quest_to_current_step(request.session_id, arc)
|
||||
|
||||
quests_list = await get_quests(request.session_id)
|
||||
if arc:
|
||||
beat_ctx = "\n".join(
|
||||
f"{m['role']}: {m['content']}"
|
||||
for m in (await get_history(request.session_id))[-6:]
|
||||
if m.get("role") in ("user", "assistant")
|
||||
)
|
||||
arc, beats, pruned, beat_mode = await process_arc_beats(
|
||||
arc,
|
||||
quests_list,
|
||||
request.message,
|
||||
recent_context=beat_ctx,
|
||||
last_dice_outcome=outcome if roll is not None else None,
|
||||
)
|
||||
if pruned or beats:
|
||||
await update_session_plot_arc(
|
||||
request.session_id, json.dumps(arc, ensure_ascii=False)
|
||||
)
|
||||
if pruned:
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc_prune",
|
||||
"text": f"Removed {len(pruned)} beat(s) already completed as quests",
|
||||
})
|
||||
if beats:
|
||||
inj = beats[0].get("injection", "")
|
||||
if inj:
|
||||
debug_blocks.append({"type": "narrator_injection", "text": inj})
|
||||
if rpg_settings.get("choices", True):
|
||||
choices += choices_from_beat(beats[0])
|
||||
if beat_mode in ("after_dice", "llm", "trigger", "stuck_recovery"):
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc",
|
||||
"text": (
|
||||
f"Beat fired ({beat_mode}): "
|
||||
f"«{beats[0].get('title', '')}»"
|
||||
),
|
||||
})
|
||||
if advance_phase(arc):
|
||||
await update_session_plot_arc(
|
||||
request.session_id, json.dumps(arc, ensure_ascii=False)
|
||||
)
|
||||
debug_blocks.append({"type": "phase_advance", "text": arc["phase"]})
|
||||
if pruned and not arc.get("beats"):
|
||||
narrator_meta["arc_pruned"] = len(pruned)
|
||||
if beat_mode:
|
||||
narrator_meta["beat_mode"] = beat_mode
|
||||
arc = normalize_story_arc(
|
||||
arc, genre=session.get("genre") or "adventure"
|
||||
)
|
||||
cur, total = step_progress(arc)
|
||||
narrator_meta["story_step"] = f"{cur}/{total}"
|
||||
narrator_meta["rp_language"] = rp_lang
|
||||
if story_arc_meta.get("new_arc_rolled"):
|
||||
narrator_meta["new_arc_rolled"] = True
|
||||
|
||||
ctx = [
|
||||
m for m in (await get_history(request.session_id))
|
||||
@@ -598,6 +720,7 @@ async def chat_stream(request: ChatRequest):
|
||||
json.dumps(arc, ensure_ascii=False) if arc else "",
|
||||
facts_to_prompt(session.get("facts_json", "[]")),
|
||||
extra_context=narr_ctx_post,
|
||||
lang=rp_lang,
|
||||
)
|
||||
|
||||
sq = (post.get("status_quo_update") or "").strip()
|
||||
@@ -607,9 +730,11 @@ async def chat_stream(request: ChatRequest):
|
||||
if rpg_settings.get("choices", True):
|
||||
choices += choices_from_narrator(post.get("choices") or [])
|
||||
|
||||
applied = await apply_narrator_post(
|
||||
request.session_id, post, rpg_settings, session
|
||||
applied = await apply_narrator_post_with_story(
|
||||
request.session_id, post, rpg_settings, session, arc=arc
|
||||
)
|
||||
if applied.get("arc"):
|
||||
arc = applied["arc"]
|
||||
narrator_meta = {
|
||||
"pre_ok": pre_ok,
|
||||
"post_ok": bool(post.get("_ok")),
|
||||
@@ -619,26 +744,27 @@ async def chat_stream(request: ChatRequest):
|
||||
**applied,
|
||||
}
|
||||
|
||||
if not arc.get("beats"):
|
||||
persona = await get_persona(persona_id) or {}
|
||||
arc = await replenish_arc_beats(
|
||||
arc,
|
||||
persona.get("name", persona_id),
|
||||
ctx_txt,
|
||||
await get_quests(request.session_id),
|
||||
session.get("genre") or "adventure",
|
||||
)
|
||||
if arc.get("beats"):
|
||||
await update_session_plot_arc(
|
||||
request.session_id, json.dumps(arc, ensure_ascii=False)
|
||||
)
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc",
|
||||
"text": f"Added {len(arc.get('beats', []))} new plot beats",
|
||||
})
|
||||
narrator_meta["beats_replenished"] = len(arc.get("beats", []))
|
||||
if rpg_settings.get("quests", True):
|
||||
await seed_quests_from_arc(request.session_id, arc)
|
||||
if applied.get("step_advanced"):
|
||||
# Drop stale narrator/plot choices from the step we just left;
|
||||
# injection is shown once in the plot choice panel (beat_injection).
|
||||
choices = [c for c in choices if c.get("type") == "new_arc_roll"]
|
||||
new_step = get_current_step(arc)
|
||||
if new_step and rpg_settings.get("choices", True):
|
||||
choices += choices_from_step(new_step)
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc",
|
||||
"text": f"Step advanced: «{applied.get('new_step_title', '')}»",
|
||||
})
|
||||
narrator_meta["choices_count"] = len(choices)
|
||||
|
||||
if applied.get("arc_completed"):
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc",
|
||||
"text": "Story arc completed — new arc available",
|
||||
})
|
||||
|
||||
if is_arc_completed(arc) and rpg_settings.get("choices", True):
|
||||
choices = append_new_arc_roll_choice(choices, lang=rp_lang)
|
||||
outfit_update = post.get("outfit_update")
|
||||
if isinstance(outfit_update, list) and outfit_update:
|
||||
from services.outfit_tags import outfit_list_to_json
|
||||
@@ -705,7 +831,8 @@ async def chat_stream(request: ChatRequest):
|
||||
"choices": choices,
|
||||
"debug": debug_blocks,
|
||||
"affinity": affinity,
|
||||
"quests": quests_updated,
|
||||
"quests": quests_updated if session and session.get("rpg_enabled") else [],
|
||||
"story_arc": arc if session and session.get("rpg_enabled") else None,
|
||||
"narrator_meta": narrator_meta,
|
||||
}
|
||||
if rpg_settings.get("stats") and updated_session:
|
||||
@@ -713,6 +840,11 @@ async def chat_stream(request: ChatRequest):
|
||||
updated_session.get("narrative_stats_json")
|
||||
)
|
||||
|
||||
if session and session.get("rpg_enabled"):
|
||||
snap_id = msg_id or await get_last_message_id(request.session_id)
|
||||
if snap_id:
|
||||
await save_state_snapshot(request.session_id, snap_id)
|
||||
|
||||
yield f"data: {json.dumps(done_payload)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
+305
-23
@@ -396,16 +396,32 @@ async def delete_messages_after(session_id: str, message_id: int):
|
||||
"DELETE FROM messages WHERE session_id = ? AND id > ?",
|
||||
(session_id, message_id),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
await db.commit()
|
||||
await restore_session_to_message(session_id, message_id)
|
||||
|
||||
|
||||
async def delete_message(message_id: int):
|
||||
msg = await get_message(message_id)
|
||||
if not msg:
|
||||
return
|
||||
session_id = msg["session_id"]
|
||||
anchor = await get_max_message_id_before(session_id, message_id)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||
await db.execute(
|
||||
"UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
await db.commit()
|
||||
await restore_session_to_message(session_id, anchor)
|
||||
|
||||
|
||||
async def delete_message_and_following(session_id: str, message_id: int) -> bool:
|
||||
anchor = await get_max_message_id_before(session_id, message_id)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"DELETE FROM messages WHERE session_id = ? AND id >= ?",
|
||||
@@ -416,6 +432,7 @@ async def delete_message_and_following(session_id: str, message_id: int) -> bool
|
||||
(session_id,),
|
||||
)
|
||||
await db.commit()
|
||||
await restore_session_to_message(session_id, anchor)
|
||||
return True
|
||||
|
||||
|
||||
@@ -456,6 +473,185 @@ async def get_last_message_preview(session_id: str, max_len: int = 80) -> str:
|
||||
return prefix + text
|
||||
|
||||
|
||||
async def get_max_message_id_before(session_id: str, message_id: int) -> int | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
async with db.execute(
|
||||
"SELECT MAX(id) FROM messages WHERE session_id = ? AND id < ?",
|
||||
(session_id, message_id),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if not row or row[0] is None:
|
||||
return None
|
||||
return int(row[0])
|
||||
|
||||
|
||||
async def _collect_action_resolutions(session_id: str) -> list[dict]:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""SELECT message_id, intent_text, roll, outcome, resolution_text
|
||||
FROM action_resolutions
|
||||
WHERE session_id = ?
|
||||
ORDER BY id""",
|
||||
(session_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
{
|
||||
"message_id": r["message_id"],
|
||||
"intent_text": r["intent_text"],
|
||||
"roll": r["roll"],
|
||||
"outcome": r["outcome"],
|
||||
"resolution_text": r["resolution_text"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def save_state_snapshot(session_id: str, message_id: int) -> None:
|
||||
"""Persist RPG session state as it exists right after this message."""
|
||||
session = await get_session(session_id)
|
||||
if not session:
|
||||
return
|
||||
quests = await get_quests(session_id)
|
||||
resolutions = await _collect_action_resolutions(session_id)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO session_state_snapshots
|
||||
(message_id, session_id, facts_json, global_plot, status_quo,
|
||||
plot_arc_json, affinity, outfit_json, scene_json,
|
||||
narrative_stats_json, quests_json, action_resolutions_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(message_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
facts_json = excluded.facts_json,
|
||||
global_plot = excluded.global_plot,
|
||||
status_quo = excluded.status_quo,
|
||||
plot_arc_json = excluded.plot_arc_json,
|
||||
affinity = excluded.affinity,
|
||||
outfit_json = excluded.outfit_json,
|
||||
scene_json = excluded.scene_json,
|
||||
narrative_stats_json = excluded.narrative_stats_json,
|
||||
quests_json = excluded.quests_json,
|
||||
action_resolutions_json = excluded.action_resolutions_json""",
|
||||
(
|
||||
message_id,
|
||||
session_id,
|
||||
session.get("facts_json", "[]"),
|
||||
session.get("global_plot", ""),
|
||||
session.get("status_quo", ""),
|
||||
session.get("plot_arc_json", "{}"),
|
||||
int(session.get("affinity") or 0),
|
||||
session.get("outfit_json", "[]"),
|
||||
session.get("scene_json", "{}"),
|
||||
session.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'),
|
||||
json.dumps(quests, ensure_ascii=False),
|
||||
json.dumps(resolutions, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_snapshot_at_or_before(session_id: str, message_id: int) -> dict | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"""SELECT * FROM session_state_snapshots
|
||||
WHERE session_id = ? AND message_id <= ?
|
||||
ORDER BY message_id DESC LIMIT 1""",
|
||||
(session_id, message_id),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def restore_session_from_snapshot(session_id: str, snapshot: dict) -> None:
|
||||
from services.rpg_state import DEFAULT_NARRATIVE_STATS
|
||||
|
||||
stats_default = json.dumps(DEFAULT_NARRATIVE_STATS, ensure_ascii=False)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""UPDATE sessions SET
|
||||
facts_json = ?,
|
||||
global_plot = ?,
|
||||
status_quo = ?,
|
||||
plot_arc_json = ?,
|
||||
affinity = ?,
|
||||
outfit_json = ?,
|
||||
scene_json = ?,
|
||||
narrative_stats_json = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ?""",
|
||||
(
|
||||
snapshot.get("facts_json", "[]"),
|
||||
snapshot.get("global_plot", ""),
|
||||
snapshot.get("status_quo", ""),
|
||||
snapshot.get("plot_arc_json", "{}"),
|
||||
int(snapshot.get("affinity") or 0),
|
||||
snapshot.get("outfit_json", "[]"),
|
||||
snapshot.get("scene_json", "{}"),
|
||||
snapshot.get("narrative_stats_json") or stats_default,
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
await db.execute("DELETE FROM rpg_quests WHERE session_id = ?", (session_id,))
|
||||
try:
|
||||
quests = json.loads(snapshot.get("quests_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
quests = []
|
||||
if isinstance(quests, list):
|
||||
for q in quests:
|
||||
if not isinstance(q, dict):
|
||||
continue
|
||||
title = (q.get("title") or "").strip()
|
||||
if title:
|
||||
await db.execute(
|
||||
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
|
||||
(session_id, title[:120], q.get("status", "active")),
|
||||
)
|
||||
await db.execute("DELETE FROM action_resolutions WHERE session_id = ?", (session_id,))
|
||||
try:
|
||||
resolutions = json.loads(snapshot.get("action_resolutions_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
resolutions = []
|
||||
if isinstance(resolutions, list):
|
||||
for r in resolutions:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
await db.execute(
|
||||
"""INSERT INTO action_resolutions
|
||||
(session_id, message_id, intent_text, roll, outcome, resolution_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
r.get("message_id"),
|
||||
r.get("intent_text", ""),
|
||||
int(r.get("roll") or 0),
|
||||
r.get("outcome", ""),
|
||||
r.get("resolution_text", ""),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def restore_session_to_message(session_id: str, anchor_message_id: int | None) -> bool:
|
||||
"""Restore RPG state to snapshot at anchor (or nearest earlier message)."""
|
||||
if anchor_message_id is None:
|
||||
await _reset_persona_bound_state_only(session_id)
|
||||
return False
|
||||
snap = await get_snapshot_at_or_before(session_id, anchor_message_id)
|
||||
if not snap:
|
||||
return False
|
||||
await restore_session_from_snapshot(session_id, snap)
|
||||
return True
|
||||
|
||||
|
||||
async def _reset_persona_bound_state_only(session_id: str) -> None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await _reset_persona_bound_state(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def fork_session(source_session_id: str, until_message_id: int) -> str | None:
|
||||
source = await get_session(source_session_id)
|
||||
if not source:
|
||||
@@ -464,6 +660,8 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
|
||||
import uuid
|
||||
new_id = "sess_" + uuid.uuid4().hex[:8]
|
||||
|
||||
snap = await get_snapshot_at_or_before(source_session_id, until_message_id)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO sessions
|
||||
@@ -476,40 +674,112 @@ async def fork_session(source_session_id: str, until_message_id: int) -> str | N
|
||||
source["persona_id"],
|
||||
(source.get("title") or "Новый чат") + " (ветка)",
|
||||
source.get("rpg_enabled", 0),
|
||||
source.get("facts_json", "[]"),
|
||||
source.get("global_plot", ""),
|
||||
source.get("status_quo", ""),
|
||||
source.get("plot_arc_json", "{}"),
|
||||
(snap or source).get("facts_json", "[]"),
|
||||
(snap or source).get("global_plot", ""),
|
||||
(snap or source).get("status_quo", ""),
|
||||
(snap or source).get("plot_arc_json", "{}"),
|
||||
source.get("genre", "adventure"),
|
||||
source.get("rpg_settings_json", "{}"),
|
||||
source.get("affinity", 0),
|
||||
source.get("outfit_json", "[]"),
|
||||
source.get("scene_json", "{}"),
|
||||
source.get("narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'),
|
||||
int((snap or source).get("affinity") or 0),
|
||||
(snap or source).get("outfit_json", "[]"),
|
||||
(snap or source).get("scene_json", "{}"),
|
||||
(snap or source).get(
|
||||
"narrative_stats_json", '{"lust":0,"stamina":10,"tension":0}'
|
||||
),
|
||||
),
|
||||
)
|
||||
async with db.execute(
|
||||
"""SELECT role, content, image_prompt, image_path FROM messages
|
||||
"""SELECT id, role, content, image_prompt, image_path,
|
||||
image_prompt_alt, image_path_alt, choices_json
|
||||
FROM messages
|
||||
WHERE session_id = ? AND id <= ? ORDER BY id""",
|
||||
(source_session_id, until_message_id),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
id_map: dict[int, int] = {}
|
||||
for r in rows:
|
||||
await db.execute(
|
||||
"""INSERT INTO messages (session_id, role, content, image_prompt, image_path)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(new_id, r[0], r[1], r[2], r[3]),
|
||||
)
|
||||
async with db.execute(
|
||||
"SELECT title, status FROM rpg_quests WHERE session_id = ?",
|
||||
(source_session_id,),
|
||||
) as cur:
|
||||
quests = await cur.fetchall()
|
||||
for q in quests:
|
||||
await db.execute(
|
||||
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
|
||||
(new_id, q[0], q[1]),
|
||||
cur_ins = await db.execute(
|
||||
"""INSERT INTO messages
|
||||
(session_id, role, content, image_prompt, image_path,
|
||||
image_prompt_alt, image_path_alt, choices_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(new_id, r[1], r[2], r[3], r[4], r[5], r[6], r[7]),
|
||||
)
|
||||
id_map[int(r[0])] = int(cur_ins.lastrowid)
|
||||
if snap:
|
||||
try:
|
||||
quests = json.loads(snap.get("quests_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
quests = []
|
||||
if isinstance(quests, list):
|
||||
for q in quests:
|
||||
if isinstance(q, dict) and (q.get("title") or "").strip():
|
||||
await db.execute(
|
||||
"INSERT INTO rpg_quests (session_id, title, status) VALUES (?, ?, ?)",
|
||||
(new_id, q["title"][:120], q.get("status", "active")),
|
||||
)
|
||||
try:
|
||||
resolutions = json.loads(snap.get("action_resolutions_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
resolutions = []
|
||||
if isinstance(resolutions, list):
|
||||
for res in resolutions:
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
old_mid = res.get("message_id")
|
||||
new_mid = id_map.get(int(old_mid)) if old_mid is not None else None
|
||||
if new_mid is None:
|
||||
continue
|
||||
await db.execute(
|
||||
"""INSERT INTO action_resolutions
|
||||
(session_id, message_id, intent_text, roll, outcome, resolution_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
new_id,
|
||||
new_mid,
|
||||
res.get("intent_text", ""),
|
||||
int(res.get("roll") or 0),
|
||||
res.get("outcome", ""),
|
||||
res.get("resolution_text", ""),
|
||||
),
|
||||
)
|
||||
async with db.execute(
|
||||
"""SELECT message_id, facts_json, global_plot, status_quo, plot_arc_json,
|
||||
affinity, outfit_json, scene_json, narrative_stats_json,
|
||||
quests_json, action_resolutions_json
|
||||
FROM session_state_snapshots
|
||||
WHERE session_id = ? AND message_id <= ?""",
|
||||
(source_session_id, until_message_id),
|
||||
) as snap_cur:
|
||||
snap_rows = await snap_cur.fetchall()
|
||||
for srow in snap_rows:
|
||||
new_mid = id_map.get(int(srow[0]))
|
||||
if new_mid is None:
|
||||
continue
|
||||
res_json = srow[10]
|
||||
try:
|
||||
res_list = json.loads(res_json or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
res_list = []
|
||||
if isinstance(res_list, list):
|
||||
remapped = []
|
||||
for res in res_list:
|
||||
if not isinstance(res, dict):
|
||||
continue
|
||||
old_mid = res.get("message_id")
|
||||
nm = id_map.get(int(old_mid)) if old_mid is not None else None
|
||||
if nm is not None:
|
||||
remapped.append({**res, "message_id": nm})
|
||||
res_json = json.dumps(remapped, ensure_ascii=False)
|
||||
await db.execute(
|
||||
"""INSERT INTO session_state_snapshots
|
||||
(message_id, session_id, facts_json, global_plot, status_quo,
|
||||
plot_arc_json, affinity, outfit_json, scene_json,
|
||||
narrative_stats_json, quests_json, action_resolutions_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(new_mid, new_id, srow[1], srow[2], srow[3], srow[4], srow[5],
|
||||
srow[6], srow[7], srow[8], srow[9], res_json),
|
||||
)
|
||||
await db.commit()
|
||||
return new_id
|
||||
|
||||
@@ -572,6 +842,17 @@ async def update_message_image_alt(message_id: int, image_path_alt: str):
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_last_message_id(session_id: str) -> int | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
async with db.execute(
|
||||
"SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1",
|
||||
(session_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
async def get_last_assistant_message_id(session_id: str) -> int | None:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
@@ -591,6 +872,7 @@ async def clear_history(session_id: str):
|
||||
"DELETE FROM messages WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
await db.commit()
|
||||
await _reset_persona_bound_state_only(session_id)
|
||||
|
||||
|
||||
async def update_session_affinity(session_id: str, delta: int):
|
||||
|
||||
+21
-3
@@ -7,7 +7,6 @@ from services.memory import (
|
||||
get_last_assistant_message_id,
|
||||
update_session_plot_arc,
|
||||
update_message_choices,
|
||||
seed_quests_from_arc,
|
||||
get_quests,
|
||||
)
|
||||
from services.rpg_state import apply_narrator_post
|
||||
@@ -64,7 +63,10 @@ async def ensure_plot_arc_and_quests(
|
||||
if arc:
|
||||
return arc
|
||||
|
||||
from services.rpg_locale import infer_rp_language
|
||||
|
||||
facts_block = facts_to_prompt(session.get("facts_json", "[]"))
|
||||
lang = infer_rp_language([{"role": "assistant", "content": greeting}])
|
||||
arc = await generate_plot_arc(
|
||||
persona.get("name", "Character"),
|
||||
persona.get("description", ""),
|
||||
@@ -72,13 +74,17 @@ async def ensure_plot_arc_and_quests(
|
||||
greeting,
|
||||
facts_block=facts_block,
|
||||
genre=genre,
|
||||
lang=lang,
|
||||
recent_context=f"assistant: {greeting}",
|
||||
)
|
||||
if not arc:
|
||||
return {}
|
||||
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
if seed_quests:
|
||||
await seed_quests_from_arc(session_id, arc)
|
||||
from services.rpg_story import sync_quest_to_current_step
|
||||
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
return arc
|
||||
|
||||
|
||||
@@ -122,6 +128,9 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
|
||||
|
||||
quests_pre = await get_quests(session_id)
|
||||
narr_ctx = format_narrator_context(arc, quests_pre, session.get("status_quo") or "")
|
||||
from services.rpg_locale import infer_rp_language
|
||||
|
||||
o_lang = infer_rp_language([{"role": "assistant", "content": first_mes_text}])
|
||||
post = await narrator_post(
|
||||
persona.get("name", persona_id),
|
||||
ctx_txt,
|
||||
@@ -129,12 +138,17 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
|
||||
facts_block,
|
||||
is_opening=True,
|
||||
extra_context=narr_ctx,
|
||||
lang=o_lang,
|
||||
)
|
||||
|
||||
if rpg_settings.get("choices", True):
|
||||
choices = choices_from_narrator(post.get("choices") or [])
|
||||
|
||||
await apply_narrator_post(session_id, post, rpg_settings, session)
|
||||
from services.rpg_state import apply_narrator_post_with_story
|
||||
|
||||
await apply_narrator_post_with_story(
|
||||
session_id, post, rpg_settings, session, arc=arc
|
||||
)
|
||||
session = await get_session(session_id) or session
|
||||
status_quo = session.get("status_quo") or status_quo
|
||||
outfit_json = session.get("outfit_json") or outfit_json
|
||||
@@ -150,6 +164,10 @@ async def process_opening(session_id: str, persona_id: str, *, rpg: bool) -> dic
|
||||
sd_out = await run_sd_for_message(bundle, msg_id) if bundle else {}
|
||||
|
||||
updated = await get_session(session_id)
|
||||
if rpg and msg_id:
|
||||
from services.memory import save_state_snapshot
|
||||
|
||||
await save_state_snapshot(session_id, msg_id)
|
||||
affinity = updated.get("affinity", 0) if updated else 0
|
||||
|
||||
if msg_id and choices:
|
||||
|
||||
+23
-42
@@ -1,6 +1,12 @@
|
||||
"""Shared context blocks for RPG narrator / plot LLM calls."""
|
||||
|
||||
from services.rpg_plot import count_active_quests
|
||||
import json
|
||||
|
||||
from services.rpg_story import (
|
||||
format_step_for_narrator,
|
||||
normalize_story_arc,
|
||||
step_progress,
|
||||
)
|
||||
|
||||
|
||||
def format_narrator_context(
|
||||
@@ -8,46 +14,21 @@ def format_narrator_context(
|
||||
quests: list | None,
|
||||
status_quo: str = "",
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
arc = arc or {}
|
||||
beats = arc.get("beats") or []
|
||||
if not isinstance(beats, list):
|
||||
beats = []
|
||||
arc = normalize_story_arc(arc or {}) if arc else {}
|
||||
return format_step_for_narrator(arc, quests, status_quo)
|
||||
|
||||
parts.append(f"Plot phase: {arc.get('phase', 'opening')}. Scripted beats left: {len(beats)}.")
|
||||
if not beats:
|
||||
parts.append(
|
||||
"IMPORTANT: Scripted beats are EXHAUSTED (quests may already be done). "
|
||||
"The story must CONTINUE — do not stall. "
|
||||
"Always return 2-4 meaningful choices for the player's next actions. "
|
||||
"You may add quest_updates with status 'active' for NEW optional threads. "
|
||||
"Do NOT re-activate quests the player already completed unless they explicitly revisit that thread."
|
||||
)
|
||||
elif count_active_quests(quests) == 0:
|
||||
pending = [
|
||||
(b.get("title") or b.get("id") or "beat")
|
||||
for b in beats[:3]
|
||||
if isinstance(b, dict)
|
||||
]
|
||||
parts.append(
|
||||
"IMPORTANT: No active quests but scripted beats remain — arc was likely desynced. "
|
||||
"The engine will inject the next beat; prefer choices that fit pending beats: "
|
||||
+ ", ".join(pending)
|
||||
+ ". Do NOT treat the arc as finished."
|
||||
)
|
||||
hint = (arc.get("next_beat_hint") or "").strip()
|
||||
if hint:
|
||||
parts.append(f"Arc hint: {hint}")
|
||||
|
||||
if quests:
|
||||
parts.append("Quest log:")
|
||||
for q in quests:
|
||||
parts.append(f" [{q.get('status', 'active')}] {q.get('title', '')}")
|
||||
else:
|
||||
parts.append("Quest log: (empty)")
|
||||
|
||||
sq = (status_quo or "").strip()
|
||||
if sq:
|
||||
parts.append(f"Status quo: {sq[:400]}")
|
||||
|
||||
return "\n".join(parts)
|
||||
def format_arc_summary_for_runtime(arc: dict | None) -> str:
|
||||
arc = normalize_story_arc(arc or {}) if arc else {}
|
||||
if not arc:
|
||||
return ""
|
||||
cur, total = step_progress(arc)
|
||||
return json.dumps(
|
||||
{
|
||||
"title": arc.get("title"),
|
||||
"global_story": (arc.get("global_story") or "")[:300],
|
||||
"step": f"{cur}/{total}",
|
||||
"status": arc.get("status", "active"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
+59
-7
@@ -20,22 +20,37 @@ Return ONLY valid JSON (no markdown), as an array of objects:
|
||||
Rules:
|
||||
- Return at most 5 NEW facts per turn. If nothing new, return [].
|
||||
- Do NOT repeat or rephrase facts already listed under "Already known".
|
||||
- Facts must be durable (names, relations, inventory, locations, lasting world state).
|
||||
- Facts must be DURABLE world/character state only:
|
||||
traits, relationships, inventory, locations, secrets revealed, lasting abilities/rules.
|
||||
- NEVER store plot events or scene narration (no "they went", "they decided", "they hugged",
|
||||
"they started a new arc", "they stepped through the portal").
|
||||
- Skip momentary emotions unless they permanently change a relationship.
|
||||
- text <= 120 chars each.
|
||||
- rp_day: in-world time label (день 1, второй день, та же ночь, через год). Use RP time hint when unclear."""
|
||||
|
||||
FACTS_COMPRESS_SYSTEM = """You consolidate RPG session memory for a long-running chat.
|
||||
Return ONLY valid JSON (no markdown): an array of {"text": "...", "rp_day": "..."}.
|
||||
Return ONLY valid JSON (no markdown): an array of {{"text": "...", "rp_day": "..."}}.
|
||||
|
||||
Goals:
|
||||
- Use ONLY information from the input facts. NEVER invent or infer new facts.
|
||||
- Aggressively MERGE near-duplicates (same topic in RU/EN, Rin/Рин, Grigo/Григорий).
|
||||
- Keep ONE best fact per topic; combine rp_day if needed (e.g. "день 1–2").
|
||||
- DROP redundant, trivial, or superseded facts.
|
||||
- Keep: names, relationships, key locations, lasting magic/rules, inventory, unresolved threads.
|
||||
- DROP redundant, trivial, superseded, and ALL one-off narrative/event facts.
|
||||
- DROP facts that describe a single scene action (went, decided, hugged, called for help, stepped into portal).
|
||||
- KEEP durable state only: names, nicknames, relationships, inventory, home items, locations,
|
||||
lasting abilities, secrets/identity, unresolved mysteries.
|
||||
- Target at most {target} facts (fewer is better). Each text <= 120 chars.
|
||||
- rp_day = in-world labels only."""
|
||||
|
||||
_NARRATIVE_EVENT_RE = re.compile(
|
||||
r"(?:"
|
||||
r"отправил(?:ись|а|и)?|решили|начали|обнялись|шагнули|вызвали|стали ближе|"
|
||||
r"передали|раскрыла|начали новую арку|вместе шагнули|тактическ(?:ое|и) отступлени|"
|
||||
r"went to|decided to|hugged|stepped through|called for help|started a new arc"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NAME_ALIASES = (
|
||||
("grigoriy", "григорий"),
|
||||
("grigo", "григо"),
|
||||
@@ -135,6 +150,38 @@ def facts_are_similar(a: str, b: str) -> bool:
|
||||
return overlap >= 0.32
|
||||
|
||||
|
||||
def is_likely_narrative_event(text: str) -> bool:
|
||||
"""One-off scene actions — not durable memory."""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return True
|
||||
if _NARRATIVE_EVENT_RE.search(t):
|
||||
return True
|
||||
if "новую арку" in t.lower() or "new arc" in t.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def filter_durable_facts(facts: list[dict]) -> list[dict]:
|
||||
return [f for f in facts if not is_likely_narrative_event(f.get("text", ""))]
|
||||
|
||||
|
||||
def validate_compressed_against_source(
|
||||
original: list[dict], compressed: list[dict]
|
||||
) -> list[dict]:
|
||||
"""Reject LLM-hallucinated facts not grounded in the input list."""
|
||||
if not compressed:
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for c in compressed:
|
||||
text = (c.get("text") or "").strip()
|
||||
if not text or is_likely_narrative_event(text):
|
||||
continue
|
||||
if any(facts_are_similar(text, o.get("text", "")) for o in original):
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def dedupe_facts_fuzzy(facts: list[dict]) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for f in facts:
|
||||
@@ -243,8 +290,10 @@ async def compress_facts(
|
||||
if entry:
|
||||
out.append(entry)
|
||||
if out:
|
||||
logger.info("compress_facts: %d -> %d", len(facts), len(out))
|
||||
return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT]
|
||||
out = validate_compressed_against_source(facts, out)
|
||||
if out:
|
||||
logger.info("compress_facts: %d -> %d", len(facts), len(out))
|
||||
return dedupe_facts_fuzzy(out)[:FACTS_STORE_LIMIT]
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("compress_facts JSON parse failed. Raw=%.400s", raw)
|
||||
return dedupe_facts_fuzzy(facts)[-target:]
|
||||
@@ -263,6 +312,7 @@ async def merge_facts_persist(
|
||||
existing_json, new_facts, rp_day_default=rp_day_default
|
||||
)
|
||||
facts = dedupe_facts_fuzzy(parse_facts_list(merged_json))
|
||||
facts = filter_durable_facts(facts)
|
||||
if len(facts) > FACTS_DEDUP_THRESHOLD:
|
||||
facts = await compress_facts(
|
||||
facts,
|
||||
@@ -340,6 +390,8 @@ async def extract_facts(
|
||||
continue
|
||||
if any(facts_are_similar(entry["text"], k["text"]) for k in known):
|
||||
continue
|
||||
if is_likely_narrative_event(entry["text"]):
|
||||
continue
|
||||
if not entry["rp_day"] and hint:
|
||||
entry["rp_day"] = hint[:80]
|
||||
out.append(entry)
|
||||
@@ -350,7 +402,7 @@ async def extract_facts(
|
||||
|
||||
|
||||
def facts_to_prompt(facts_json: str, max_items: int = FACTS_PROMPT_MAX) -> str:
|
||||
facts = dedupe_facts_fuzzy(parse_facts_list(facts_json))
|
||||
facts = filter_durable_facts(dedupe_facts_fuzzy(parse_facts_list(facts_json)))
|
||||
if not facts:
|
||||
return ""
|
||||
recent = facts[-max_items:]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Infer RP session language from recent chat for narrator/plot prompts."""
|
||||
|
||||
import re
|
||||
|
||||
_CYRILLIC = re.compile(r"[\u0400-\u04FF]")
|
||||
|
||||
|
||||
def infer_rp_language(messages: list | None, *, sample: int = 8) -> str:
|
||||
"""
|
||||
Return 'ru' if recent user/assistant text is mostly Cyrillic, else 'en'.
|
||||
"""
|
||||
if not messages:
|
||||
return "ru"
|
||||
texts: list[str] = []
|
||||
for m in reversed(messages):
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
if m.get("role") not in ("user", "assistant"):
|
||||
continue
|
||||
c = (m.get("content") or "").strip()
|
||||
if c:
|
||||
texts.append(c)
|
||||
if len(texts) >= sample:
|
||||
break
|
||||
if not texts:
|
||||
return "ru"
|
||||
combined = " ".join(texts)
|
||||
cyr = len(_CYRILLIC.findall(combined))
|
||||
lat = len(re.findall(r"[A-Za-z]", combined))
|
||||
if cyr == 0 and lat > 0:
|
||||
return "en"
|
||||
if cyr >= lat:
|
||||
return "ru"
|
||||
return "ru" if cyr > lat * 0.3 else "en"
|
||||
|
||||
|
||||
def locale_instruction(lang: str) -> str:
|
||||
if lang == "ru":
|
||||
return (
|
||||
"Session language: Russian. "
|
||||
"All prose you generate (injections, titles, resolution_text, status_quo, choice labels) "
|
||||
"MUST be in Russian."
|
||||
)
|
||||
return (
|
||||
"Session language: English. "
|
||||
"All prose you generate MUST be in English."
|
||||
)
|
||||
|
||||
|
||||
def locale_label(lang: str) -> str:
|
||||
return "Russian" if lang == "ru" else "English"
|
||||
@@ -40,14 +40,17 @@ Return ONLY valid JSON (no markdown):
|
||||
"stats_delta": {"lust": 0, "stamina": 0, "tension": 0},
|
||||
"scene_update": {"place": "", "place_id": "", "time_of_day": "", "day": "", "weather": "", "exits": [], "layout_note": ""},
|
||||
"quest_updates": [{"title": "quest title", "status": "active|done|failed"}],
|
||||
"outfit_update": ["danbooru_tag", "danbooru_tag"]
|
||||
"outfit_update": ["danbooru_tag", "danbooru_tag"],
|
||||
"step_complete": false,
|
||||
"step_completion_note": "optional 1 sentence when step_complete is true"
|
||||
}
|
||||
Rules:
|
||||
- status_quo_update: internal DM state only (facts, location, mood). Never address the player, never use headers like "Status quo"/"Статус кво", P.S., or author commentary.
|
||||
- affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral.
|
||||
- stats_delta: each lust/stamina/tension -2..+2 (0 if unchanged). lust=arousal, stamina=energy, tension=stress.
|
||||
- scene_update: partial location/time schema; only keys that changed. Do not duplicate all of status_quo into scene_update.
|
||||
- quest_updates: only include if a quest was clearly started, completed, or failed. Empty array otherwise.
|
||||
- quest_updates: legacy; prefer step_complete for story progression. Empty array otherwise.
|
||||
- step_complete: true ONLY when the CURRENT story step completion_criteria are clearly met. Do not rush.
|
||||
- choices: 0-4 options for what the player can do next. REQUIRED when scripted beats are exhausted — never return an empty choices array unless the session truly ended.
|
||||
- outfit_update: ONLY if clothing visibly changed. Use danbooru underscore_tags WITH COLOR when possible
|
||||
(e.g. white_tank_top, black_sports_shorts, gold_championship_belt, blue_jeans, red_ribbon).
|
||||
@@ -64,6 +67,8 @@ async def narrator_pre(
|
||||
roll: int | None = None,
|
||||
outcome: str | None = None,
|
||||
extra_context: str = "",
|
||||
*,
|
||||
lang: str = "ru",
|
||||
) -> dict:
|
||||
roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else ""
|
||||
user = (
|
||||
@@ -76,9 +81,17 @@ async def narrator_pre(
|
||||
)
|
||||
if extra_context:
|
||||
user += f"\n--- Session state ---\n{extra_context}\n---\n"
|
||||
from services.rpg_locale import locale_instruction
|
||||
|
||||
try:
|
||||
raw = await send_message_with_model(
|
||||
[{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}],
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": NARRATOR_PRE_SYSTEM + "\n" + locale_instruction(lang),
|
||||
},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
NARRATOR_MODEL,
|
||||
)
|
||||
except LLMError as e:
|
||||
@@ -111,6 +124,8 @@ async def narrator_post(
|
||||
facts_block: str,
|
||||
is_opening: bool = False,
|
||||
extra_context: str = "",
|
||||
*,
|
||||
lang: str = "ru",
|
||||
) -> dict:
|
||||
opening_block = ""
|
||||
if is_opening:
|
||||
@@ -133,17 +148,25 @@ async def narrator_post(
|
||||
)
|
||||
if extra_context:
|
||||
user += f"\n--- Session state ---\n{extra_context}\n---\n"
|
||||
from services.rpg_locale import locale_instruction
|
||||
|
||||
try:
|
||||
raw = await send_message_with_model(
|
||||
[{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}],
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": NARRATOR_POST_SYSTEM + "\n" + locale_instruction(lang),
|
||||
},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
NARRATOR_MODEL,
|
||||
)
|
||||
except LLMError as e:
|
||||
logger.warning("Narrator-post LLM failed (model=%s): %s", NARRATOR_MODEL, e)
|
||||
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
|
||||
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "step_complete": False, "_ok": False}
|
||||
except Exception as e:
|
||||
logger.warning("Narrator-post unexpected error: %s", e)
|
||||
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "_ok": False}
|
||||
return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": [], "step_complete": False, "_ok": False}
|
||||
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
|
||||
+293
-72
@@ -18,25 +18,36 @@ GENRE_LABELS = {
|
||||
}
|
||||
|
||||
ARC_SYSTEM = """You are a narrative designer for an RPG chat.
|
||||
Given the opening scene (greeting), character info, current facts, and genre(s), produce a STRUCTURED PLOT ARC.
|
||||
Given the opening scene (greeting), character info, current facts, and genre(s), produce ONE LINEAR STORY ARC.
|
||||
Return ONLY valid JSON (no markdown):
|
||||
{
|
||||
"title": "short arc title",
|
||||
"boundaries": ["things that must remain true to preserve immersion"],
|
||||
"phase": "opening|hook|complication|reveal|climax|aftermath",
|
||||
"cast": [{"name":"NPC name","role":"helper|antagonist|bystander","motivation":"..."}],
|
||||
"secrets": ["hidden truths not revealed yet"],
|
||||
"beats": [
|
||||
{"id":"b1","title":"short quest title (3-6 words)","trigger":"event_driven:rest|event_driven:travel|event_driven:help_request|event_driven:after_fail|event_driven:after_success",
|
||||
"injection":"1-3 sentences to introduce the beat WITHOUT breaking current scene",
|
||||
"choices":[{"id":"a","label":"..."},{"id":"b","label":"..."}]}
|
||||
"genre_blend": "e.g. Romance + Adventure",
|
||||
"global_story": "2-4 sentences: setup, through-line, planned finale",
|
||||
"ending": "how this arc resolves",
|
||||
"reward": "conditional reward/hook for player and character after finale",
|
||||
"boundaries": ["no teleporting", "stay in character", "..."],
|
||||
"current_step_index": 0,
|
||||
"status": "active",
|
||||
"steps": [
|
||||
{
|
||||
"id": "s1",
|
||||
"title": "short quest title (3-8 words)",
|
||||
"goal": "what must happen in this episode",
|
||||
"completion_criteria": "concrete signs the step is done (for narrator)",
|
||||
"character_guidance": "how the PC should behave toward the goal",
|
||||
"injection": "1-3 immersive sentences when this step begins",
|
||||
"choices": [{"id":"a","label":"..."},{"id":"b","label":"..."}]
|
||||
}
|
||||
],
|
||||
"next_beat_hint": "short hint for narrator what to push next"
|
||||
"meta": {"arc_number": 1, "previous_arc_summary": ""}
|
||||
}
|
||||
Rules:
|
||||
- Respect the opening scene. Do not jump to unrelated characters immediately.
|
||||
- Beats must feel like natural developments fitting the genre(s). For cross-genre, blend tropes organically.
|
||||
- Keep injections immersive (in-world narration)."""
|
||||
- 3-5 linear steps from opening to finale. ONE quest = ONE step at a time.
|
||||
- Step 1 often matches what already happened in the greeting (shelter, meet, etc.).
|
||||
- Steps must escalate naturally (trust, daily life, adventure, climax).
|
||||
- No event triggers — progression is narrative completion only.
|
||||
- Injections and titles in session language."""
|
||||
|
||||
|
||||
def format_genres(genre: str) -> str:
|
||||
@@ -49,18 +60,33 @@ def format_genres(genre: str) -> str:
|
||||
return " + ".join(labels) + " (cross-genre blend)"
|
||||
|
||||
|
||||
async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenario: str, greeting: str, facts_block: str = "", genre: str = "adventure") -> dict:
|
||||
async def generate_plot_arc(
|
||||
persona_name: str,
|
||||
persona_desc: str,
|
||||
persona_scenario: str,
|
||||
greeting: str,
|
||||
facts_block: str = "",
|
||||
genre: str = "adventure",
|
||||
*,
|
||||
lang: str = "ru",
|
||||
recent_context: str = "",
|
||||
) -> dict:
|
||||
from services.rpg_locale import locale_instruction, locale_label
|
||||
|
||||
user = (
|
||||
f"Character: {persona_name}\n"
|
||||
f"Description: {persona_desc}\n"
|
||||
f"Scenario: {persona_scenario}\n"
|
||||
f"Greeting: {greeting}\n"
|
||||
f"Genre: {format_genres(genre)}\n"
|
||||
f"Session language: {locale_label(lang)}\n"
|
||||
f"Facts:\n{facts_block}\n"
|
||||
).strip()
|
||||
if recent_context.strip():
|
||||
user += f"\nRecent chat:\n{recent_context.strip()[-2000:]}\n"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": ARC_SYSTEM},
|
||||
{"role": "system", "content": ARC_SYSTEM + "\n" + locale_instruction(lang)},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
@@ -85,12 +111,93 @@ async def generate_plot_arc(persona_name: str, persona_desc: str, persona_scenar
|
||||
cleaned = cleaned.strip()
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
return data if isinstance(data, dict) else {}
|
||||
if isinstance(data, dict):
|
||||
from services.rpg_story import normalize_story_arc
|
||||
return normalize_story_arc(data, genre=genre)
|
||||
return {}
|
||||
except Exception:
|
||||
logger.warning("PlotArc JSON parse failed. Raw=%.500s", raw)
|
||||
return {}
|
||||
|
||||
|
||||
NEXT_ARC_SYSTEM = """You are a narrative designer for an RPG chat.
|
||||
The previous story arc COMPLETED. Design the NEXT arc continuing the same characters and relationship.
|
||||
Return ONLY valid JSON (same schema as initial arc):
|
||||
{
|
||||
"title": "...",
|
||||
"genre_blend": "...",
|
||||
"global_story": "...",
|
||||
"ending": "...",
|
||||
"reward": "...",
|
||||
"boundaries": [],
|
||||
"current_step_index": 0,
|
||||
"status": "active",
|
||||
"steps": [ ... 3-5 steps ... ],
|
||||
"meta": {"arc_number": N, "previous_arc_summary": "..."}
|
||||
}
|
||||
Rules:
|
||||
- Build on previous arc outcome and reward (e.g. tickets found → cruise trip).
|
||||
- New arc must feel like a natural sequel, not a reset.
|
||||
- Keep same cast; facts and affinity continue."""
|
||||
|
||||
|
||||
async def generate_next_arc(
|
||||
persona_name: str,
|
||||
persona_desc: str,
|
||||
persona_scenario: str,
|
||||
recent_context: str,
|
||||
*,
|
||||
previous_arc_summary: str = "",
|
||||
facts_block: str = "",
|
||||
genre: str = "adventure",
|
||||
lang: str = "ru",
|
||||
) -> dict:
|
||||
from services.rpg_locale import locale_instruction, locale_label
|
||||
|
||||
user = (
|
||||
f"Character: {persona_name}\n"
|
||||
f"Description: {persona_desc}\n"
|
||||
f"Scenario: {persona_scenario}\n"
|
||||
f"Genre: {format_genres(genre)}\n"
|
||||
f"Session language: {locale_label(lang)}\n"
|
||||
f"Previous arc summary:\n{previous_arc_summary}\n"
|
||||
f"Facts:\n{facts_block}\n"
|
||||
f"Recent chat:\n{recent_context.strip()[-3000:]}\n"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": NEXT_ARC_SYSTEM + "\n" + locale_instruction(lang)},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
raw = await (
|
||||
send_message_with_model(messages, PLOT_MODEL)
|
||||
if PLOT_MODEL
|
||||
else send_message(messages)
|
||||
)
|
||||
except LLMError as e:
|
||||
logger.warning("generate_next_arc failed: %s", e)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.warning("generate_next_arc unexpected: %s", e)
|
||||
return {}
|
||||
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned.rsplit("```", 1)[0]
|
||||
cleaned = cleaned.strip()
|
||||
try:
|
||||
data = json.loads(cleaned)
|
||||
if isinstance(data, dict):
|
||||
from services.rpg_story import normalize_story_arc
|
||||
return normalize_story_arc(data, genre=genre)
|
||||
return {}
|
||||
except Exception:
|
||||
logger.warning("generate_next_arc JSON parse failed. Raw=%.400s", raw)
|
||||
return {}
|
||||
|
||||
|
||||
BEAT_MATCH_SYSTEM = """You decide whether the player's latest message should fire ONE scripted plot beat.
|
||||
Return ONLY valid JSON (no markdown):
|
||||
{"fire_beat_id": "id from list or null", "confidence": "high|low"}
|
||||
@@ -155,6 +262,8 @@ async def classify_plot_beat(
|
||||
beats: list[dict],
|
||||
recent_context: str = "",
|
||||
last_dice_outcome: str | None = None,
|
||||
*,
|
||||
lang: str = "ru",
|
||||
) -> str | None:
|
||||
"""LLM: return beat id to fire, or None."""
|
||||
pending = [b for b in beats if isinstance(b, dict) and b.get("id")]
|
||||
@@ -183,8 +292,13 @@ async def classify_plot_beat(
|
||||
if last_dice_outcome:
|
||||
user += f"\nLast dice outcome this turn: {last_dice_outcome}\n"
|
||||
|
||||
from services.rpg_locale import locale_instruction
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": BEAT_MATCH_SYSTEM},
|
||||
{
|
||||
"role": "system",
|
||||
"content": BEAT_MATCH_SYSTEM + "\n" + locale_instruction(lang),
|
||||
},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
@@ -232,10 +346,100 @@ def beat_title(beat: dict) -> str:
|
||||
return ((beat.get("title") or beat.get("injection") or "")[:120]).strip()
|
||||
|
||||
|
||||
def format_beat_injection_for_character(injection: str, *, lang: str = "ru") -> str:
|
||||
"""Soft plot hint for CHAT model — not a script to copy verbatim."""
|
||||
inj = (injection or "").strip()
|
||||
if not inj:
|
||||
return ""
|
||||
if lang == "ru":
|
||||
header = "--- Сюжетная подсказка (не цитируй дословно) ---"
|
||||
footer = (
|
||||
"Продолжи текущую сцену естественно по-русски; не цитируй подсказку дословно; "
|
||||
"не меняй локацию без согласия игрока."
|
||||
)
|
||||
else:
|
||||
header = "--- Plot hint (do not quote verbatim) ---"
|
||||
footer = (
|
||||
"Continue the scene naturally in English; do not quote the hint verbatim; "
|
||||
"do not change location without player consent."
|
||||
)
|
||||
return f"\n\n{header}\n{inj}\n{footer}\n---"
|
||||
|
||||
|
||||
def arc_user_turn_count(history: list | None) -> int:
|
||||
return sum(1 for m in (history or []) if isinstance(m, dict) and m.get("role") == "user")
|
||||
|
||||
|
||||
def can_fire_beat(arc: dict, user_turn: int, *, min_gap: int = 2) -> bool:
|
||||
meta = arc.get("meta") if isinstance(arc.get("meta"), dict) else {}
|
||||
last = meta.get("last_beat_fired_at_user_turn")
|
||||
if last is None:
|
||||
return True
|
||||
try:
|
||||
last_i = int(last)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
return user_turn - last_i >= min_gap
|
||||
|
||||
|
||||
def record_beat_fired(arc: dict, beat: dict, user_turn: int) -> None:
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
arc["meta"] = meta
|
||||
meta["last_beat_fired_at_user_turn"] = user_turn
|
||||
bid = beat.get("id")
|
||||
if bid:
|
||||
meta["last_beat_id"] = str(bid)
|
||||
|
||||
|
||||
async def complete_quest_for_fired_beat(session_id: str, beat: dict) -> None:
|
||||
"""Mark the fired beat's quest done so reconcile does not orphan it next turn."""
|
||||
from services.memory import upsert_quest
|
||||
|
||||
title = beat_title(beat)
|
||||
if title:
|
||||
await upsert_quest(session_id, title, "done")
|
||||
|
||||
|
||||
def count_active_quests(quests: list | None) -> int:
|
||||
return sum(1 for q in (quests or []) if q.get("status") == "active")
|
||||
|
||||
|
||||
def active_quest_titles_to_close(arc: dict, quests: list | None) -> list[str]:
|
||||
"""Active quests whose title does not match any pending beat in arc."""
|
||||
pending = {
|
||||
beat_title(b).lower()
|
||||
for b in (arc.get("beats") or [])
|
||||
if isinstance(b, dict)
|
||||
}
|
||||
to_close: list[str] = []
|
||||
for q in quests or []:
|
||||
if q.get("status") != "active":
|
||||
continue
|
||||
tl = (q.get("title") or "").strip().lower()
|
||||
if tl and tl not in pending:
|
||||
to_close.append((q.get("title") or "").strip())
|
||||
return to_close
|
||||
|
||||
|
||||
async def reconcile_active_quests_to_arc(session_id: str, arc: dict) -> int:
|
||||
"""Mark active quests done when their beat is no longer in arc (desync after fire/replenish)."""
|
||||
from services.memory import get_quests, upsert_quest
|
||||
|
||||
quests = await get_quests(session_id)
|
||||
titles = active_quest_titles_to_close(arc, quests)
|
||||
for title in titles:
|
||||
await upsert_quest(session_id, title, "done")
|
||||
if titles:
|
||||
logger.info(
|
||||
"reconcile_active_quests_to_arc: closed %d orphan(s) %s",
|
||||
len(titles),
|
||||
titles[:5],
|
||||
)
|
||||
return len(titles)
|
||||
|
||||
|
||||
def prune_beats_for_done_quests(arc: dict, quests: list | None) -> tuple[dict, list[dict]]:
|
||||
"""Drop beats whose title already matches a done/failed quest (manual quest close desync)."""
|
||||
done_titles = {
|
||||
@@ -272,53 +476,79 @@ async def process_arc_beats(
|
||||
*,
|
||||
recent_context: str = "",
|
||||
last_dice_outcome: str | None = None,
|
||||
needs_check: bool = False,
|
||||
user_turn: int = 0,
|
||||
allow_stuck_recovery: bool = True,
|
||||
) -> tuple[dict, list[dict], list[dict], str]:
|
||||
reconcile_closed_count: int = 0,
|
||||
lang: str = "ru",
|
||||
) -> tuple[dict, list[dict], list[dict], str, dict]:
|
||||
"""
|
||||
Prune completed beats, then fire by dice outcome, LLM match, keywords, or stuck recovery.
|
||||
Returns (arc, fired_beats, pruned_beats, mode).
|
||||
Returns (arc, fired_beats, pruned_beats, mode, extras).
|
||||
mode: '' | 'after_dice' | 'llm' | 'trigger' | 'stuck_recovery' | 'pruned'
|
||||
"""
|
||||
extras: dict = {"cooldown_skipped": False}
|
||||
if not arc:
|
||||
return arc, [], [], ""
|
||||
return arc, [], [], "", extras
|
||||
|
||||
arc, pruned = prune_beats_for_done_quests(arc, quests)
|
||||
beats_pending = arc.get("beats") or []
|
||||
|
||||
if not can_fire_beat(arc, user_turn):
|
||||
extras["cooldown_skipped"] = True
|
||||
if pruned:
|
||||
return arc, [], pruned, "pruned", extras
|
||||
return arc, [], [], "", extras
|
||||
|
||||
dice_trig = dice_outcome_to_beat_trigger(last_dice_outcome)
|
||||
if dice_trig and beats_pending:
|
||||
if needs_check and dice_trig and beats_pending:
|
||||
arc, fired = pop_matching_beats(arc, dice_trig, max_beats=1)
|
||||
if fired:
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
logger.info(
|
||||
"process_arc_beats: after_dice %s -> %s",
|
||||
last_dice_outcome,
|
||||
fired[0].get("id"),
|
||||
)
|
||||
return arc, fired, pruned, "after_dice"
|
||||
return arc, fired, pruned, "after_dice", extras
|
||||
|
||||
if beats_pending:
|
||||
beat_id = await classify_plot_beat(
|
||||
user_text, beats_pending, recent_context, last_dice_outcome
|
||||
user_text,
|
||||
beats_pending,
|
||||
recent_context,
|
||||
last_dice_outcome,
|
||||
lang=lang,
|
||||
)
|
||||
if beat_id:
|
||||
arc, fired = pop_beat_by_id(arc, beat_id)
|
||||
if fired:
|
||||
return arc, fired, pruned, "llm"
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
return arc, fired, pruned, "llm", extras
|
||||
|
||||
trig = should_advance_arc_keywords(user_text)
|
||||
if trig:
|
||||
arc, fired = pop_matching_beats(arc, trig, max_beats=1)
|
||||
if fired:
|
||||
return arc, fired, pruned, "trigger"
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
return arc, fired, pruned, "trigger", extras
|
||||
|
||||
if allow_stuck_recovery and arc.get("beats") and count_active_quests(quests) == 0:
|
||||
stuck_ok = (
|
||||
allow_stuck_recovery
|
||||
and reconcile_closed_count == 0
|
||||
and arc.get("beats")
|
||||
and count_active_quests(quests) == 0
|
||||
and can_fire_beat(arc, user_turn)
|
||||
)
|
||||
if stuck_ok:
|
||||
arc, fired = pop_next_beats(arc, 1)
|
||||
if fired:
|
||||
return arc, fired, pruned, "stuck_recovery"
|
||||
record_beat_fired(arc, fired[0], user_turn)
|
||||
return arc, fired, pruned, "stuck_recovery", extras
|
||||
|
||||
if pruned:
|
||||
return arc, [], pruned, "pruned"
|
||||
return arc, [], [], ""
|
||||
return arc, [], pruned, "pruned", extras
|
||||
return arc, [], [], "", extras
|
||||
|
||||
|
||||
PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"]
|
||||
@@ -360,6 +590,8 @@ async def replenish_arc_beats(
|
||||
recent_context: str,
|
||||
quests: list,
|
||||
genre: str = "adventure",
|
||||
*,
|
||||
lang: str = "ru",
|
||||
) -> dict:
|
||||
"""Append new beats when arc.beats is empty so plot/quest engine can continue."""
|
||||
if arc.get("beats"):
|
||||
@@ -368,9 +600,12 @@ async def replenish_arc_beats(
|
||||
quest_lines = "\n".join(
|
||||
f" [{q.get('status')}] {q.get('title')}" for q in (quests or [])
|
||||
) or " (none)"
|
||||
from services.rpg_locale import locale_instruction, locale_label
|
||||
|
||||
user = (
|
||||
f"Character: {persona_name}\n"
|
||||
f"Genre: {format_genres(genre)}\n"
|
||||
f"Session language: {locale_label(lang)}\n"
|
||||
f"Current arc title: {arc.get('title', '')}\n"
|
||||
f"Phase: {arc.get('phase', 'aftermath')}\n"
|
||||
f"Boundaries: {json.dumps(arc.get('boundaries', []), ensure_ascii=False)}\n"
|
||||
@@ -378,7 +613,7 @@ async def replenish_arc_beats(
|
||||
f"Recent chat:\n{recent_context[-4000:]}\n"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": BEATS_APPEND_SYSTEM},
|
||||
{"role": "system", "content": BEATS_APPEND_SYSTEM + "\n" + locale_instruction(lang)},
|
||||
{"role": "user", "content": user},
|
||||
]
|
||||
try:
|
||||
@@ -425,41 +660,14 @@ async def reconcile_plot_arc(
|
||||
persona_name: str = "Character",
|
||||
genre: str = "adventure",
|
||||
) -> tuple[dict, bool]:
|
||||
"""
|
||||
Prune beats that match done quests; replenish if empty. Persists arc when changed.
|
||||
Returns (arc, changed).
|
||||
"""
|
||||
from services.memory import get_session, get_quests, update_session_plot_arc, seed_quests_from_arc
|
||||
"""Sync linear story arc and single active quest. replenish_if_empty ignored (legacy)."""
|
||||
from services.rpg_story import reconcile_story_arc
|
||||
|
||||
session = await get_session(session_id)
|
||||
if not session or not session.get("rpg_enabled"):
|
||||
return {}, False
|
||||
try:
|
||||
arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arc = {}
|
||||
if not isinstance(arc, dict):
|
||||
arc = {}
|
||||
|
||||
quests = await get_quests(session_id)
|
||||
arc, pruned = prune_beats_for_done_quests(arc, quests)
|
||||
changed = bool(pruned)
|
||||
|
||||
if replenish_if_empty and not arc.get("beats"):
|
||||
arc = await replenish_arc_beats(
|
||||
arc,
|
||||
persona_name,
|
||||
recent_context,
|
||||
quests,
|
||||
genre=session.get("genre") or genre,
|
||||
)
|
||||
if arc.get("beats"):
|
||||
changed = True
|
||||
await seed_quests_from_arc(session_id, arc)
|
||||
|
||||
if changed:
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
return arc, changed
|
||||
return await reconcile_story_arc(
|
||||
session_id,
|
||||
persona_name=persona_name,
|
||||
genre=genre,
|
||||
)
|
||||
|
||||
|
||||
def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]:
|
||||
@@ -503,15 +711,28 @@ def normalize_choice(
|
||||
|
||||
|
||||
def choices_from_beat(beat: dict) -> list[dict]:
|
||||
if not isinstance(beat, dict):
|
||||
return choices_from_step(beat)
|
||||
|
||||
|
||||
def choices_from_step(step: dict) -> list[dict]:
|
||||
if not isinstance(step, dict):
|
||||
return []
|
||||
return [
|
||||
c for c in (
|
||||
normalize_choice(item, source="plot_beat", beat=beat)
|
||||
for item in (beat.get("choices") or [])
|
||||
)
|
||||
if c
|
||||
]
|
||||
out = []
|
||||
for item in (step.get("choices") or []):
|
||||
c = normalize_choice(item, source="plot_step", beat=step)
|
||||
if c:
|
||||
if step.get("id"):
|
||||
c["step_id"] = step["id"]
|
||||
c["beat_id"] = step["id"]
|
||||
title = (step.get("title") or "").strip()
|
||||
if title:
|
||||
c["beat_title"] = title
|
||||
c["step_title"] = title
|
||||
inj = (step.get("injection") or "").strip()
|
||||
if inj:
|
||||
c["beat_injection"] = inj
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def choices_from_narrator(raw_choices: list) -> list[dict]:
|
||||
|
||||
+58
-20
@@ -186,31 +186,45 @@ def stats_prompt_block(stats: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def format_narrator_outcome_for_llm(data: dict) -> str:
|
||||
def format_narrator_outcome_for_llm(data: dict, *, lang: str = "ru") -> str:
|
||||
"""Turn stored narrator JSON into a binding user-turn for the character model."""
|
||||
roll = data.get("roll")
|
||||
outcome = (data.get("outcome") or "").strip().lower()
|
||||
text = (data.get("text") or "").strip()
|
||||
lines = [
|
||||
"--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
|
||||
f"Roll d20={roll}. Outcome: {outcome}.",
|
||||
f"What ACTUALLY happened (canonical truth): {text}",
|
||||
]
|
||||
if outcome in ("failure", "critical failure"):
|
||||
lines.append(
|
||||
"The player's action FAILED as they imagined it. "
|
||||
"Do NOT write a success version: no crowd fleeing, no intimidation working, "
|
||||
"no effortless victory. Show the failure, embarrassment, or partial result above."
|
||||
)
|
||||
elif outcome == "critical success":
|
||||
lines.append(
|
||||
"The attempt succeeded dramatically. You may show amplified success aligned with the outcome above."
|
||||
)
|
||||
if lang == "ru":
|
||||
lines = [
|
||||
"--- Правило рассказчика (ОБЯЗАТЕЛЬНО — ответ персонажа должен ему следовать) ---",
|
||||
f"Бросок d20={roll}. Исход: {outcome}.",
|
||||
f"Что РЕАЛЬНО произошло (канон): {text}",
|
||||
]
|
||||
if outcome in ("failure", "critical failure"):
|
||||
lines.append(
|
||||
"Действие игрока НЕ удалось. Не пиши успешную версию — покажи провал или частичный результат выше."
|
||||
)
|
||||
elif outcome == "critical success":
|
||||
lines.append("Попытка удалась блестяще. Усиль успех в духе исхода выше.")
|
||||
else:
|
||||
lines.append("Попытка удалась. Ответ должен совпадать с исходом выше, не противоречить ему.")
|
||||
lines.append("Отвечай только как персонаж на ЭТОТ исход. Не упоминай кубики, броски, статы.")
|
||||
else:
|
||||
lines.append(
|
||||
"The attempt succeeded. Your reply must align with the narrator outcome above, not contradict it."
|
||||
)
|
||||
lines.append("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
|
||||
lines = [
|
||||
"--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
|
||||
f"Roll d20={roll}. Outcome: {outcome}.",
|
||||
f"What ACTUALLY happened (canonical truth): {text}",
|
||||
]
|
||||
if outcome in ("failure", "critical failure"):
|
||||
lines.append(
|
||||
"The player's action FAILED. Do NOT write a success version; show failure per above."
|
||||
)
|
||||
elif outcome == "critical success":
|
||||
lines.append(
|
||||
"The attempt succeeded dramatically. Align with the outcome above."
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
"The attempt succeeded. Your reply must align with the narrator outcome above."
|
||||
)
|
||||
lines.append("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -319,3 +333,27 @@ async def apply_narrator_post(session_id: str, post: dict, rpg_settings: dict, s
|
||||
applied["quests_updated"] += 1
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
async def apply_narrator_post_with_story(
|
||||
session_id: str,
|
||||
post: dict,
|
||||
rpg_settings: dict,
|
||||
session: dict | None = None,
|
||||
arc: dict | None = None,
|
||||
) -> dict:
|
||||
"""apply_narrator_post + linear story step advance."""
|
||||
from services.rpg_story import apply_story_post, normalize_story_arc
|
||||
|
||||
applied = await apply_narrator_post(session_id, post, rpg_settings, session)
|
||||
arc = normalize_story_arc(arc or {})
|
||||
story = await apply_story_post(session_id, post, arc, rpg_settings)
|
||||
applied.update({
|
||||
"step_advanced": story.get("step_advanced", False),
|
||||
"arc_completed": story.get("arc_completed", False),
|
||||
"new_step_title": story.get("new_step_title", ""),
|
||||
"step_injection": story.get("step_injection", ""),
|
||||
})
|
||||
if story.get("arc"):
|
||||
applied["arc"] = story["arc"]
|
||||
return applied
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Linear story arc: global plot, steps, one active quest."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate_beats_to_steps(arc: dict) -> dict:
|
||||
"""One-time: collapse legacy beats[] into steps[]."""
|
||||
if not isinstance(arc, dict):
|
||||
return {}
|
||||
if arc.get("steps"):
|
||||
return arc
|
||||
beats = arc.get("beats") or []
|
||||
if not isinstance(beats, list) or not beats:
|
||||
return arc
|
||||
steps = []
|
||||
for i, b in enumerate(beats):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
title = (b.get("title") or b.get("injection") or f"Step {i + 1}").strip()[:120]
|
||||
steps.append({
|
||||
"id": b.get("id") or f"s_m{i + 1}",
|
||||
"title": title,
|
||||
"goal": title,
|
||||
"completion_criteria": f"Player and character naturally complete: {title}",
|
||||
"character_guidance": "Stay in character; move toward the goal without teleporting.",
|
||||
"injection": (b.get("injection") or "").strip(),
|
||||
"choices": b.get("choices") or [],
|
||||
})
|
||||
arc = dict(arc)
|
||||
arc["steps"] = steps
|
||||
arc.pop("beats", None)
|
||||
arc.setdefault("current_step_index", 0)
|
||||
arc.setdefault("status", "active")
|
||||
arc.setdefault("global_story", arc.get("next_beat_hint") or arc.get("title") or "")
|
||||
arc.setdefault("ending", "")
|
||||
arc.setdefault("reward", "")
|
||||
logger.info("migrate_beats_to_steps: %d steps", len(steps))
|
||||
return arc
|
||||
|
||||
|
||||
def normalize_story_arc(raw: dict, genre: str = "adventure") -> dict:
|
||||
"""Ensure required fields on a story arc from LLM or migration."""
|
||||
from services.rpg_plot import format_genres
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
arc = migrate_beats_to_steps(raw)
|
||||
arc.setdefault("title", "Story arc")
|
||||
arc.setdefault("genre_blend", format_genres(genre))
|
||||
arc.setdefault("global_story", "")
|
||||
arc.setdefault("ending", "")
|
||||
arc.setdefault("reward", "")
|
||||
arc.setdefault("boundaries", [])
|
||||
arc.setdefault("current_step_index", 0)
|
||||
arc.setdefault("status", "active")
|
||||
if not isinstance(arc.get("steps"), list):
|
||||
arc["steps"] = []
|
||||
arc["steps"] = [
|
||||
normalize_step(s, i) for i, s in enumerate(arc.get("steps") or []) if isinstance(s, dict)
|
||||
]
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
arc["meta"] = {"arc_number": 1, "previous_arc_summary": ""}
|
||||
else:
|
||||
meta.setdefault("arc_number", 1)
|
||||
meta.setdefault("previous_arc_summary", "")
|
||||
try:
|
||||
arc["current_step_index"] = max(0, int(arc.get("current_step_index") or 0))
|
||||
except (TypeError, ValueError):
|
||||
arc["current_step_index"] = 0
|
||||
return arc
|
||||
|
||||
|
||||
def normalize_step(step: dict, index: int = 0) -> dict:
|
||||
"""Repair legacy beat-shaped steps (status_quo / choices[].injection)."""
|
||||
s = dict(step)
|
||||
title = (s.get("title") or s.get("goal") or "").strip()
|
||||
if not title:
|
||||
sq = (s.get("status_quo") or "").strip()
|
||||
if sq:
|
||||
title = sq.split(".")[0].strip()[:120] or sq[:120]
|
||||
else:
|
||||
title = f"Шаг {index + 1}"
|
||||
s["id"] = (s.get("id") or f"s{index + 1}").strip()
|
||||
s["title"] = title[:120]
|
||||
s["goal"] = (s.get("goal") or title).strip()[:200]
|
||||
s.setdefault(
|
||||
"completion_criteria",
|
||||
f"Сцена естественно завершает эпизод: {title}",
|
||||
)
|
||||
s.setdefault(
|
||||
"character_guidance",
|
||||
"Оставайся в роли; веди сцену к цели шага без телепортов.",
|
||||
)
|
||||
inj = resolve_step_injection(s)
|
||||
if inj:
|
||||
s["injection"] = inj
|
||||
return s
|
||||
|
||||
|
||||
def resolve_step_injection(step: dict | None) -> str:
|
||||
if not isinstance(step, dict):
|
||||
return ""
|
||||
inj = (step.get("injection") or "").strip()
|
||||
if inj:
|
||||
return inj
|
||||
for item in step.get("choices") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
c_inj = (item.get("injection") or "").strip()
|
||||
if c_inj:
|
||||
return c_inj
|
||||
return (step.get("status_quo") or "").strip()
|
||||
|
||||
|
||||
def format_new_arc_opening(arc: dict | None, step: dict | None, *, lang: str = "ru") -> str:
|
||||
arc = arc or {}
|
||||
title = (arc.get("title") or "Новая арка").strip()
|
||||
inj = resolve_step_injection(step)
|
||||
if not inj:
|
||||
inj = (arc.get("global_story") or "").strip()[:400]
|
||||
if lang == "ru":
|
||||
header = f"📖 Новая арка: «{title}»"
|
||||
else:
|
||||
header = f"📖 New arc: «{title}»"
|
||||
return f"{header}\n\n{inj}".strip() if inj else header
|
||||
|
||||
|
||||
def get_current_step(arc: dict | None) -> dict | None:
|
||||
arc = arc or {}
|
||||
steps = arc.get("steps") or []
|
||||
if not isinstance(steps, list) or not steps:
|
||||
return None
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
if idx < 0 or idx >= len(steps):
|
||||
return None
|
||||
step = steps[idx]
|
||||
return step if isinstance(step, dict) else None
|
||||
|
||||
|
||||
def step_progress(arc: dict | None) -> tuple[int, int]:
|
||||
arc = arc or {}
|
||||
steps = arc.get("steps") or []
|
||||
total = len(steps) if isinstance(steps, list) else 0
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
if total == 0:
|
||||
return 0, 0
|
||||
return min(idx + 1, total), total
|
||||
|
||||
|
||||
def is_arc_completed(arc: dict | None) -> bool:
|
||||
arc = arc or {}
|
||||
if arc.get("status") == "completed":
|
||||
return True
|
||||
steps = arc.get("steps") or []
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
return bool(steps) and idx >= len(steps)
|
||||
|
||||
|
||||
def should_show_step_injection(arc: dict) -> bool:
|
||||
if is_arc_completed(arc):
|
||||
return False
|
||||
meta = arc.get("meta") if isinstance(arc.get("meta"), dict) else {}
|
||||
shown = meta.get("injection_shown_for_step")
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
return shown != idx
|
||||
|
||||
|
||||
def mark_injection_shown(arc: dict) -> None:
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
arc["meta"] = meta
|
||||
meta["injection_shown_for_step"] = int(arc.get("current_step_index") or 0)
|
||||
|
||||
|
||||
def format_step_guidance_for_character(
|
||||
step: dict, arc: dict | None = None, *, lang: str = "ru"
|
||||
) -> str:
|
||||
if not step:
|
||||
return ""
|
||||
goal = (step.get("goal") or step.get("title") or "").strip()
|
||||
guidance = (step.get("character_guidance") or "").strip()
|
||||
title = (step.get("title") or "").strip()
|
||||
cur, total = step_progress(arc or {})
|
||||
global_story = ((arc or {}).get("global_story") or "").strip()
|
||||
ending = ((arc or {}).get("ending") or "").strip()
|
||||
|
||||
if lang == "ru":
|
||||
lines = [
|
||||
"--- Текущий шаг сюжета (ОБЯЗАТЕЛЬНОЕ направление) ---",
|
||||
f"Шаг {cur}/{total}: {title}" if total else f"Шаг: {title}",
|
||||
f"Цель эпизода: {goal}",
|
||||
]
|
||||
if guidance:
|
||||
lines.append(f"Поведение персонажа: {guidance}")
|
||||
if global_story:
|
||||
lines.append(f"Глобальный конвой: {global_story[:400]}")
|
||||
if ending:
|
||||
lines.append(f"Финал арки (не раскрывать досрочно): {ending[:300]}")
|
||||
lines.append(
|
||||
"Веди сцену к цели шага естественно, в роли, без телепортов и смены локации без игрока."
|
||||
)
|
||||
lines.append("---")
|
||||
else:
|
||||
lines = [
|
||||
"--- Current story step (MANDATORY direction) ---",
|
||||
f"Step {cur}/{total}: {title}" if total else f"Step: {title}",
|
||||
f"Episode goal: {goal}",
|
||||
]
|
||||
if guidance:
|
||||
lines.append(f"Character behavior: {guidance}")
|
||||
if global_story:
|
||||
lines.append(f"Global arc: {global_story[:400]}")
|
||||
if ending:
|
||||
lines.append(f"Arc ending (do not spoil early): {ending[:300]}")
|
||||
lines.append(
|
||||
"Move the scene toward the step goal naturally, in character, no teleporting."
|
||||
)
|
||||
lines.append("---")
|
||||
return "\n\n" + "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def format_step_hint_for_character(injection: str, *, lang: str = "ru") -> str:
|
||||
inj = (injection or "").strip()
|
||||
if not inj:
|
||||
return ""
|
||||
if lang == "ru":
|
||||
header = "--- Сюжетная подсказка (не цитируй дословно) ---"
|
||||
footer = (
|
||||
"Продолжи текущую сцену естественно по-русски; не цитируй подсказку дословно; "
|
||||
"не меняй локацию без согласия игрока."
|
||||
)
|
||||
else:
|
||||
header = "--- Plot hint (do not quote verbatim) ---"
|
||||
footer = (
|
||||
"Continue naturally in English; do not quote verbatim; "
|
||||
"do not change location without player consent."
|
||||
)
|
||||
return f"\n\n{header}\n{inj}\n{footer}\n---"
|
||||
|
||||
|
||||
def format_step_for_narrator(
|
||||
arc: dict | None,
|
||||
quests: list | None = None,
|
||||
status_quo: str = "",
|
||||
) -> str:
|
||||
arc = normalize_story_arc(arc or {}) if arc else {}
|
||||
parts: list[str] = []
|
||||
parts.append(f"Story arc: {arc.get('title', '')} [{arc.get('status', 'active')}]")
|
||||
if arc.get("global_story"):
|
||||
parts.append(f"Global story: {arc['global_story'][:500]}")
|
||||
if arc.get("ending"):
|
||||
parts.append(f"Planned ending: {arc['ending'][:300]}")
|
||||
if arc.get("reward"):
|
||||
parts.append(f"Reward hook: {arc['reward'][:200]}")
|
||||
|
||||
cur, total = step_progress(arc)
|
||||
step = get_current_step(arc)
|
||||
if is_arc_completed(arc):
|
||||
parts.append(
|
||||
"IMPORTANT: Arc COMPLETED. Offer 2-4 choices including starting a NEW story arc "
|
||||
"(e.g. label about a new adventure/chapter) while keeping character continuity."
|
||||
)
|
||||
elif step:
|
||||
parts.append(f"Current step: {cur}/{total} — «{step.get('title', '')}»")
|
||||
parts.append(f"Step goal: {step.get('goal', '')}")
|
||||
parts.append(f"Completion criteria: {step.get('completion_criteria', '')}")
|
||||
parts.append(
|
||||
"Set step_complete:true ONLY when completion_criteria are clearly met in recent chat. "
|
||||
"Do NOT use quest_updates for step progression — the engine handles quests."
|
||||
)
|
||||
else:
|
||||
parts.append("No active step — arc may need rollover.")
|
||||
|
||||
if quests:
|
||||
parts.append("Quest log:")
|
||||
for q in quests:
|
||||
parts.append(f" [{q.get('status', 'active')}] {q.get('title', '')}")
|
||||
|
||||
sq = (status_quo or "").strip()
|
||||
if sq:
|
||||
parts.append(f"Status quo: {sq[:400]}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def sync_quest_to_current_step(session_id: str, arc: dict) -> int:
|
||||
from services.memory import get_quests, upsert_quest
|
||||
|
||||
arc = normalize_story_arc(arc)
|
||||
quests = await get_quests(session_id)
|
||||
step = get_current_step(arc)
|
||||
closed = 0
|
||||
|
||||
if is_arc_completed(arc) or not step:
|
||||
for q in quests:
|
||||
if q.get("status") == "active":
|
||||
await upsert_quest(session_id, q["title"], "done")
|
||||
closed += 1
|
||||
return closed
|
||||
|
||||
target_title = (step.get("title") or "Current step").strip()[:120]
|
||||
for q in quests:
|
||||
if q.get("status") == "active" and (q.get("title") or "").strip() != target_title:
|
||||
await upsert_quest(session_id, q["title"], "done")
|
||||
closed += 1
|
||||
|
||||
existing_titles = {(q.get("title") or "").strip() for q in quests}
|
||||
if target_title not in existing_titles:
|
||||
await upsert_quest(session_id, target_title, "active")
|
||||
else:
|
||||
for q in quests:
|
||||
if (q.get("title") or "").strip() == target_title and q.get("status") != "active":
|
||||
await upsert_quest(session_id, target_title, "active")
|
||||
return closed
|
||||
|
||||
|
||||
async def apply_step_advance(session_id: str, arc: dict) -> dict:
|
||||
from services.memory import update_session_plot_arc, upsert_quest
|
||||
|
||||
arc = normalize_story_arc(arc)
|
||||
steps = arc.get("steps") or []
|
||||
idx = int(arc.get("current_step_index") or 0)
|
||||
result = {
|
||||
"advanced": False,
|
||||
"arc_completed": False,
|
||||
"new_step": None,
|
||||
"injection": "",
|
||||
"step_index": idx,
|
||||
}
|
||||
|
||||
if is_arc_completed(arc) or idx >= len(steps):
|
||||
arc["status"] = "completed"
|
||||
result["arc_completed"] = True
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
return result
|
||||
|
||||
old_step = steps[idx] if idx < len(steps) else None
|
||||
if old_step and isinstance(old_step, dict):
|
||||
title = (old_step.get("title") or "").strip()
|
||||
if title:
|
||||
await upsert_quest(session_id, title, "done")
|
||||
|
||||
idx += 1
|
||||
arc["current_step_index"] = idx
|
||||
if idx >= len(steps):
|
||||
arc["status"] = "completed"
|
||||
result["arc_completed"] = True
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
else:
|
||||
new_step = steps[idx]
|
||||
result["advanced"] = True
|
||||
result["new_step"] = new_step
|
||||
result["step_index"] = idx
|
||||
if isinstance(new_step, dict):
|
||||
result["injection"] = (new_step.get("injection") or "").strip()
|
||||
await sync_quest_to_current_step(session_id, arc)
|
||||
meta = arc.get("meta")
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
arc["meta"] = meta
|
||||
meta.pop("injection_shown_for_step", None)
|
||||
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
logger.info("apply_step_advance: idx=%s completed=%s", idx, result["arc_completed"])
|
||||
return result
|
||||
|
||||
|
||||
async def reconcile_story_arc(
|
||||
session_id: str,
|
||||
*,
|
||||
persona_name: str = "Character",
|
||||
genre: str = "adventure",
|
||||
) -> tuple[dict, bool]:
|
||||
from services.memory import get_session, update_session_plot_arc
|
||||
|
||||
session = await get_session(session_id)
|
||||
if not session or not session.get("rpg_enabled"):
|
||||
return {}, False
|
||||
try:
|
||||
arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arc = {}
|
||||
if not isinstance(arc, dict):
|
||||
arc = {}
|
||||
|
||||
before = json.dumps(arc, sort_keys=True)
|
||||
arc = normalize_story_arc(arc, genre=session.get("genre") or genre)
|
||||
changed = before != json.dumps(arc, sort_keys=True)
|
||||
|
||||
closed = await sync_quest_to_current_step(session_id, arc)
|
||||
if closed:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await update_session_plot_arc(session_id, json.dumps(arc, ensure_ascii=False))
|
||||
return arc, changed
|
||||
|
||||
|
||||
async def apply_story_post(
|
||||
session_id: str,
|
||||
post: dict,
|
||||
arc: dict,
|
||||
rpg_settings: dict,
|
||||
) -> dict:
|
||||
from services.memory import get_session
|
||||
|
||||
arc = normalize_story_arc(arc)
|
||||
out = {
|
||||
"step_advanced": False,
|
||||
"arc_completed": False,
|
||||
"step_injection": "",
|
||||
"new_step_title": "",
|
||||
"arc": arc,
|
||||
}
|
||||
|
||||
if not rpg_settings.get("quests", True) or is_arc_completed(arc):
|
||||
return out
|
||||
|
||||
note = (post.get("step_completion_note") or "").strip()
|
||||
step_complete = bool(post.get("step_complete"))
|
||||
# LLM sometimes provides a completion note but misses the boolean.
|
||||
# Treat a non-empty note as a conservative signal to advance.
|
||||
if not step_complete and note:
|
||||
step_complete = True
|
||||
logger.info("step_complete fallback via note: %s", note[:200])
|
||||
|
||||
if not step_complete:
|
||||
return out
|
||||
|
||||
if note:
|
||||
logger.info("step_complete: %s", note[:200])
|
||||
|
||||
advance = await apply_step_advance(session_id, arc)
|
||||
out["step_advanced"] = advance.get("advanced", False)
|
||||
out["arc_completed"] = advance.get("arc_completed", False)
|
||||
out["step_injection"] = advance.get("injection") or ""
|
||||
new_step = advance.get("new_step")
|
||||
if isinstance(new_step, dict):
|
||||
out["new_step_title"] = (new_step.get("title") or "").strip()
|
||||
|
||||
session = await get_session(session_id) or {}
|
||||
try:
|
||||
out["arc"] = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
NEW_ARC_CHOICE_MARKERS = (
|
||||
"новая арка",
|
||||
"новую арку",
|
||||
"new arc",
|
||||
"new chapter",
|
||||
"следующая арка",
|
||||
"начать новую",
|
||||
# LLM/UI sometimes label next-arc choices as "new quest" instead of "new arc".
|
||||
# The router still keys off this function, so we recognize both.
|
||||
"новый квест",
|
||||
"новую квест",
|
||||
"начать новый квест",
|
||||
"new quest",
|
||||
"start new quest",
|
||||
)
|
||||
|
||||
|
||||
def is_new_arc_request(message: str) -> bool:
|
||||
t = (message or "").lower()
|
||||
return any(m in t for m in NEW_ARC_CHOICE_MARKERS)
|
||||
|
||||
|
||||
def normalize_new_arc_first(value: str | None) -> str | None:
|
||||
v = (value or "").strip().lower()
|
||||
return v if v in ("user", "character") else None
|
||||
|
||||
|
||||
def new_arc_roll_choice(*, lang: str = "ru") -> dict:
|
||||
label = "Начать новую арку" if lang == "ru" else "Start new arc"
|
||||
return {
|
||||
"id": "new_arc",
|
||||
"type": "new_arc_roll",
|
||||
"label": label,
|
||||
"source": "story_engine",
|
||||
}
|
||||
|
||||
|
||||
def filter_new_arc_noise_choices(choices: list) -> list:
|
||||
"""Drop LLM 'start new quest' duplicates when we show the structured roll button."""
|
||||
out = []
|
||||
for c in choices or []:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
if c.get("type") == "new_arc_roll":
|
||||
out.append(c)
|
||||
continue
|
||||
if is_new_arc_request(c.get("label", "")):
|
||||
continue
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
def append_new_arc_roll_choice(choices: list, *, lang: str = "ru") -> list:
|
||||
choices = filter_new_arc_noise_choices(choices)
|
||||
if any(c.get("type") == "new_arc_roll" for c in choices):
|
||||
return choices
|
||||
return [*choices, new_arc_roll_choice(lang=lang)]
|
||||
|
||||
|
||||
async def roll_next_arc(
|
||||
session_id: str,
|
||||
persona: dict,
|
||||
greeting_or_context: str,
|
||||
genre: str,
|
||||
*,
|
||||
lang: str = "ru",
|
||||
recent_context: str = "",
|
||||
facts_block: str = "",
|
||||
) -> dict:
|
||||
from services.memory import get_session, update_session_plot_arc
|
||||
from services.rpg_plot import generate_next_arc
|
||||
|
||||
session = await get_session(session_id) or {}
|
||||
try:
|
||||
old_arc = json.loads(session.get("plot_arc_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
old_arc = {}
|
||||
|
||||
summary = (
|
||||
f"Title: {old_arc.get('title', '')}. "
|
||||
f"Story: {(old_arc.get('global_story') or '')[:500]}. "
|
||||
f"Ending: {(old_arc.get('ending') or '')[:300]}. "
|
||||
f"Reward: {(old_arc.get('reward') or '')[:200]}."
|
||||
)
|
||||
arc_num = int((old_arc.get("meta") or {}).get("arc_number") or 1) + 1
|
||||
|
||||
ctx_parts = [recent_context.strip(), greeting_or_context.strip()]
|
||||
combined_context = "\n".join(p for p in ctx_parts if p)
|
||||
|
||||
new_arc = await generate_next_arc(
|
||||
persona.get("name", "Character"),
|
||||
persona.get("description", ""),
|
||||
persona.get("scenario", ""),
|
||||
combined_context,
|
||||
previous_arc_summary=summary,
|
||||
facts_block=facts_block,
|
||||
genre=genre,
|
||||
lang=lang,
|
||||
)
|
||||
if not new_arc:
|
||||
return old_arc
|
||||
|
||||
new_arc = normalize_story_arc(new_arc, genre=genre)
|
||||
meta = new_arc.get("meta") or {}
|
||||
meta["arc_number"] = arc_num
|
||||
meta["previous_arc_summary"] = summary[:800]
|
||||
new_arc["meta"] = meta
|
||||
new_arc["current_step_index"] = 0
|
||||
new_arc["status"] = "active"
|
||||
|
||||
await update_session_plot_arc(session_id, json.dumps(new_arc, ensure_ascii=False))
|
||||
await sync_quest_to_current_step(session_id, new_arc)
|
||||
logger.info("roll_next_arc: arc #%s «%s»", arc_num, new_arc.get("title"))
|
||||
return new_arc
|
||||
@@ -463,6 +463,73 @@ header h1 { font-size: 1.1rem; color: #e94560; }
|
||||
background: rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
|
||||
.choice-row-new-arc {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.new-arc-roll {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(233, 69, 96, 0.45);
|
||||
background: linear-gradient(135deg, rgba(233, 69, 96, 0.12), rgba(201, 162, 39, 0.1));
|
||||
}
|
||||
.new-arc-roll-header {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f5d0d8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.new-arc-roll-hint {
|
||||
font-size: 0.78rem;
|
||||
color: #b8b0c8;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.new-arc-roll-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(15, 52, 96, 0.9);
|
||||
}
|
||||
.new-arc-half {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 72px;
|
||||
padding: 12px 10px;
|
||||
border: none;
|
||||
background: #16213e;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.new-arc-half-user {
|
||||
border-right: 1px solid rgba(15, 52, 96, 0.9);
|
||||
}
|
||||
.new-arc-half:hover {
|
||||
background: rgba(233, 69, 96, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
.new-arc-half-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.new-arc-half-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.new-arc-half-sub {
|
||||
font-size: 0.72rem;
|
||||
color: #9aa3b8;
|
||||
}
|
||||
.new-arc-half:hover .new-arc-half-sub {
|
||||
color: #e8edf7;
|
||||
}
|
||||
|
||||
|
||||
.typing {
|
||||
align-self: flex-start;
|
||||
@@ -763,6 +830,13 @@ textarea:focus { border-color: #e94560; }
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.quest-panel-arc-header {
|
||||
font-size: 0.72rem;
|
||||
color: #c9a227;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.quest-panel-arc-header.hidden { display: none; }
|
||||
.quest-panel-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #555;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<div class="session-list" id="sessionList"></div>
|
||||
<div class="quest-panel hidden" id="questPanel">
|
||||
<div class="quest-panel-header">Квесты</div>
|
||||
<div class="quest-panel-arc-header hidden" id="questPanelHeader"></div>
|
||||
<p class="quest-panel-hint" id="questPanelHint">Клик по 🔸 — выбор, затем кнопка ниже</p>
|
||||
<div id="questList"></div>
|
||||
<div class="quest-panel-actions" id="questPanelActions">
|
||||
|
||||
+111
-16
@@ -154,12 +154,63 @@ export function ensureMessageActionsLast(wrapper) {
|
||||
if (actions) wrapper.appendChild(actions);
|
||||
}
|
||||
|
||||
function renderNewArcRollButton(wrapper, choice) {
|
||||
removeChoiceRows(wrapper);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'choice-row choice-row-new-arc';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'new-arc-roll';
|
||||
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'new-arc-roll-header';
|
||||
hdr.textContent = choice?.label || 'Начать новую арку';
|
||||
panel.appendChild(hdr);
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'new-arc-roll-hint';
|
||||
hint.textContent = 'Арка завершена. Можно продолжить эпилог или начать новую — кто ходит первым после инъекта рассказчика?';
|
||||
panel.appendChild(hint);
|
||||
|
||||
const split = document.createElement('div');
|
||||
split.className = 'new-arc-roll-split';
|
||||
|
||||
const mkHalf = (side, icon, title, subtitle, first) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `new-arc-half new-arc-half-${side}`;
|
||||
btn.innerHTML = `<span class="new-arc-half-icon">${icon}</span><span class="new-arc-half-title">${title}</span><span class="new-arc-half-sub">${subtitle}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
removeChoiceRows(wrapper);
|
||||
sendMessage(choice?.label || 'Начать новую арку', {
|
||||
isNarratorChoice: true,
|
||||
newArcFirst: first,
|
||||
});
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
split.appendChild(mkHalf('user', '👤', 'Игрок', 'первый ход после инъекта', 'user'));
|
||||
split.appendChild(mkHalf('character', '🤖', 'Персонаж', 'открывает новую арку', 'character'));
|
||||
panel.appendChild(split);
|
||||
row.appendChild(panel);
|
||||
wrapper.appendChild(row);
|
||||
ensureMessageActionsLast(wrapper);
|
||||
}
|
||||
|
||||
export function renderChoices(wrapper, choices) {
|
||||
if (!choices?.length || !wrapper) return;
|
||||
removeChoiceRows(wrapper);
|
||||
|
||||
const plotChoices = choices.filter(c => c?.source === 'plot_beat');
|
||||
const otherChoices = choices.filter(c => c?.source !== 'plot_beat');
|
||||
const newArc = choices.find(c => c?.type === 'new_arc_roll');
|
||||
if (newArc) {
|
||||
renderNewArcRollButton(wrapper, newArc);
|
||||
return;
|
||||
}
|
||||
|
||||
const isPlot = c => c?.source === 'plot_beat' || c?.source === 'plot_step';
|
||||
const plotChoices = choices.filter(isPlot);
|
||||
const otherChoices = choices.filter(c => !isPlot(c));
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'choice-row';
|
||||
@@ -167,7 +218,7 @@ export function renderChoices(wrapper, choices) {
|
||||
const appendBtn = (container, c) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = c.source === 'plot_beat' ? 'choice-btn choice-btn-plot' : 'choice-btn';
|
||||
btn.className = isPlot(c) ? 'choice-btn choice-btn-plot' : 'choice-btn';
|
||||
const label = c.label || '';
|
||||
btn.textContent = label;
|
||||
if (c.beat_title) btn.title = c.beat_title;
|
||||
@@ -251,10 +302,11 @@ function showNarratorActivityHint(wrapper, meta) {
|
||||
if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`);
|
||||
if (meta.status_quo) parts.push('🌍 status_quo');
|
||||
if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`);
|
||||
if (meta.beat_mode === 'after_dice') parts.push('📜 beat (d20)');
|
||||
if (meta.beat_mode === 'llm') parts.push('📜 beat (AI)');
|
||||
if (meta.beat_mode === 'stuck_recovery') parts.push('📜 beat (recovery)');
|
||||
if (meta.beat_mode === 'trigger') parts.push('📜 beat (keywords)');
|
||||
if (meta.step_advanced) parts.push('📜 шаг +1');
|
||||
if (meta.arc_completed) parts.push('🏁 арка завершена');
|
||||
if (meta.new_arc_rolled) parts.push('📖 новая арка');
|
||||
if (meta.story_step) parts.push(`📜 ${meta.story_step}`);
|
||||
if (meta.rp_language) parts.push(`🌐 ${meta.rp_language}`);
|
||||
if (meta.arc_pruned) parts.push(`🧹 −${meta.arc_pruned} beat`);
|
||||
if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`);
|
||||
}
|
||||
@@ -299,13 +351,27 @@ function syncQuestActionButtons() {
|
||||
if (failBtn) failBtn.disabled = !active;
|
||||
}
|
||||
|
||||
export function updateQuestPanel(quests) {
|
||||
export function updateQuestPanel(quests, storyArc = null) {
|
||||
const list = document.getElementById('questList');
|
||||
const actions = document.getElementById('questPanelActions');
|
||||
const header = document.getElementById('questPanelHeader');
|
||||
if (!list) return;
|
||||
_questsCache = quests || [];
|
||||
list.innerHTML = '';
|
||||
|
||||
if (header && storyArc) {
|
||||
const steps = storyArc.steps || [];
|
||||
const idx = Number(storyArc.current_step_index ?? 0);
|
||||
const cur = steps.length ? Math.min(idx + 1, steps.length) : 0;
|
||||
const title = storyArc.title || 'Арка';
|
||||
const status = storyArc.status === 'completed' ? ' · завершена' : '';
|
||||
header.textContent = `Арка: ${title} · шаг ${cur}/${steps.length || '?'}${status}`;
|
||||
header.classList.remove('hidden');
|
||||
} else if (header) {
|
||||
header.textContent = '';
|
||||
header.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (!_questsCache.length) {
|
||||
_selectedQuestId = null;
|
||||
syncQuestActionButtons();
|
||||
@@ -577,7 +643,10 @@ export async function reloadChatFromServer(id) {
|
||||
try {
|
||||
const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
|
||||
if (data?.text) renderNarratorMessage(data);
|
||||
} catch { /* ignore bad narrator payload */ }
|
||||
} catch {
|
||||
const plain = (m.content || '').trim();
|
||||
if (plain) renderNarratorMessage({ text: plain });
|
||||
}
|
||||
return;
|
||||
}
|
||||
addMessage(
|
||||
@@ -694,8 +763,11 @@ async function consumeStream(res) {
|
||||
data.assistant_message_id,
|
||||
data.choices,
|
||||
);
|
||||
} else if (data.choices?.length && bubble) {
|
||||
renderChoices(bubble.parentElement, data.choices);
|
||||
} else if (data.choices?.length) {
|
||||
const choiceHost = bubble?.parentElement
|
||||
|| [...dom.messagesEl.querySelectorAll('.message.narrator')].pop()
|
||||
|| [...dom.messagesEl.querySelectorAll('.message.assistant')].pop();
|
||||
if (choiceHost) renderChoices(choiceHost, data.choices);
|
||||
}
|
||||
if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
|
||||
if (data.narrator_meta && bubble?.parentElement) {
|
||||
@@ -703,7 +775,9 @@ async function consumeStream(res) {
|
||||
}
|
||||
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
|
||||
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
|
||||
if (data.quests?.length) updateQuestPanel(data.quests);
|
||||
if (data.quests !== undefined) {
|
||||
updateQuestPanel(data.quests, data.story_arc ?? null);
|
||||
}
|
||||
|
||||
_pendingUserBubble = null;
|
||||
|
||||
@@ -822,21 +896,42 @@ export function clearMessages() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessage(text, isNarratorChoice = false) {
|
||||
export async function sendMessage(text, choiceOpts = false) {
|
||||
if (typeof text !== 'string') text = dom.inputEl.value.trim();
|
||||
if (!text || !sessionId) return;
|
||||
|
||||
let isNarratorChoice = false;
|
||||
let newArcFirst = null;
|
||||
if (typeof choiceOpts === 'boolean') {
|
||||
isNarratorChoice = choiceOpts;
|
||||
} else if (choiceOpts && typeof choiceOpts === 'object') {
|
||||
isNarratorChoice = !!choiceOpts.isNarratorChoice;
|
||||
newArcFirst = choiceOpts.newArcFirst || null;
|
||||
}
|
||||
|
||||
dom.inputEl.value = '';
|
||||
dom.inputEl.style.height = 'auto';
|
||||
dom.sendBtn.disabled = true;
|
||||
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text;
|
||||
dom.messagesEl.querySelectorAll('.message.assistant').forEach(w => removeChoiceRows(w));
|
||||
let userContent = text;
|
||||
if (isNarratorChoice && newArcFirst) {
|
||||
const who = newArcFirst === 'user' ? 'игрок' : 'персонаж';
|
||||
userContent = `[Player chose: Начать новую арку — первый ход: ${who}]`;
|
||||
} else if (isNarratorChoice) {
|
||||
userContent = `[Player chose: ${text}]`;
|
||||
}
|
||||
dom.messagesEl.querySelectorAll('.message.assistant, .message.narrator').forEach(w => removeChoiceRows(w));
|
||||
_pendingUserBubble = addMessage('user', userContent);
|
||||
showTyping();
|
||||
try {
|
||||
const res = await fetch('/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }),
|
||||
body: JSON.stringify({
|
||||
message: text,
|
||||
session_id: sessionId,
|
||||
is_narrator_choice: isNarratorChoice,
|
||||
new_arc_first: newArcFirst,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
|
||||
removeTyping();
|
||||
|
||||
@@ -80,7 +80,7 @@ async function bootstrapRpg(sid, personaId, genreValue, settings) {
|
||||
const { updateAffinityDisplay } = await import('./chat.js');
|
||||
updateAffinityDisplay(data.affinity);
|
||||
}
|
||||
if (data.quests) updateQuestPanel(data.quests);
|
||||
if (data.quests) updateQuestPanel(data.quests, data.plot_arc ?? null);
|
||||
if (data.plot_arc) {
|
||||
const title = data.plot_arc.title || '';
|
||||
const hint = data.plot_arc.next_beat_hint || '';
|
||||
|
||||
@@ -225,7 +225,7 @@ export async function createNewChatFromWizard() {
|
||||
await reloadChatFromServer(sid);
|
||||
|
||||
if (openingData?.quests?.length) {
|
||||
updateQuestPanel(openingData.quests);
|
||||
updateQuestPanel(openingData.quests, openingData.plot_arc ?? null);
|
||||
}
|
||||
if (openingData?.affinity !== undefined) {
|
||||
updateAffinityDisplay(openingData.affinity);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from services.rpg_plot import (
|
||||
prune_beats_for_done_quests,
|
||||
@@ -6,6 +7,10 @@ from services.rpg_plot import (
|
||||
should_advance_arc_keywords,
|
||||
pop_matching_beats,
|
||||
dice_outcome_to_beat_trigger,
|
||||
active_quest_titles_to_close,
|
||||
can_fire_beat,
|
||||
record_beat_fired,
|
||||
complete_quest_for_fired_beat,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,21 +26,60 @@ def test_prune_removes_beat_when_quest_done():
|
||||
assert arc["beats"] == []
|
||||
|
||||
|
||||
def test_stuck_recovery_fires_when_no_active_quests():
|
||||
def test_stuck_recovery_fires_when_no_active_quests_and_cooldown_ok():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": [{"id": "a", "label": "A"}]},
|
||||
]
|
||||
],
|
||||
"meta": {"last_beat_fired_at_user_turn": 1},
|
||||
}
|
||||
quests = [{"title": "Old", "status": "done"}]
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(arc, quests, "hello")
|
||||
return await process_arc_beats(arc, quests, "hello", user_turn=5)
|
||||
|
||||
arc2, fired, pruned, mode = asyncio.run(run())
|
||||
arc2, fired, pruned, mode, extras = asyncio.run(run())
|
||||
assert mode == "stuck_recovery"
|
||||
assert fired[0]["title"] == "New Beat"
|
||||
assert arc2["beats"] == []
|
||||
assert extras.get("cooldown_skipped") is False
|
||||
|
||||
|
||||
def test_stuck_recovery_skipped_when_cooldown_active():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
|
||||
],
|
||||
"meta": {"last_beat_fired_at_user_turn": 4},
|
||||
}
|
||||
quests = []
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(arc, quests, "hello", user_turn=5)
|
||||
|
||||
arc2, fired, _, mode, extras = asyncio.run(run())
|
||||
assert mode == ""
|
||||
assert fired == []
|
||||
assert extras.get("cooldown_skipped") is True
|
||||
assert len(arc2["beats"]) == 1
|
||||
|
||||
|
||||
def test_stuck_recovery_skipped_after_reconcile_closed_quests():
|
||||
arc = {
|
||||
"beats": [
|
||||
{"id": "b3", "title": "New Beat", "trigger": "event_driven:rest", "choices": []},
|
||||
],
|
||||
}
|
||||
quests = []
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(
|
||||
arc, quests, "hello", user_turn=10, reconcile_closed_count=2
|
||||
)
|
||||
|
||||
_, fired, _, mode, _ = asyncio.run(run())
|
||||
assert mode == ""
|
||||
assert fired == []
|
||||
|
||||
|
||||
def test_dice_outcome_maps_to_after_fail():
|
||||
@@ -44,7 +88,7 @@ def test_dice_outcome_maps_to_after_fail():
|
||||
assert dice_outcome_to_beat_trigger("success") == "event_driven:after_success"
|
||||
|
||||
|
||||
def test_after_fail_beat_fires_on_dice_failure():
|
||||
def test_after_fail_beat_fires_on_dice_failure_only_with_needs_check():
|
||||
arc = {
|
||||
"beats": [
|
||||
{
|
||||
@@ -54,25 +98,94 @@ def test_after_fail_beat_fires_on_dice_failure():
|
||||
"injection": "The stumble leaves you both shaken.",
|
||||
"choices": [{"id": "a", "label": "Try again"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async def run(needs_check: bool):
|
||||
return await process_arc_beats(
|
||||
arc, [], "продолжаем", last_dice_outcome="failure", needs_check=needs_check, user_turn=3
|
||||
)
|
||||
|
||||
arc_ok, fired, _, mode, _ = asyncio.run(run(True))
|
||||
assert mode == "after_dice"
|
||||
assert fired[0]["id"] == "b_fail"
|
||||
assert arc_ok["beats"] == []
|
||||
|
||||
arc2 = {
|
||||
"beats": [
|
||||
{
|
||||
"id": "b_ok",
|
||||
"title": "Victory Lap",
|
||||
"trigger": "event_driven:after_success",
|
||||
"id": "b_fail",
|
||||
"title": "Dust Yourself Off",
|
||||
"trigger": "event_driven:after_fail",
|
||||
"choices": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async def run():
|
||||
return await process_arc_beats(
|
||||
arc, [], "продолжаем разговор", last_dice_outcome="failure"
|
||||
)
|
||||
async def run_no_check():
|
||||
with patch(
|
||||
"services.rpg_plot.classify_plot_beat",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
return await process_arc_beats(
|
||||
arc2,
|
||||
[],
|
||||
"продолжаем",
|
||||
last_dice_outcome="failure",
|
||||
needs_check=False,
|
||||
user_turn=3,
|
||||
allow_stuck_recovery=False,
|
||||
)
|
||||
|
||||
arc2, fired, _, mode = asyncio.run(run())
|
||||
assert mode == "after_dice"
|
||||
assert fired[0]["id"] == "b_fail"
|
||||
assert len(arc2["beats"]) == 1
|
||||
assert arc2["beats"][0]["id"] == "b_ok"
|
||||
_, fired2, _, mode2, _ = asyncio.run(run_no_check())
|
||||
assert mode2 == ""
|
||||
assert fired2 == []
|
||||
|
||||
|
||||
def test_orphan_active_quests_closed_when_not_in_arc():
|
||||
arc = {
|
||||
"beats": [
|
||||
{
|
||||
"id": "b_new_3",
|
||||
"title": "Defensive Magic",
|
||||
"trigger": "event_driven:after_fail",
|
||||
"choices": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
quests = [
|
||||
{"title": "Floral Whispers", "status": "active"},
|
||||
{"title": "Gift from the Glade", "status": "active"},
|
||||
{"title": "Defensive Magic", "status": "active"},
|
||||
{"title": "Old Done", "status": "done"},
|
||||
]
|
||||
closed = active_quest_titles_to_close(arc, quests)
|
||||
assert set(closed) == {"Floral Whispers", "Gift from the Glade"}
|
||||
|
||||
|
||||
def test_can_fire_beat_respects_min_gap():
|
||||
arc = {"meta": {"last_beat_fired_at_user_turn": 5}}
|
||||
assert can_fire_beat(arc, 6, min_gap=2) is False
|
||||
assert can_fire_beat(arc, 7, min_gap=2) is True
|
||||
|
||||
|
||||
def test_record_beat_fired_updates_meta():
|
||||
arc = {}
|
||||
record_beat_fired(arc, {"id": "b1"}, 8)
|
||||
assert arc["meta"]["last_beat_fired_at_user_turn"] == 8
|
||||
assert arc["meta"]["last_beat_id"] == "b1"
|
||||
|
||||
|
||||
def test_complete_quest_for_fired_beat():
|
||||
beat = {"id": "b1", "title": "Pancake Talk", "injection": "..."}
|
||||
|
||||
async def run():
|
||||
with patch("services.memory.upsert_quest", new_callable=AsyncMock) as mock:
|
||||
await complete_quest_for_fired_beat("sess-1", beat)
|
||||
mock.assert_called_once_with("sess-1", "Pancake Talk", "done")
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_keyword_fallback_travel():
|
||||
|
||||
@@ -29,7 +29,7 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
|
||||
},
|
||||
{"role": "narrator", "content": narrator_json},
|
||||
]
|
||||
llm = messages_for_llm(history, "system+runtime")
|
||||
llm = messages_for_llm(history, "system+runtime", rp_lang="en")
|
||||
user_msgs = [m for m in llm if m["role"] == "user"]
|
||||
assert len(user_msgs) == 2
|
||||
assert "canonical outcome" in user_msgs[0]["content"].lower()
|
||||
@@ -39,14 +39,24 @@ def test_messages_for_llm_includes_narrator_ruling_not_original_only():
|
||||
assert "Do NOT write a success version" in user_msgs[1]["content"]
|
||||
|
||||
|
||||
def test_format_narrator_failure_wording():
|
||||
def test_format_narrator_failure_wording_en():
|
||||
text = format_narrator_outcome_for_llm(
|
||||
{"roll": 5, "outcome": "failure", "text": "It failed."}
|
||||
{"roll": 5, "outcome": "failure", "text": "It failed."},
|
||||
lang="en",
|
||||
)
|
||||
assert "FAILED" in text
|
||||
assert "Do NOT write a success version" in text
|
||||
|
||||
|
||||
def test_format_narrator_failure_wording_ru():
|
||||
text = format_narrator_outcome_for_llm(
|
||||
{"roll": 5, "outcome": "failure", "text": "Не вышло."},
|
||||
lang="ru",
|
||||
)
|
||||
assert "ОБЯЗАТЕЛЬНО" in text
|
||||
assert "Не удалось" in text or "НЕ удалось" in text
|
||||
|
||||
|
||||
def test_parse_narrator_message_roundtrip():
|
||||
raw = narrator_message_content({"roll": 12, "outcome": "success", "text": "OK"})
|
||||
data = parse_narrator_message(raw)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from services.rpg_plot import choices_from_beat, choices_from_narrator, normalize_choice
|
||||
from services.rpg_plot import (
|
||||
choices_from_beat,
|
||||
choices_from_narrator,
|
||||
normalize_choice,
|
||||
format_beat_injection_for_character,
|
||||
)
|
||||
|
||||
|
||||
def test_choices_from_beat_tags_source():
|
||||
@@ -13,7 +18,7 @@ def test_choices_from_beat_tags_source():
|
||||
}
|
||||
out = choices_from_beat(beat)
|
||||
assert len(out) == 2
|
||||
assert out[0]["source"] == "plot_beat"
|
||||
assert out[0]["source"] == "plot_step"
|
||||
assert out[0]["beat_title"] == "Rest Stop Confession"
|
||||
assert out[0]["beat_id"] == "b_new_1"
|
||||
assert "beat_injection" in out[0]
|
||||
@@ -25,5 +30,24 @@ def test_choices_from_narrator_tags_source():
|
||||
assert "beat_title" not in out[0]
|
||||
|
||||
|
||||
def test_beat_injection_block_for_character_prompt():
|
||||
block = format_beat_injection_for_character(
|
||||
"Over pancakes, Luna steals glances at you.",
|
||||
lang="en",
|
||||
)
|
||||
assert "Plot hint" in block
|
||||
assert "Over pancakes" in block
|
||||
assert format_beat_injection_for_character("") == ""
|
||||
|
||||
|
||||
def test_beat_injection_block_ru():
|
||||
block = format_beat_injection_for_character(
|
||||
"Луна крадёт взгляды через блины.",
|
||||
lang="ru",
|
||||
)
|
||||
assert "Сюжетная подсказка" in block
|
||||
assert "по-русски" in block
|
||||
|
||||
|
||||
def test_normalize_choice_skips_empty_label():
|
||||
assert normalize_choice({"id": "a", "label": " "}, source="narrator") is None
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from services.rpg_facts import (
|
||||
FACTS_COMPRESS_SYSTEM,
|
||||
merge_facts,
|
||||
parse_facts_list,
|
||||
facts_to_prompt,
|
||||
facts_list_to_json,
|
||||
dedupe_facts_fuzzy,
|
||||
facts_are_similar,
|
||||
is_likely_narrative_event,
|
||||
filter_durable_facts,
|
||||
validate_compressed_against_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,6 +38,33 @@ def test_dedupe_collapses_duplicates():
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_facts_compress_system_format_escapes_json_braces():
|
||||
system = FACTS_COMPRESS_SYSTEM.format(target=22)
|
||||
assert "Target at most 22 facts" in system
|
||||
assert '{"text":' in system
|
||||
|
||||
|
||||
def test_narrative_event_facts_filtered():
|
||||
assert is_likely_narrative_event("Сара и Григо отправились через портал")
|
||||
assert not is_likely_narrative_event("Сара носит красный спортивный костюм")
|
||||
facts = filter_durable_facts([
|
||||
{"text": "Сара любит шоколад", "rp_day": "день 1"},
|
||||
{"text": "Сара и Григо решили вызвать СОДП", "rp_day": "день 2"},
|
||||
])
|
||||
assert len(facts) == 1
|
||||
|
||||
|
||||
def test_validate_compressed_rejects_hallucinations():
|
||||
original = [{"text": "Сара любит шоколад", "rp_day": "день 1"}]
|
||||
compressed = [
|
||||
{"text": "Сара любит шоколад", "rp_day": "день 1"},
|
||||
{"text": "Сара — королева галактики", "rp_day": "день 9"},
|
||||
]
|
||||
out = validate_compressed_against_source(original, compressed)
|
||||
assert len(out) == 1
|
||||
assert "шоколад" in out[0]["text"]
|
||||
|
||||
|
||||
def test_merge_with_rp_day():
|
||||
existing = facts_list_to_json([{"text": "A", "rp_day": "день 1"}])
|
||||
merged = parse_facts_list(
|
||||
|
||||
@@ -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")
|
||||
@@ -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Начать новую арку"
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,2 @@
|
||||
# generated module - copied to services/rpg_story.py by apply script
|
||||
PLACEHOLDER = True
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Apply linear story refactor — run once: python tools/apply_linear_story.py"""
|
||||
print("placeholder")
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user