Files
2026-06-05 14:57:15 +03:00

360 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
DEFAULT_NARRATIVE_STATS = {"lust": 0, "stamina": 10, "tension": 0}
STAT_KEYS = ("lust", "stamina", "tension")
def parse_stats_json(raw: str | None) -> dict:
try:
data = json.loads(raw or "{}") if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
out = dict(DEFAULT_NARRATIVE_STATS)
for k in STAT_KEYS:
try:
out[k] = max(0, min(10, int(data.get(k, out[k]))))
except (TypeError, ValueError):
pass
return out
def parse_scene_json(raw: str | None) -> dict:
try:
data = json.loads(raw or "{}") if isinstance(raw, str) else (raw or {})
except Exception:
data = {}
return data if isinstance(data, dict) else {}
def merge_scene(existing: dict, update: dict | None) -> dict:
if not update or not isinstance(update, dict):
return dict(existing)
merged = dict(existing)
for k, v in update.items():
if v is None:
continue
if k == "exits" and isinstance(v, list):
merged["exits"] = v
elif isinstance(v, str) and v.strip():
merged[k] = v.strip()
elif v != "":
merged[k] = v
return merged
def apply_stats_delta(stats: dict, delta: dict | None) -> dict:
out = parse_stats_json(json.dumps(stats, ensure_ascii=False))
if not delta or not isinstance(delta, dict):
return out
for k in STAT_KEYS:
try:
d = int(delta.get(k, 0))
except (TypeError, ValueError):
d = 0
d = max(-2, min(2, d))
if d:
out[k] = max(0, min(10, out[k] + d))
return out
def scene_prompt_block(scene: dict) -> str:
if not scene:
return ""
lines = []
for key, label in (
("place", "Location"),
("place_id", "Location ID"),
("time_of_day", "Time of day"),
("day", "Day"),
("weather", "Weather"),
("layout_note", "Layout"),
):
v = (scene.get(key) or "").strip()
if v:
lines.append(f"{label}: {v}")
exits = scene.get("exits")
if isinstance(exits, list) and exits:
lines.append("Exits: " + ", ".join(str(e) for e in exits))
if not lines:
return ""
return (
"\n\n--- Scene ---\n"
+ "\n".join(lines)
+ "\nUse this setting consistently. Do not recite scene labels verbatim.\n---"
)
# Scoped to current player until multi-user / multi-card relationships exist.
RELATIONSHIP_TARGET_LABEL = "current player (the human you are roleplaying with)"
def _mandatory_state_preamble() -> str:
return (
"MANDATORY ROLEPLAY DIRECTIVE (binding — overrides default cheerfulness or energy):\n"
f"- Target: {RELATIONSHIP_TARGET_LABEL} only.\n"
"- Your reply MUST visibly obey this state in tone, body language, pacing, and what the character attempts.\n"
"- Do NOT contradict a low stamina / high tension / low affinity reading with peppy or intimate behavior unless the text above explicitly allows recovery.\n"
"- Never name affinity, lust, stamina, tension, stats, meters, or numeric values in dialogue.\n"
)
def _band_instruction(value: int, bands: list[tuple[int, str]]) -> str:
"""bands: list of (min_inclusive, instruction) sorted by min descending."""
v = int(value)
for min_val, text in bands:
if v >= min_val:
return text
return bands[-1][1] if bands else ""
def affinity_prompt_block(affinity: int) -> str:
aff = max(-30, min(30, int(affinity)))
attitude = _band_instruction(
aff,
[
(10, "Devoted trust: openly affectionate, seeks closeness, defends the player, vulnerable honesty."),
(5, "Warm bond: friendly, teasing allowed, volunteers help, remembers small kindnesses."),
(1, "Slight fondness: polite-positive, rare soft moments, not yet intimate."),
(0, "Neutral professional distance: neither warm nor cold unless scene demands."),
(-1, "Cool and guarded: short answers, deflects personal topics, skeptical of motives."),
(-5, "Hostile or deeply distrustful: sarcasm, refusal, may threaten to leave or expose the player."),
(-30, "Open enmity: antagonistic, undermines, may sabotage or attack socially/physically if fitting genre."),
],
)
return (
"\n\n--- Relationship toward player (MANDATORY) ---\n"
+ _mandatory_state_preamble()
+ f"Affinity (internal, not spoken): level {aff}.\n"
f"Required attitude toward {RELATIONSHIP_TARGET_LABEL}: {attitude}\n"
"---"
)
def stats_prompt_block(stats: dict) -> str:
s = parse_stats_json(json.dumps(stats, ensure_ascii=False))
lust = s["lust"]
stamina = s["stamina"]
tension = s["tension"]
lust_line = _band_instruction(
lust,
[
(9, "Overwhelming arousal colors every beat: breathy, distracted, struggles to stay on task."),
(7, "Strong desire: flushed skin, lingering touch, voice unsteady, thoughts drift to intimacy."),
(5, "Clear attraction: meaningful glances, leaning in, playful double meanings if genre fits."),
(3, "Mild warmth: subtle flirt only when appropriate; easily redirected."),
(1, "Little romantic charge: platonic focus unless the player escalates."),
(0, "No romantic or sexual undertone in body language or subtext."),
],
)
stamina_line = _band_instruction(
stamina,
[
(9, "Peak energy: brisk movement, sharp focus, may offer to take strenuous actions."),
(7, "Well-rested: alert, steady pace, normal exertion."),
(5, "Average fatigue: fine for talk/light action; heavy labor needs justification."),
(4, "Tired: slower reactions, sits when possible, voice softer."),
(3, "Heavy fatigue: frequent pauses, avoids running/fighting, may ask to stop."),
(2, "Exhausted: barely moves, slumped posture, short sentences, needs support to walk."),
(1, "On the verge of collapse: eyelids heavy, may stumble or nearly pass out; minimal action only."),
(0, "Cannot sustain activity: collapse/immediate sleep/rest is imminent unless helped."),
],
)
tension_line = _band_instruction(
tension,
[
(9, "Breaking point: trembling, tears or rage close to surface, irrational snap decisions."),
(7, "High stress: clipped speech, hyper-vigilant, jumps at sounds, defensive."),
(5, "Uneasy: fidgeting, forced smiles, changes subject from danger."),
(3, "Mild edge: occasional sigh, watches exits, relaxes with reassurance."),
(1, "Mostly calm: normal breathing, open posture."),
(0, "Fully at ease in body and voice."),
],
)
return (
"\n\n--- Physical & emotional state (MANDATORY) ---\n"
+ _mandatory_state_preamble()
+ f"Internal scales (010, never spoken): lust/arousal={lust}, stamina/energy={stamina}, tension/stress={tension}.\n"
f"- Lust/arousal: {lust_line}\n"
f"- Stamina/energy: {stamina_line}\n"
f"- Tension/stress: {tension_line}\n"
"If multiple apply, combine them (e.g. low stamina + high tension = shaky exhaustion, not peppy panic).\n"
"---"
)
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()
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 = [
"--- 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)
def format_user_message_for_llm(content: str, *, has_dice_resolution: bool) -> str:
if not has_dice_resolution:
return content
return (
"[Player stated intent — canonical outcome is in the narrator ruling immediately below]\n"
+ content
)
def scene_to_sd_hint(scene: dict) -> str:
if not scene:
return ""
parts = []
for k in ("place", "time_of_day", "day", "weather", "layout_note"):
v = (scene.get(k) or "").strip()
if v:
parts.append(f"{k}: {v}")
return "\n".join(parts)
async def apply_narrator_post(session_id: str, post: dict, rpg_settings: dict, session: dict | None = None) -> dict:
"""Persist narrator_post fields into session. Returns summary of what changed."""
from services.memory import (
get_session,
update_session_status_quo,
update_session_affinity,
update_session_outfit,
update_session_scene,
update_session_narrative_stats,
update_session_facts,
upsert_quest,
)
from services.rpg_facts import merge_facts_persist, rp_day_from_scene, parse_facts_list
if session is None:
session = await get_session(session_id) or {}
applied = {
"status_quo": False,
"facts_added": 0,
"affinity_delta": 0,
"quests_updated": 0,
"scene": False,
"outfit": False,
}
sq = (post.get("status_quo_update") or "").strip()
if sq:
await update_session_status_quo(session_id, sq)
applied["status_quo"] = True
post_facts = post.get("facts") or []
if isinstance(post_facts, list) and post_facts:
rp_day = rp_day_from_scene(session.get("scene_json"))
before = len(parse_facts_list(session.get("facts_json") or "[]"))
merged = await merge_facts_persist(
session.get("facts_json", "[]"),
post_facts,
rp_day_default=rp_day,
scene_context=json.dumps(
parse_scene_json(session.get("scene_json")), ensure_ascii=False
),
status_quo=session.get("status_quo") or "",
)
await update_session_facts(session_id, merged)
after = len(parse_facts_list(merged))
applied["facts_added"] = max(0, after - before)
if rpg_settings.get("affinity", True):
delta = int(post.get("affinity_delta") or 0)
if delta:
await update_session_affinity(session_id, delta)
applied["affinity_delta"] = delta
outfit_update = post.get("outfit_update")
if isinstance(outfit_update, list) and outfit_update:
from services.outfit_tags import outfit_list_to_json
await update_session_outfit(session_id, outfit_list_to_json(outfit_update))
applied["outfit"] = True
scene_update = post.get("scene_update")
if isinstance(scene_update, dict) and scene_update:
merged = merge_scene(parse_scene_json(session.get("scene_json")), scene_update)
await update_session_scene(session_id, json.dumps(merged, ensure_ascii=False))
applied["scene"] = True
if rpg_settings.get("stats", False):
stats_delta = post.get("stats_delta")
if isinstance(stats_delta, dict) and stats_delta:
current = parse_stats_json(session.get("narrative_stats_json"))
updated = apply_stats_delta(current, stats_delta)
await update_session_narrative_stats(
session_id, json.dumps(updated, ensure_ascii=False)
)
if rpg_settings.get("quests", True):
for qu in post.get("quest_updates") or []:
title = (qu.get("title") or "").strip()
if title:
await upsert_quest(session_id, title[:120], qu.get("status", "active"))
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