Fixed SD RPG
This commit is contained in:
@@ -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 (0–10, 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
|
||||
Reference in New Issue
Block a user