"""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 # 🎮
# =====================================================================