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 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: """Calculated stat modifiers from the current emotion vector.""" 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]: return asdict(self)
# ===================================================================== # NCM operations # 🔥 # =====================================================================
[docs] async def initialize_game_ncm( game_id: str, redis: Any = None, ) -> dict[str, float]: """Create the default NCM vector for a new game.""" 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]: """Get the current game NCM vector.""" 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 delta changes to the game NCM vector. Values are clamped to [0, 3] for emotions and stats. G_XP and G_LEVEL are uncapped upward. After applying deltas, combo emotions are recalculated. """ 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 combo emotion values from primary emotions. Combos use geometric mean of their constituent emotions, so both need to be elevated for the combo to fire. """ 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: """Calculate battle stat modifiers from the current emotion vector. Each emotion affects different stats: - HAPPY: crit rate up, speed up - SAD: defense up, speed down - ANGRY: attack up, accuracy down - AFRAID: evasion up, attack down - Combos stack additional effects """ 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.""" 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: """Get the damage multiplier for an emotion matchup. Returns: float: 1.5 for advantage, 0.7 for disadvantage, 1.0 for neutral. """ return _EMOTION_ADVANTAGE.get( attacker_emotion, {}, ).get(defender_emotion, 1.0)
# ===================================================================== # HUD formatting # 🎮 # =====================================================================
[docs] def format_hud(vector: dict[str, float]) -> str: """Render a HUD-style status display for the system prompt. Uses the GAMEGIRL COLOR aesthetic with bar-style indicators. """ 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: 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: """Convenience: load vector and format HUD in one call.""" vector = await get_vector(game_id, redis=redis) return format_hud(vector)