Files
ChatAIBot/services/rpg_state.py
T
2026-06-04 08:05:06 +03:00

322 lines
12 KiB
Python
Raw 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) -> 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."
)
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.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