"""Escalation engine — intensity curve, phase params, stochastic timing.""" import math import random import time from server.config import config class EscalationEngine: """Computes intensity and rendering parameters from elapsed time. Intensity follows a logarithmic curve: intensity = log(1 + elapsed * rate). Rendering parameters are derived from intensity and clamped to [0, 1]. Timing intervals are randomized within ranges that shrink with intensity. """ PALETTES = [ "void_black", "crimson_void", "deep_rot", "sickly_green", "bruise_purple", "ash_grey", "bile_yellow", "blood_orange", ] def __init__(self, rate: float | None = None): self.rate = rate if rate is not None else config.escalation.rate self.session_start: float | None = None def start_session(self) -> None: """Begin a new escalation session.""" self.session_start = time.monotonic() def reset(self) -> None: """Restart the session from zero.""" self.session_start = time.monotonic() def get_elapsed(self) -> float: """Seconds since session start.""" if self.session_start is None: return 0.0 return time.monotonic() - self.session_start def get_intensity(self, elapsed: float | None = None) -> float: """Compute intensity from elapsed seconds. Logarithmic, never peaks.""" if elapsed is None: elapsed = self.get_elapsed() return math.log(1 + elapsed * self.rate) def get_phase_params(self, intensity: float | None = None) -> dict: """Derive rendering parameters from current intensity. All values clamped to [0, 1]. Higher intensity = more severe effects. """ if intensity is None: intensity = self.get_intensity() def _sigmoid(x: float, midpoint: float = 2.0, steepness: float = 1.5) -> float: """Smooth 0→1 mapping centered at midpoint.""" return 1.0 / (1.0 + math.exp(-steepness * (x - midpoint))) morph_speed = _sigmoid(intensity, midpoint=1.5, steepness=1.2) shader_severity = _sigmoid(intensity, midpoint=2.0, steepness=1.0) voice_frequency = _sigmoid(intensity, midpoint=2.5, steepness=0.8) noise_level = _sigmoid(intensity, midpoint=3.0, steepness=1.0) surprise_chance = min(1.0, _sigmoid(intensity, midpoint=3.5, steepness=0.6)) # Palette shifts to more aggressive colors at higher intensity palette_index = min(int(intensity), len(self.PALETTES) - 1) return { "morph_speed": round(morph_speed, 3), "shader_severity": round(shader_severity, 3), "voice_frequency": round(voice_frequency, 3), "noise_level": round(noise_level, 3), "surprise_chance": round(surprise_chance, 3), "palette": self.PALETTES[palette_index], } def get_asset_swap_interval(self, intensity: float | None = None) -> float: """Random interval until next asset swap. Shrinks with intensity.""" if intensity is None: intensity = self.get_intensity() cfg = config.escalation # Lerp from max to min as intensity increases, with noise t = min(1.0, intensity / 5.0) base = cfg.asset_swap_max - t * (cfg.asset_swap_max - cfg.asset_swap_min) jitter = random.uniform(-base * 0.3, base * 0.3) return max(cfg.asset_swap_min, base + jitter) def get_voice_interval(self, intensity: float | None = None) -> float: """Poisson-distributed interval until next voice clip. Mean decreases with intensity.""" if intensity is None: intensity = self.get_intensity() cfg = config.escalation # Mean interval decreases from voice_mean_interval toward 3s mean = max(3.0, cfg.voice_mean_interval / (1 + intensity)) return random.expovariate(1.0 / mean) def should_fake_calm(self) -> bool: """Roll for a fake calm period.""" return random.random() < config.escalation.fake_calm_chance def get_fake_calm_duration(self) -> float: """Duration of a fake calm period.""" cfg = config.escalation return random.uniform(cfg.fake_calm_duration_min, cfg.fake_calm_duration_max) def should_cluster_burst(self) -> bool: """Roll for a cluster burst (rapid-fire events).""" return random.random() < config.escalation.cluster_burst_chance def get_cluster_burst_count(self) -> int: """Number of events in a cluster burst.""" cfg = config.escalation return random.randint(cfg.cluster_burst_count_min, cfg.cluster_burst_count_max) def select_severity(self, intensity: float | None = None) -> float: """Pick a target severity for the next asset. Biased toward current intensity.""" if intensity is None: intensity = self.get_intensity() # Normal distribution centered on intensity, clipped to [0, intensity+1] severity = random.gauss(intensity, 0.5) return max(0.0, min(severity, intensity + 1.0))