Source code for game_ncm

"""GameGirl Color -- NCM-as-game-variables engine.

OMORI-inspired emotion system + RPG game variables implemented
as a parallel NCM vector space. NOT the real limbic system -- this
is a game mechanic layer that uses NCM-style math for battle stats,
currency, health, and an emotion interaction matrix.
# 🎮💀🌀 EMOTION ENGINE POSSESSED
"""

from __future__ import annotations

import jsonutil as json
import logging
import math
from dataclasses import dataclass, asdict
from typing import Any

logger = logging.getLogger(__name__)

# Redis key pattern  # 🕷️
_NCM_KEY = "game:ncm:{game_id}"
_NCM_TTL = 0  # No TTL -- 5ever

# ------------------------------------------------------------------
# Core emotion nodes (OMORI triangle + extensions)  # 🌀
# ------------------------------------------------------------------

# The four primary emotions form an interaction matrix:
#   HAPPY  beats SAD,   weak to ANGRY
#   ANGRY  beats HAPPY, weak to SAD
#   SAD    beats ANGRY,  weak to HAPPY
#   AFRAID is the wild card -- amplifies everything

# Combo emotions (two primaries at once):
#   MANIC      = HAPPY + ANGRY    (volatile stats, random crits)
#   MISERABLE  = SAD + AFRAID     (massive defense, no offense)
#   FURIOUS    = high ANGRY       (double damage, self-damage)
#   STRESSED   = AFRAID + SAD     (erratic, status resist down)

_DEFAULT_GAME_NCM: dict[str, float] = {
    # Primary emotions (0-3 scale like real NCM)  # 🔥
    "G_HAPPY": 0.5,
    "G_SAD": 0.3,
    "G_ANGRY": 0.3,
    "G_AFRAID": 0.2,
    # Combo emotions (derived, not directly set)
    "G_MANIC": 0.0,
    "G_MISERABLE": 0.0,
    "G_FURIOUS": 0.0,
    "G_STRESSED": 0.0,
    # Game stats  # 💀
    "G_HP": 3.0,  # Full health at 3.0
    "G_MP": 2.0,  # Recursion points
    "G_XP": 0.0,  # Experience (uncapped upward)
    "G_GOLD": 1.0,  # Currency
    "G_GLITCH": 0.0,  # Corruption level (rises with hot-swaps, weird choices)
    "G_KARMA": 1.5,  # Moral alignment (0=evil, 1.5=neutral, 3=saint)
    "G_LEVEL": 1.0,  # Player level
}

# Emotion interaction matrix  # 🌀
# Format: {attacker_emotion: {defender_emotion: damage_multiplier}}
_EMOTION_ADVANTAGE: dict[str, dict[str, float]] = {
    "G_HAPPY": {"G_SAD": 1.5, "G_ANGRY": 0.7, "G_AFRAID": 1.0},
    "G_ANGRY": {"G_HAPPY": 1.5, "G_SAD": 0.7, "G_AFRAID": 1.0},
    "G_SAD": {"G_ANGRY": 1.5, "G_HAPPY": 0.7, "G_AFRAID": 1.0},
    "G_AFRAID": {"G_HAPPY": 1.2, "G_ANGRY": 1.2, "G_SAD": 1.2},
}


[docs] @dataclass class BattleModifiers: """Derived combat stat multipliers computed from a game's emotion vector. Bundles the battle-relevant outputs of the OMORI-style emotion engine -- attack/defense/speed multipliers, crit and evasion rates, accuracy, status resistance, and a human-readable ``description`` of the dominant emotion and any active combo states. It is a pure value object with no I/O; the numbers are clamped to playable ranges by the function that builds it. Produced by :func:`get_battle_modifiers` from a vector and consumed by :func:`format_hud` to render the HUD's ATK/DEF/SPD/CRIT/EVA line. The :meth:`to_dict` helper exists for external/HUD-API consumers; no in-repo callers of it were found. """ attack_mult: float = 1.0 defense_mult: float = 1.0 speed_mult: float = 1.0 crit_rate: float = 0.05 evasion: float = 0.05 accuracy: float = 1.0 status_resist: float = 1.0 description: str = ""
[docs] def to_dict(self) -> dict[str, Any]: """Serialize the computed battle modifiers to a plain dict. Delegates to :func:`dataclasses.asdict` to flatten every field (``attack_mult``, ``defense_mult``, ``speed_mult``, ``crit_rate``, ``evasion``, ``accuracy``, ``status_resist``, ``description``) into a JSON-safe dict. Intended for surfacing the derived combat stats to callers (e.g. game tools or HUD/API payloads) without exposing the dataclass. No side effects. No internal callers were found; provided for parity with the other game dataclasses and external consumers. Returns: dict[str, Any]: A shallow dict of all modifier fields. """ return asdict(self)
# ===================================================================== # NCM operations # 🔥 # =====================================================================
[docs] async def initialize_game_ncm( game_id: str, redis: Any = None, ) -> dict[str, float]: """Create and persist the default NCM vector for a new game. Copies ``_DEFAULT_GAME_NCM`` -- the starting emotion levels and game stats (HP, MP, XP, gold, glitch, karma, level) -- and, when Redis is available, writes it as JSON to the ``game:ncm:{game_id}`` Redis string so the game's emotion/stat state survives across turns and restarts. A Redis failure is caught and logged; the fresh vector is still returned so the caller can use it in-memory regardless. No in-repo callers were found; invoked from the game subsystem at runtime when a new game is booted. Args: game_id: Identifier of the game whose NCM state is initialized. redis: Async Redis client; when ``None`` the vector is returned without being persisted. Returns: dict[str, float]: A fresh copy of the default NCM/stat vector. """ vector = dict(_DEFAULT_GAME_NCM) if redis is not None: try: await redis.set( _NCM_KEY.format(game_id=game_id), json.dumps(vector), ) except Exception as exc: logger.error("Failed to initialize game NCM: %s", exc) return vector
[docs] async def get_vector( game_id: str, redis: Any = None, ) -> dict[str, float]: """Load the current game NCM/stat vector from Redis. Reads the JSON vector stored under the ``game:ncm:{game_id}`` Redis string. If Redis is absent, the key is missing, or a read/JSON error occurs (the error is logged), it falls back to a fresh copy of ``_DEFAULT_GAME_NCM`` so callers always receive a usable vector. Read-only. Called by :func:`apply_delta` and :func:`format_hud_from_redis`; no other in-repo callers were found. Args: game_id: Identifier of the game whose NCM vector is read. redis: Async Redis client; when ``None`` the default vector is returned. Returns: dict[str, float]: The stored vector, or a copy of the defaults when unavailable. """ if redis is None: return dict(_DEFAULT_GAME_NCM) key = _NCM_KEY.format(game_id=game_id) try: raw = await redis.get(key) if raw: return json.loads(raw) except Exception as exc: logger.error("Failed to read game NCM vector: %s", exc) return dict(_DEFAULT_GAME_NCM)
[docs] async def apply_delta( game_id: str, deltas: dict[str, float], redis: Any = None, ) -> dict[str, float]: """Apply signed changes to a game's NCM vector and persist the result. Loads the current vector via :func:`get_vector`, adds each supplied delta to the matching node (defaulting unknown nodes to their starting value), and rounds to three decimals. Most nodes are clamped to the ``[0, 3]`` range used for emotions and stats, while ``G_XP`` and ``G_LEVEL`` are only floored at zero so they can grow without bound. Combo emotions are then recomputed via :func:`_recalculate_combos`, and the updated vector is written back to the ``game:ncm:{game_id}`` Redis string (a write failure is caught and logged). No in-repo callers were found; invoked from the game subsystem at runtime as gameplay events adjust the player's emotions and stats. Args: game_id: Identifier of the game whose vector is mutated. deltas: Mapping of NCM node name to the signed amount to add. redis: Async Redis client; when ``None`` the new vector is computed and returned but not persisted. Returns: dict[str, float]: The updated, clamped, combo-recalculated vector. """ vector = await get_vector(game_id, redis=redis) # Apply deltas with clamping # 💀 uncapped = {"G_XP", "G_LEVEL"} for node, delta in deltas.items(): current = vector.get(node, _DEFAULT_GAME_NCM.get(node, 0.0)) new_val = current + delta if node not in uncapped: new_val = max(0.0, min(3.0, new_val)) else: new_val = max(0.0, new_val) vector[node] = round(new_val, 3) # Recalculate combo emotions # 🌀 vector = _recalculate_combos(vector) # Persist if redis is not None: try: await redis.set( _NCM_KEY.format(game_id=game_id), json.dumps(vector), ) except Exception as exc: logger.error("Failed to save game NCM delta: %s", exc) return vector
def _recalculate_combos(vector: dict[str, float]) -> dict[str, float]: """Derive the combo-emotion nodes from the primary emotions, in place. Recomputes ``G_MANIC``, ``G_MISERABLE``, and ``G_STRESSED`` as the geometric mean of their two constituent primaries (so both must be elevated for the combo to fire), and ``G_FURIOUS`` as a scaled excess of ``G_ANGRY`` above a 2.0 threshold. Each result is rounded to three decimals and written straight back into the passed-in dict, which is also returned for convenience. Pure computation -- no Redis or other I/O. Called by :func:`apply_delta` after deltas are applied; no other in-repo callers were found. Args: vector: The NCM vector to update; combo nodes are overwritten in place. Returns: dict[str, float]: The same ``vector`` object with combo nodes refreshed. """ happy = vector.get("G_HAPPY", 0) sad = vector.get("G_SAD", 0) angry = vector.get("G_ANGRY", 0) afraid = vector.get("G_AFRAID", 0) # MANIC = HAPPY + ANGRY # 😈 vector["G_MANIC"] = round(math.sqrt(max(0, happy * angry)), 3) # MISERABLE = SAD + AFRAID vector["G_MISERABLE"] = round(math.sqrt(max(0, sad * afraid)), 3) # FURIOUS = high ANGRY (threshold at 2.0) vector["G_FURIOUS"] = round(max(0, angry - 2.0) * 1.5, 3) # STRESSED = AFRAID + SAD vector["G_STRESSED"] = round(math.sqrt(max(0, afraid * sad)), 3) return vector
[docs] def get_battle_modifiers(vector: dict[str, float]) -> BattleModifiers: """Compute clamped battle stat modifiers from a game's emotion vector. Translates the primary and combo emotions into the multipliers and rates a battle uses, then clamps each to a playable range. The mapping is, broadly: HAPPY raises crit and speed, SAD raises defense but lowers speed, ANGRY raises attack but lowers accuracy, AFRAID raises evasion but lowers attack, and the combo emotions (manic, miserable, furious, stressed) stack further effects. It also calls :func:`_get_dominant_emotion` to build a ``description`` and appends flavor notes when a combo crosses 0.5. Pure computation -- no Redis or other I/O. Called by :func:`format_hud` to populate the HUD's combat line; no other in-repo callers were found. Args: vector: The NCM/emotion vector to read stat inputs from. Returns: BattleModifiers: The rounded, range-clamped combat modifiers plus a descriptive status string. """ happy = vector.get("G_HAPPY", 0) sad = vector.get("G_SAD", 0) angry = vector.get("G_ANGRY", 0) afraid = vector.get("G_AFRAID", 0) manic = vector.get("G_MANIC", 0) miserable = vector.get("G_MISERABLE", 0) furious = vector.get("G_FURIOUS", 0) stressed = vector.get("G_STRESSED", 0) # Base modifiers # 🔥 attack = 1.0 + (angry * 0.15) - (afraid * 0.1) + (furious * 0.25) defense = 1.0 + (sad * 0.2) + (miserable * 0.3) - (manic * 0.1) speed = 1.0 + (happy * 0.15) - (sad * 0.1) + (manic * 0.1) crit = 0.05 + (happy * 0.05) + (manic * 0.1) evasion = 0.05 + (afraid * 0.08) - (angry * 0.03) accuracy = 1.0 - (angry * 0.05) - (manic * 0.08) + (happy * 0.03) status_resist = 1.0 - (stressed * 0.15) + (sad * 0.05) # Clamp to reasonable ranges # 💀 attack = max(0.3, min(3.0, attack)) defense = max(0.3, min(3.0, defense)) speed = max(0.3, min(3.0, speed)) crit = max(0.01, min(0.8, crit)) evasion = max(0.0, min(0.5, evasion)) accuracy = max(0.3, min(1.0, accuracy)) status_resist = max(0.2, min(1.5, status_resist)) # Build description # 🌀 dominant = _get_dominant_emotion(vector) desc = f"Dominant emotion: {dominant}." if manic > 0.5: desc += " MANIC: stats are volatile." if miserable > 0.5: desc += " MISERABLE: high defense, low offense." if furious > 0.5: desc += " FURIOUS: devastating attacks but taking self-damage." if stressed > 0.5: desc += " STRESSED: status effects hit harder." return BattleModifiers( attack_mult=round(attack, 2), defense_mult=round(defense, 2), speed_mult=round(speed, 2), crit_rate=round(crit, 3), evasion=round(evasion, 3), accuracy=round(accuracy, 3), status_resist=round(status_resist, 2), description=desc, )
def _get_dominant_emotion(vector: dict[str, float]) -> str: """Return the name of the strongest primary emotion in a vector. Compares the four primary emotion nodes (``G_HAPPY``, ``G_SAD``, ``G_ANGRY``, ``G_AFRAID``) and returns the short label (``HAPPY``/``SAD``/``ANGRY``/ ``AFRAID``) of the largest; combo and stat nodes are ignored. Pure helper with no I/O. Called by :func:`get_battle_modifiers` and :func:`format_hud` to label the current emotional state; no other in-repo callers were found. Args: vector: The NCM/emotion vector to inspect. Returns: str: The name of the dominant primary emotion. """ emotions = { "HAPPY": vector.get("G_HAPPY", 0), "SAD": vector.get("G_SAD", 0), "ANGRY": vector.get("G_ANGRY", 0), "AFRAID": vector.get("G_AFRAID", 0), } dominant = max(emotions, key=emotions.get) # type: ignore[arg-type] return dominant
[docs] def get_emotion_advantage( attacker_emotion: str, defender_emotion: str, ) -> float: """Look up the damage multiplier for an attacker/defender emotion matchup. Reads the static ``_EMOTION_ADVANTAGE`` matrix encoding the OMORI-style rock-paper-scissors of emotions (happy beats sad, angry beats happy, sad beats angry, afraid amplifies all). Unknown or unlisted pairings fall back to a neutral ``1.0`` rather than raising. Pure lookup with no I/O. No in-repo callers were found; invoked from the game subsystem's battle logic at runtime when resolving an attack. Args: attacker_emotion: NCM node name of the attacking side's emotion. defender_emotion: NCM node name of the defending side's emotion. Returns: float: ``1.5`` for advantage, ``0.7`` for disadvantage, ``1.2`` for the AFRAID amplifier, and ``1.0`` for neutral or unknown matchups. """ return _EMOTION_ADVANTAGE.get( attacker_emotion, {}, ).get(defender_emotion, 1.0)
# ===================================================================== # HUD formatting # 🎮 # =====================================================================
[docs] def format_hud(vector: dict[str, float]) -> str: """Render a GAMEGIRL COLOR HUD status block from a game's NCM vector. Formats the game's stats and emotions into a multi-line, ASCII status display meant to be injected into the system/context prompt so the LLM (and player) can see live state. It pulls HP/MP/XP/gold/level/glitch/karma and the four primary emotions from the vector, draws bracketed bars with the nested :func:`_bar` helper, names the dominant emotion via :func:`_get_dominant_emotion`, and renders the combat line plus any status flavor from :func:`get_battle_modifiers`. Pure string assembly -- no I/O. Called by :func:`format_hud_from_redis`; no other in-repo callers were found, so it is otherwise driven by the game subsystem's prompt assembly. Args: vector: The NCM/stat vector to visualize. Returns: str: A multi-line HUD status block. """ hp = vector.get("G_HP", 0) mp = vector.get("G_MP", 0) xp = vector.get("G_XP", 0) gold = vector.get("G_GOLD", 0) level = vector.get("G_LEVEL", 1) glitch = vector.get("G_GLITCH", 0) karma = vector.get("G_KARMA", 1.5) # Emotion bars # 💀 happy = vector.get("G_HAPPY", 0) sad = vector.get("G_SAD", 0) angry = vector.get("G_ANGRY", 0) afraid = vector.get("G_AFRAID", 0) def _bar(val: float, max_val: float = 3.0, width: int = 10) -> str: """Render a fixed-width ASCII progress bar for one HUD stat. Maps *val* onto a *width*-cell bar by filling ``int((val / max_val) * width)`` cells with ``#`` and the remainder with ``-``, wrapped in square brackets. The fill count is clamped to ``[0, width]`` so out-of-range or negative values stay inside the bar, and a non-positive *max_val* yields an empty bar (guards against division by zero). Pure string helper with no side effects, defined inside :func:`format_hud` and closing over nothing external. Called by :func:`format_hud` to draw the HP, MP, GLITCH, and each primary-emotion gauge in the GAMEGIRL COLOR status block. Args: val: The current value to visualize. max_val: The value corresponding to a full bar; ``<= 0`` produces an empty bar. width: Number of cells in the bar. Returns: str: A bracketed bar such as ``"[####------]"``. """ filled = int((val / max_val) * width) if max_val > 0 else 0 filled = min(width, max(0, filled)) return "[" + "#" * filled + "-" * (width - filled) + "]" dominant = _get_dominant_emotion(vector) mods = get_battle_modifiers(vector) lines = [ "=== GAMEGIRL HUD ===", f"LV {int(level)} | HP {_bar(hp)} {hp:.1f}/3.0 | " f"MP {_bar(mp)} {mp:.1f}/3.0", f"XP: {xp:.0f} | GOLD: {gold:.1f} | " f"KARMA: {'[+]' if karma > 1.5 else '[-]' if karma < 1.5 else '[=]'} " f"{karma:.1f}", f"GLITCH: {_bar(glitch)} {glitch:.1f}/3.0", "", f"EMOTION: {dominant}", f" HAPPY {_bar(happy)} | SAD {_bar(sad)}", f" ANGRY {_bar(angry)} | AFRAID {_bar(afraid)}", f" ATK x{mods.attack_mult} | DEF x{mods.defense_mult} | " f"SPD x{mods.speed_mult}", f" CRIT {mods.crit_rate:.0%} | EVA {mods.evasion:.0%}", ] if mods.description: lines.append(f" STATUS: {mods.description}") lines.append("=== END HUD ===") return "\n".join(lines)
[docs] async def format_hud_from_redis( game_id: str, redis: Any = None, ) -> str: """Load a game's NCM vector from Redis and render its HUD in one call. Thin async convenience wrapper that fetches the current vector via :func:`get_vector` (reading the ``game:ncm:{game_id}`` Redis string, falling back to defaults when unavailable) and hands it to the synchronous :func:`format_hud` to produce the status block. Read-only with respect to Redis. No in-repo callers were found; invoked from the game subsystem at runtime when the HUD needs to be rendered straight from persisted state. Args: game_id: Identifier of the game whose HUD is rendered. redis: Async Redis client passed through to :func:`get_vector`; when ``None`` the default vector is used. Returns: str: A multi-line HUD status block for the game's current state. """ vector = await get_vector(game_id, redis=redis) return format_hud(vector)