//! Instrumentation (spec §12): a structured event stream plus a recorder that //! rolls it into per-fight metrics. Outcomes alone can't tell you *why* a config //! feels bad, so the schema records **causes** — pre-mitigation damage and each //! mitigation stage's contribution (you can't tune the con curve from `landed` //! alone), `blocked` reasons (a "wanted to act but couldn't" signal that makes //! soft-lock/turtle modes visible), and a fizzle-vs-interrupt split. use crate::actor::{Actor, ActorId, HitOutcome, StaggerState}; use crate::fixed::Milli; use serde::{Deserialize, Serialize}; /// Why an attempted action didn't happen (§12 `action_blocked_reason`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum BlockedReason { CantAfford, OnCooldown, Dazed, NoTarget, } /// Why a committed ability produced nothing (§12 `fail_reason`). Kept separate so /// "competency too low" and "wind-ups too long" never collapse into one signal. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FailReason { CompetencyFizzle, Interrupted, } /// How a fight ended. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Outcome { /// A single team had the last conscious actor(s) standing. Win { team: u8 }, /// No conscious actors on any team (mutual collapse). Draw, /// Tick budget exhausted with multiple teams still up. Timeout, } /// The raw event stream (opt-in; not stored for million-fight sweeps). A tagged /// enum so it serializes cleanly to JSON for inspecting a single fight. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum Event { Action { tick: u64, actor: ActorId, ability: String, fill_before: Milli, fill_after: Milli, fatigue: Milli, cap: Milli, }, CastStarted { tick: u64, actor: ActorId, ability: String, completes_at: u64, }, Blocked { tick: u64, actor: ActorId, ability: String, reason: BlockedReason, }, Fail { tick: u64, actor: ActorId, ability: String, reason: FailReason, }, Hit { tick: u64, source: ActorId, target: ActorId, ability: String, pre_mitigation: Milli, armor: Milli, con: Milli, defense: Milli, stance: Milli, buff: Milli, landed: Milli, outcome: HitKind, target_fill_after: Milli, target_cap_after: Milli, }, Ceiling { tick: u64, source: ActorId, target: ActorId, ability: String, fatigue_added: Milli, }, Recovery { tick: u64, source: ActorId, target: ActorId, ability: String, fatigue_healed: Milli, revived: bool, }, Death { tick: u64, actor: ActorId, }, End { tick: u64, outcome: Outcome, }, } /// Serializable mirror of [`HitOutcome`]. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HitKind { Absorbed, Dazed, Unconscious, Killed, } impl From for HitKind { fn from(o: HitOutcome) -> Self { match o { HitOutcome::Absorbed => HitKind::Absorbed, HitOutcome::Dazed => HitKind::Dazed, HitOutcome::Unconscious => HitKind::Unconscious, HitOutcome::Killed => HitKind::Killed, } } } /// Serializable final state of an actor. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FinalState { Alive, Dazed, Unconscious, Dead, } impl From for FinalState { fn from(s: StaggerState) -> Self { match s { StaggerState::Normal => FinalState::Alive, StaggerState::Dazed { .. } => FinalState::Dazed, StaggerState::Unconscious => FinalState::Unconscious, StaggerState::Dead => FinalState::Dead, } } } /// Per-actor rolled-up metrics for one fight (§12). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ActorMetrics { pub id: ActorId, pub name: String, pub team: u8, pub level: i32, // Throughput. pub fill_damage_dealt: Milli, pub fill_damage_taken: Milli, pub ceiling_damage_dealt: Milli, pub ceiling_damage_taken: Milli, // Fill-flow accounting (§12 make-or-break co-tuning): where every point went. pub fill_gained_regen: Milli, pub fill_spent_actions: Milli, pub fill_lost_damage: Milli, pub fill_restored: Milli, // Stagger ledger. pub dazes_inflicted: u32, pub unconscious_inflicted: u32, pub dazes_taken: u32, pub unconscious_taken: u32, // Reliability. pub fizzles: u32, pub interruptions_suffered: u32, // Blocked-action breakdown. pub blocked_cant_afford: u32, pub blocked_on_cooldown: u32, pub blocked_dazed: u32, pub blocked_no_target: u32, // Action mix — feeds the auto-attack-only guardrail (§12). pub auto_attacks: u32, pub abilities_used: u32, pub recoveries_used: u32, pub idle_ticks: u32, pub near_death_recoveries: u32, // Lows over the fight. pub min_fill: Milli, pub min_cap: Milli, pub final_state: FinalState, /// Sampled `cap` over time (the ceiling-decay curve). Empty unless sampling /// is enabled. pub cap_series: Vec, /// Sampled `vigor` over time. Empty unless sampling is enabled. pub fill_series: Vec, } impl ActorMetrics { fn new(a: &Actor) -> Self { Self { id: a.id, name: a.name.clone(), team: a.team, level: a.level, fill_damage_dealt: 0, fill_damage_taken: 0, ceiling_damage_dealt: 0, ceiling_damage_taken: 0, fill_gained_regen: 0, fill_spent_actions: 0, fill_lost_damage: 0, fill_restored: 0, dazes_inflicted: 0, unconscious_inflicted: 0, dazes_taken: 0, unconscious_taken: 0, fizzles: 0, interruptions_suffered: 0, blocked_cant_afford: 0, blocked_on_cooldown: 0, blocked_dazed: 0, blocked_no_target: 0, auto_attacks: 0, abilities_used: 0, recoveries_used: 0, idle_ticks: 0, near_death_recoveries: 0, min_fill: a.vigor, min_cap: a.cap(), final_state: FinalState::Alive, cap_series: Vec::new(), fill_series: Vec::new(), } } } /// Whole-fight metrics (§12). The sim exports one row per fight from this. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FightMetrics { pub seed: u64, pub ticks: u64, pub tick_rate: u32, pub outcome: Outcome, pub actors: Vec, } impl FightMetrics { /// Fight duration in seconds (derived from ticks and rate). pub fn seconds(&self) -> f64 { self.ticks as f64 / self.tick_rate.max(1) as f64 } /// Look up an actor's metrics by id. pub fn actor(&self, id: ActorId) -> Option<&ActorMetrics> { self.actors.iter().find(|m| m.id == id) } } /// Accumulates events into [`FightMetrics`]. Always tracks the rollups; only /// retains the raw [`Event`] log when `log_events` is set (cheap for a single /// inspected fight, skipped for million-fight sweeps). Sampling `cap`/`fill` /// series is gated by `sample_every` (0 = off). #[derive(Clone, Debug)] pub struct FightRecorder { metrics: Vec, events: Vec, log_events: bool, sample_every: u32, tick_rate: u32, seed: u64, } impl FightRecorder { pub fn new(actors: &[Actor], seed: u64, tick_rate: u32, log_events: bool, sample_every: u32) -> Self { Self { metrics: actors.iter().map(ActorMetrics::new).collect(), events: Vec::new(), log_events, sample_every, tick_rate, seed, } } fn m(&mut self, id: ActorId) -> &mut ActorMetrics { // Actor ids are dense 0..n in encounters; fall back to a search if not. let idx = self .metrics .iter() .position(|x| x.id == id) .expect("metrics exist for every actor"); &mut self.metrics[idx] } fn push(&mut self, e: Event) { if self.log_events { self.events.push(e); } } // --- engine-facing recording API -------------------------------------- pub fn action(&mut self, tick: u64, a: &Actor, ability: &str, fill_before: Milli) { self.push(Event::Action { tick, actor: a.id, ability: ability.to_string(), fill_before, fill_after: a.vigor, fatigue: a.fatigue, cap: a.cap(), }); } pub fn cast_started(&mut self, tick: u64, actor: ActorId, ability: &str, completes_at: u64) { self.push(Event::CastStarted { tick, actor, ability: ability.to_string(), completes_at, }); } pub fn idle(&mut self, actor: ActorId) { self.m(actor).idle_ticks += 1; } pub fn auto_attack(&mut self, actor: ActorId) { self.m(actor).auto_attacks += 1; } pub fn ability_used(&mut self, actor: ActorId) { self.m(actor).abilities_used += 1; } pub fn recovery_used(&mut self, actor: ActorId) { self.m(actor).recoveries_used += 1; } pub fn spent(&mut self, actor: ActorId, fill: Milli) { self.m(actor).fill_spent_actions += fill; } pub fn blocked(&mut self, tick: u64, actor: ActorId, ability: &str, reason: BlockedReason) { { let m = self.m(actor); match reason { BlockedReason::CantAfford => m.blocked_cant_afford += 1, BlockedReason::OnCooldown => m.blocked_on_cooldown += 1, BlockedReason::Dazed => m.blocked_dazed += 1, BlockedReason::NoTarget => m.blocked_no_target += 1, } } self.push(Event::Blocked { tick, actor, ability: ability.to_string(), reason, }); } pub fn fizzle(&mut self, tick: u64, actor: ActorId, ability: &str) { self.m(actor).fizzles += 1; self.push(Event::Fail { tick, actor, ability: ability.to_string(), reason: FailReason::CompetencyFizzle, }); } pub fn interrupted(&mut self, tick: u64, actor: ActorId, ability: &str) { self.m(actor).interruptions_suffered += 1; self.push(Event::Fail { tick, actor, ability: ability.to_string(), reason: FailReason::Interrupted, }); } /// Record a resolved hit with its full mitigation breakdown. #[allow(clippy::too_many_arguments)] pub fn hit( &mut self, tick: u64, source: ActorId, ability: &str, target: &Actor, mit: crate::mitigation::MitigationResult, outcome: HitOutcome, ) { { let m = self.m(source); m.fill_damage_dealt += mit.landed; match outcome { HitOutcome::Dazed => m.dazes_inflicted += 1, HitOutcome::Unconscious | HitOutcome::Killed => m.unconscious_inflicted += 1, HitOutcome::Absorbed => {} } } { let m = self.m(target.id); m.fill_damage_taken += mit.landed; m.fill_lost_damage += mit.landed; match outcome { HitOutcome::Dazed => m.dazes_taken += 1, HitOutcome::Unconscious => m.unconscious_taken += 1, _ => {} } } self.push(Event::Hit { tick, source, target: target.id, ability: ability.to_string(), pre_mitigation: mit.pre_mitigation, armor: mit.armor_delta, con: mit.con_delta, defense: mit.defense_delta, stance: mit.stance_delta, buff: mit.buff_delta, landed: mit.landed, outcome: outcome.into(), target_fill_after: target.vigor, target_cap_after: target.cap(), }); } pub fn ceiling_damage(&mut self, tick: u64, source: ActorId, target: ActorId, ability: &str, fatigue: Milli) { self.m(source).ceiling_damage_dealt += fatigue; self.m(target).ceiling_damage_taken += fatigue; self.push(Event::Ceiling { tick, source, target, ability: ability.to_string(), fatigue_added: fatigue, }); } pub fn recovery( &mut self, tick: u64, source: ActorId, target: ActorId, ability: &str, fatigue_healed: Milli, revived: bool, ) { if revived { self.m(target).near_death_recoveries += 1; } self.push(Event::Recovery { tick, source, target, ability: ability.to_string(), fatigue_healed, revived, }); } pub fn fill_restored(&mut self, target: ActorId, amount: Milli) { self.m(target).fill_restored += amount; } pub fn regen(&mut self, actor: ActorId, amount: Milli) { self.m(actor).fill_gained_regen += amount; } pub fn death(&mut self, tick: u64, actor: ActorId) { self.push(Event::Death { tick, actor }); } /// Per-tick sampling of lows + (optional) time series. pub fn sample(&mut self, tick: u64, a: &Actor) { let store_series = self.sample_every > 0 && tick.is_multiple_of(self.sample_every as u64); let m = self.m(a.id); m.min_fill = m.min_fill.min(a.vigor); m.min_cap = m.min_cap.min(a.cap()); if store_series { m.cap_series.push(a.cap()); m.fill_series.push(a.vigor); } } /// Finalize into [`FightMetrics`], stamping each actor's terminal state. pub fn finish(mut self, tick: u64, outcome: Outcome, actors: &[Actor]) -> FightMetrics { for a in actors { self.m(a.id).final_state = a.stagger.into(); } self.push(Event::End { tick, outcome }); FightMetrics { seed: self.seed, ticks: tick, tick_rate: self.tick_rate, outcome, actors: self.metrics, } } /// The raw event log (empty unless `log_events` was set). pub fn into_events(self) -> Vec { self.events } }