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>
524 lines
15 KiB
Rust
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
|
|
}
|
|
}
|