new RPG system
This commit is contained in:
+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"):
|
||||
new_step = get_current_step(arc)
|
||||
if new_step:
|
||||
inj = (new_step.get("injection") or "").strip()
|
||||
if inj:
|
||||
debug_blocks.append({"type": "narrator_injection", "text": inj})
|
||||
if rpg_settings.get("choices", True):
|
||||
choices += choices_from_step(new_step)
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc",
|
||||
"text": f"Step advanced: «{applied.get('new_step_title', '')}»",
|
||||
})
|
||||
|
||||
if applied.get("arc_completed"):
|
||||
debug_blocks.append({
|
||||
"type": "plot_arc",
|
||||
"text": "Story arc completed — new arc available",
|
||||
})
|
||||
|
||||
if is_arc_completed(arc) and rpg_settings.get("choices", True):
|
||||
choices = append_new_arc_roll_choice(choices, lang=rp_lang)
|
||||
outfit_update = post.get("outfit_update")
|
||||
if isinstance(outfit_update, list) and outfit_update:
|
||||
from services.outfit_tags import outfit_list_to_json
|
||||
@@ -705,7 +831,8 @@ async def chat_stream(request: ChatRequest):
|
||||
"choices": choices,
|
||||
"debug": debug_blocks,
|
||||
"affinity": affinity,
|
||||
"quests": quests_updated,
|
||||
"quests": quests_updated if session and session.get("rpg_enabled") else [],
|
||||
"story_arc": arc if session and session.get("rpg_enabled") else None,
|
||||
"narrator_meta": narrator_meta,
|
||||
}
|
||||
if rpg_settings.get("stats") and updated_session:
|
||||
@@ -713,6 +840,11 @@ async def chat_stream(request: ChatRequest):
|
||||
updated_session.get("narrative_stats_json")
|
||||
)
|
||||
|
||||
if session and session.get("rpg_enabled"):
|
||||
snap_id = msg_id or await get_last_message_id(request.session_id)
|
||||
if snap_id:
|
||||
await save_state_snapshot(request.session_id, snap_id)
|
||||
|
||||
yield f"data: {json.dumps(done_payload)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
|
||||
Reference in New Issue
Block a user