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, *, 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