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