Fixed SD RPG

This commit is contained in:
2026-06-04 08:05:06 +03:00
parent d4cd8f02f4
commit 6189a5fb74
62 changed files with 6969 additions and 552 deletions
+321
View File
@@ -0,0 +1,321 @@
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