reikhelm/combat/combat-core/src/event.rs
Parley Hatch 5055feb4c3 feat(combat): deterministic combined-pool combat engine + sim + analysis
Build the full combat-system experiment from its spec, no slices deferred.

combat-core — pure, headless, deterministic engine (the single source of truth):
- One combined Vigor pool (HP+mana+stamina merged) with the two-axis
  (fill, ceiling) cost model; offense spends survival.
- Stagger cascade (absorbed/dazed/unconscious/dead), capacity = current fill,
  ceiling collapse via fatigue as the real loss condition; revive-on-recovery.
- Unified ability model (skills == spells) with the delivery+effects+potency+
  targets cost composer and a full effect library (fill/ceiling damage, DoT/HoT,
  regen-sabotage, fatigue-amp, mitigation buff/debuff, recovery).
- Ordered mitigation pipeline, config-driven con curve, competency/fizzle/resist,
  loadout/memorization, active-defense windows.
- Symmetric actors; pluggable controllers (SwingOnCooldown, ScriptedPlayer{skill},
  HoldAndPunish). Fixed-tick loop with the canonical within-tick resolution order;
  decisions are simultaneous within a tick so mirror matches are fair.
- Determinism is load-bearing: vendored order-independent RNG fork (+ integer
  chance_bp so no f64 touches the hot path) and integer fixed-point magnitudes
  with banker's rounding. 48 tests incl. bit-reproducible event-log integration.

combat-sim — batch harness: loads the config "table", runs con-tier x skill and
archetype sweeps (tens of thousands of fights), exports per-fight CSV, an
aggregated summary, and one fight's full event log.

analysis — pandas/matplotlib layer: difficulty curve, duration tent, anti-turtle
guardrail, stagger frequency, and one fight's fill/ceiling time series.

The sim did its job: the first default numbers produced a one-tick cliff and 82%
mutual-KO draws and surfaced the spec's deepest risk live (a poke skill strictly
worse than free auto-attack, so auto-only out-won the full kit). Tuning the table
moved it to the intended shape — duration tent peaking ~22s at even con, a
monotone skill->win-rate gradient (auto ~3% -> skill100 44% at even con), and a
green anti-turtle guardrail. That sim->measure->tune loop is the deliverable.

Own Cargo workspace, fully decoupled from the root reikhelm workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:13:04 -06:00

524 lines
15 KiB
Rust

//! 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<HitOutcome> 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<StaggerState> 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<Milli>,
/// Sampled `vigor` over time. Empty unless sampling is enabled.
pub fill_series: Vec<Milli>,
}
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<ActorMetrics>,
}
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<ActorMetrics>,
events: Vec<Event>,
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<Event> {
self.events
}
}