reikhelm/combat/combat-core/src/engine.rs
Parley Hatch 57dba5e149 feat(combat): order-of-magnitude slowdown — fights now ~20-30s
Fights were ~3-5s; the user wanted 20-30s. Tuned the table (and added one
mechanic) so they land there, re-validated against the sim throughout.

- Engine: a `global_cooldown` (the genre swing timer, §10 "attack delay") — an
  actor can't start a new action until it elapses, so no button-masher can
  out-DPS the intended tempo. Config-driven (`global_cooldown` ticks; 0 = off).
  Actor gains `next_action_at`; engine gates decisions on it.
- Controller: ScriptedPlayer now pressures with its heavy proactively when
  healthy (not only on the exact overrun frame), so the sim measures what a real
  player experiences instead of an unrealistically patient bound.
- Config rebalance: damage cut + flattened, fatigue/regen/recovery co-tuned, and
  the kit made roughly **DPS-neutral** with auto-attack (each ability's cooldown
  matched to auto's damage-per-second). Under the global cooldown that's the key
  insight: abilities win through stagger leverage + timing, not raw throughput —
  otherwise strong abilities trivialize the pace and flat ones aren't worth their
  fill (the guardrail kept inverting until this landed). Auto-attacks (difficulty
  0) also no longer fizzle.
- Front-end: cache-bust the config fetch so edits are always picked up; widened
  the sim's archetype sweep to all six monsters.

Result (sim, skilled play ≈ real play): monster fights 16-38s (centered ~24s);
guardrail steep and green (even-con auto 0% → skill20 2% → skill50 12% →
skill80 44% → skill100 78%); all six monsters winnable. Verified live in-browser:
realistic play lands ~15-30s and a clean mid-fight reads perfectly on the dual bar.

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

841 lines
33 KiB
Rust

//! The engine (spec §10): a fixed-tick, deterministic [`Encounter`] loop that
//! enforces the **canonical within-tick resolution order** — itself a determinism
//! invariant, because reordering changes outcomes (regen-before-damage yields
//! fewer staggers; sequential hits differ from batched).
//!
//! ```text
//! 0. Controllers decide (ascending actor-id) → pay cost, open cooldowns/windows,
//! enqueue instant hits / start casts.
//! 1. Expire finishing statuses, dazes, and defense windows (inclusive of tick t).
//! 2. Resolve casts completing this tick → enqueue their hits / install statuses.
//! 3. Apply queued hits sequentially in ascending source-id order — each followed
//! immediately by its stagger check and fatigue add.
//! 4. Tick DoT/HoT and other per-tick effects.
//! 5. Apply regen (clamped to cap).
//! ```
//!
//! A fight is a pure function of `(rules, seed, actors, controllers)`. Every roll
//! is a `root_rng.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one
//! ability never reshuffles another actor's or tick's stream (§10).
use crate::actor::{Actor, ActorId, HitOutcome, Status, StatusKind};
use crate::ability::Ability;
use crate::config::Rules;
use crate::controller::{Action, Controller, View};
use crate::effect::{EffectKind, EffectSpec, Valence};
use crate::event::{Event, FightMetrics, FightRecorder, Outcome, BlockedReason};
use crate::fixed::{apply_bp, Milli};
use crate::mitigation::{self, MitigationInputs, MitigationStage};
use crate::rng::Rng;
/// Knobs for a single fight that aren't balance (those live in [`Rules`]).
#[derive(Clone, Copy, Debug)]
pub struct EncounterOptions {
/// Hard cap on ticks; reaching it is a `Timeout` outcome.
pub max_ticks: u64,
/// Retain the raw event log (for inspecting one fight; off for sweeps).
pub log_events: bool,
/// Sample cap/fill time series every N ticks (0 = off).
pub sample_every: u32,
}
impl Default for EncounterOptions {
fn default() -> Self {
Self { max_ticks: 6_000, log_events: false, sample_every: 0 }
}
}
/// A resolved instant magnitude waiting for sequential application at step 3.
#[derive(Clone, Debug)]
struct PendingHit {
source: ActorId,
source_eff_level: i32,
target: ActorId,
ability_id: String,
seq: u64,
payload: HitPayload,
}
#[derive(Clone, Debug)]
enum HitPayload {
/// Fill damage, pre-con/pre-mitigation (potency already folded).
Fill { pre_mitigation: Milli },
/// Direct fatigue (a curse), pre-con-resist.
Ceiling { magnitude: Milli },
/// Fatigue healed (friendly, no resist).
Recovery { magnitude: Milli },
/// Instant fill restore (friendly).
Heal { magnitude: Milli },
}
/// The fight.
pub struct Encounter {
pub actors: Vec<Actor>,
controllers: Vec<Box<dyn Controller>>,
rules: Rules,
root_rng: Rng,
tick: u64,
opts: EncounterOptions,
recorder: FightRecorder,
pending: Vec<PendingHit>,
seq: u64,
}
impl Encounter {
/// Build an encounter. `actors` must carry dense ids `0..n` matching their
/// index, paired with a controller each.
pub fn new(
actors: Vec<Actor>,
controllers: Vec<Box<dyn Controller>>,
rules: Rules,
seed: u64,
opts: EncounterOptions,
) -> Self {
assert_eq!(actors.len(), controllers.len(), "one controller per actor");
for (i, a) in actors.iter().enumerate() {
assert_eq!(a.id as usize, i, "actor ids must be dense 0..n in index order");
}
let recorder = FightRecorder::new(&actors, seed, rules.tick_rate, opts.log_events, opts.sample_every);
Self {
actors,
controllers,
rules,
root_rng: Rng::from_seed(seed),
tick: 0,
opts,
recorder,
pending: Vec::new(),
seq: 0,
}
}
/// Run to resolution and return the rolled-up metrics.
pub fn run(mut self) -> FightMetrics {
loop {
self.step();
if let Some(outcome) = self.resolution() {
return self.recorder.finish(self.tick, outcome, &self.actors);
}
if self.tick >= self.opts.max_ticks {
return self.recorder.finish(self.tick, Outcome::Timeout, &self.actors);
}
self.tick += 1;
}
}
/// Run and also return the raw event log (only populated if `log_events`).
pub fn run_with_events(mut self) -> (FightMetrics, Vec<Event>) {
let metrics = loop {
self.step();
if let Some(outcome) = self.resolution() {
break self.recorder.clone().finish(self.tick, outcome, &self.actors);
}
if self.tick >= self.opts.max_ticks {
break self.recorder.clone().finish(self.tick, Outcome::Timeout, &self.actors);
}
self.tick += 1;
};
let events = self.recorder.into_events();
(metrics, events)
}
/// Advance exactly one tick, for a real-time front-end driving the engine on
/// a wall clock (spec §10). Returns `Some(outcome)` the tick the fight
/// resolves (or times out); `None` while it continues. Idempotent once
/// resolved — calling again past resolution is a no-op returning the outcome.
pub fn tick_once(&mut self) -> Option<Outcome> {
if let Some(o) = self.resolution() {
return Some(o);
}
self.step();
if let Some(o) = self.resolution() {
return Some(o);
}
if self.tick >= self.opts.max_ticks {
return Some(Outcome::Timeout);
}
self.tick += 1;
None
}
/// The current tick index (for a front-end clock / display).
pub fn current_tick(&self) -> u64 {
self.tick
}
/// Drain events recorded since the last drain (empty unless `log_events`).
/// Lets a front-end pull per-tick events for damage numbers / log lines
/// without retaining the whole fight.
pub fn drain_events(&mut self) -> Vec<Event> {
self.recorder.take_events()
}
/// One full tick through the canonical order.
fn step(&mut self) {
self.pending.clear();
self.decide_and_commit(); // step 0
self.expire(); // step 1
self.resolve_casts(); // step 2
self.apply_hits(); // step 3
self.tick_over_time(); // step 4
self.apply_regen(); // step 5
self.sample();
}
// --- step 0 ------------------------------------------------------------
fn decide_and_commit(&mut self) {
let n = self.actors.len();
// Pass 1: ALL controllers decide against the *same* start-of-tick snapshot
// (no commits happen yet). Within one 0.1s tick combatants act
// simultaneously — a higher-id actor must not get sub-tick reaction to a
// lower-id actor's spend, or a perfect mirror match would be unfair.
let mut decisions: Vec<(ActorId, Action)> = Vec::with_capacity(n);
for id in 0..n as ActorId {
let idx = id as usize;
if !self.actors[idx].is_active() {
continue; // unconscious/dead: out of the fight
}
if !self.actors[idx].can_act() {
// Dazed or mid-cast: no fresh decision. Dazed = blocked; casting
// proceeds toward step 2.
if matches!(self.actors[idx].stagger, crate::actor::StaggerState::Dazed { .. }) {
self.recorder.blocked(self.tick, id, "", BlockedReason::Dazed);
}
continue;
}
// Global cooldown / swing timer: still mid-recovery from the last
// action, so no new one can start this tick (§10 attack delay).
if self.tick < self.actors[idx].next_action_at {
continue;
}
let action = {
let view = View {
me: &self.actors[idx],
actors: &self.actors,
tick: self.tick,
rules: &self.rules,
};
let mut rng = self.root_rng.fork(&format!("actor{id}:decide:tick{}", self.tick));
self.controllers[idx].decide(&view, &mut rng)
};
decisions.push((id, action));
}
// Pass 2: commit in ascending id order (payment + cast-start are still
// ordered, the §10 step-0 invariant — only the *decision* is simultaneous).
for (id, action) in decisions {
match action {
Action::Idle => self.recorder.idle(id),
Action::Use { ability_id, targets } => self.commit_use(id, &ability_id, targets),
}
}
}
fn commit_use(&mut self, source: ActorId, ability_id: &str, targets: Vec<ActorId>) {
let idx = source as usize;
let Some(ability) = self.actors[idx].ability(ability_id).cloned() else {
// Controller named an ability not in the loadout — treat as a no-target
// misfire so it's visible rather than silent.
self.recorder.blocked(self.tick, source, ability_id, BlockedReason::NoTarget);
return;
};
if self.actors[idx].on_cooldown(&ability.id, self.tick) {
self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::OnCooldown);
return;
}
if !self.actors[idx].can_afford(ability.cost) {
// The §6 affordability gate: heavy hitters fall away as the ceiling
// grinds down. This is a real "wanted to act but couldn't" signal.
self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::CantAfford);
return;
}
// Pay at cast-start (§6): fizzle/interrupt later = paid for nothing.
let fill_before = self.actors[idx].vigor;
self.actors[idx].pay(ability.cost);
self.recorder.spent(source, ability.cost.fill);
// Open the global cooldown — no new action until it elapses.
self.actors[idx].next_action_at = self.tick + self.rules.global_cooldown as u64;
if ability.cooldown > 0 {
self.actors[idx]
.cooldowns
.insert(ability.id.clone(), self.tick + ability.cooldown as u64);
}
self.recorder.action(self.tick, &self.actors[idx], &ability.id, fill_before);
self.classify_action(source, &ability);
if ability.cast_time == 0 {
// Instant: resolve now (step 0). Self-buffs install immediately so a
// block protects this very tick; hits queue for step 3.
self.resolve_ability(source, &ability, &targets);
} else {
let completes_at = self.tick + ability.cast_time as u64;
self.actors[idx].casting = Some(crate::actor::Cast {
ability_id: ability.id.clone(),
targets,
completes_at,
});
self.recorder.cast_started(self.tick, source, &ability.id, completes_at);
}
}
fn classify_action(&mut self, source: ActorId, ability: &Ability) {
if ability.is_support() {
self.recorder.recovery_used(source);
} else if ability.cost.fill == 0 && ability.is_offensive() {
self.recorder.auto_attack(source);
} else {
self.recorder.ability_used(source);
}
}
// --- step 1 ------------------------------------------------------------
fn expire(&mut self) {
let tick = self.tick;
for a in &mut self.actors {
a.expire_statuses(tick);
a.expire_daze(tick);
}
}
// --- step 2 ------------------------------------------------------------
fn resolve_casts(&mut self) {
let n = self.actors.len();
for id in 0..n as ActorId {
let idx = id as usize;
let due = self.actors[idx]
.casting
.as_ref()
.filter(|c| c.completes_at == self.tick)
.map(|c| (c.ability_id.clone(), c.targets.clone()));
if let Some((ability_id, targets)) = due {
self.actors[idx].casting = None;
if let Some(ability) = self.actors[idx].ability(&ability_id).cloned() {
self.resolve_ability(id, &ability, &targets);
}
}
}
}
/// Roll fizzle, then route every effect: install timed statuses immediately,
/// queue instant magnitudes for step 3.
fn resolve_ability(&mut self, source: ActorId, ability: &Ability, targets: &[ActorId]) {
// Fizzle (§9): competency vs difficulty. Paid for nothing on failure.
let comp = self.actors[source as usize].competency_in(&ability.school);
let chance = self.rules.fizzle_chance_bp(comp, ability.difficulty);
if chance > 0 {
let mut rng = self
.root_rng
.fork(&format!("actor{source}:fizzle:tick{}", self.tick));
if rng.chance_bp(chance) {
self.recorder.fizzle(self.tick, source, &ability.id);
return;
}
}
let eff_level = self.actors[source as usize]
.effective_level(&ability.school, self.rules.competency_level_per_point_bp);
for (order, effect) in ability.effects.iter().enumerate() {
let chosen = self.select_targets(source, ability, effect, targets);
for target in chosen {
self.route_effect(source, eff_level, target, ability, effect, order as u64);
}
}
}
/// Pick targets for one effect by valence, honoring the controller's request
/// and topping up to `ability.targets` from the appropriate side.
fn select_targets(
&self,
source: ActorId,
ability: &Ability,
effect: &EffectSpec,
requested: &[ActorId],
) -> Vec<ActorId> {
let want = ability.targets.max(1) as usize;
let me = &self.actors[source as usize];
let mut out: Vec<ActorId> = Vec::new();
match effect.valence() {
Valence::Friendly => {
// Self first, then requested allies, then nearest allies.
let push = |id: ActorId, out: &mut Vec<ActorId>| {
if out.len() < want && !out.contains(&id) {
out.push(id);
}
};
// Honor explicit friendly requests (e.g. heal an ally), else self.
for &r in requested {
if let Some(t) = self.actors.get(r as usize) {
if t.team == me.team && t.is_alive() {
push(r, &mut out);
}
}
}
push(source, &mut out);
for a in &self.actors {
if a.team == me.team && a.is_alive() {
push(a.id, &mut out);
}
}
}
Valence::Hostile => {
let push = |id: ActorId, out: &mut Vec<ActorId>| {
if out.len() < want && !out.contains(&id) {
out.push(id);
}
};
for &r in requested {
if let Some(t) = self.actors.get(r as usize) {
if t.team != me.team && t.is_active() {
push(r, &mut out);
}
}
}
// Top up with the most-vulnerable enemies (lowest cap).
let mut enemies: Vec<&Actor> = self
.actors
.iter()
.filter(|a| a.team != me.team && a.is_active())
.collect();
enemies.sort_by_key(|a| (a.cap(), a.vigor, a.id));
for a in enemies {
push(a.id, &mut out);
}
}
}
out
}
/// Apply a status immediately or queue an instant magnitude.
fn route_effect(
&mut self,
source: ActorId,
source_eff_level: i32,
target: ActorId,
ability: &Ability,
effect: &EffectSpec,
order: u64,
) {
let potency = ability.potency_bp;
let tmax = self.actors[target as usize].max_vigor;
let scaled = |bp: i64| apply_bp(tmax, apply_bp(bp, potency));
let _ = order;
match effect.kind {
EffectKind::FillDamage { magnitude_bp } => {
let payload = HitPayload::Fill { pre_mitigation: scaled(magnitude_bp) };
let seq = self.next_seq();
self.queue(PendingHit {
source,
source_eff_level,
target,
ability_id: ability.id.clone(),
seq,
payload,
});
}
EffectKind::CeilingDamage { magnitude_bp } => {
let payload = HitPayload::Ceiling { magnitude: scaled(magnitude_bp) };
let seq = self.next_seq();
self.queue(PendingHit {
source,
source_eff_level,
target,
ability_id: ability.id.clone(),
seq,
payload,
});
}
EffectKind::Recovery { magnitude_bp } => {
let payload = HitPayload::Recovery { magnitude: scaled(magnitude_bp) };
let seq = self.next_seq();
self.queue(PendingHit {
source,
source_eff_level,
target,
ability_id: ability.id.clone(),
seq,
payload,
});
}
EffectKind::Hot { per_tick_bp, duration: 0 } => {
let payload = HitPayload::Heal { magnitude: scaled(per_tick_bp) };
let seq = self.next_seq();
self.queue(PendingHit {
source,
source_eff_level,
target,
ability_id: ability.id.clone(),
seq,
payload,
});
}
// --- timed statuses: install now, tick later ---
EffectKind::Dot { per_tick_bp, duration } => self.install(
target,
StatusKind::Dot { per_tick_bp: apply_bp(per_tick_bp, potency), source_eff_level },
duration,
source,
),
EffectKind::Hot { per_tick_bp, duration } => self.install(
target,
StatusKind::Hot { per_tick_bp: apply_bp(per_tick_bp, potency) },
duration,
source,
),
EffectKind::RegenSabotage { reduce_bp, duration } => {
self.install(target, StatusKind::RegenSabotage { reduce_bp }, duration, source)
}
EffectKind::FatigueAmp { amp_bp, duration } => {
self.install(target, StatusKind::FatigueAmp { amp_bp }, duration, source)
}
EffectKind::MitigationBuff { stage, amount_bp, duration } => self.install(
target,
StatusKind::Mitigation { stage, amount_bp: apply_bp(amount_bp, potency) },
duration,
source,
),
EffectKind::MitigationDebuff { stage, amount_bp, duration } => self.install(
target,
// A debuff strips mitigation → stored negative so the pipeline
// amplifies the hit (a vulnerability/sunder).
StatusKind::Mitigation { stage, amount_bp: -apply_bp(amount_bp, potency) },
duration,
source,
),
}
}
fn install(&mut self, target: ActorId, kind: StatusKind, duration: u32, source: ActorId) {
if duration == 0 {
return;
}
let expires_at = self.tick + duration as u64 - 1;
self.actors[target as usize].add_status(Status { kind, expires_at, source });
}
fn queue(&mut self, hit: PendingHit) {
self.pending.push(hit);
}
fn next_seq(&mut self) -> u64 {
self.seq += 1;
self.seq
}
/// Apply deterministic ± damage variance to a magnitude. Returns the input
/// unchanged when variance is disabled. Keyed by `(source, target, seq, tick)`
/// so it never reshuffles another hit's stream (§10).
fn varied(&self, amount: Milli, source: ActorId, target: ActorId, seq: u64) -> Milli {
let var = self.rules.damage_variance_bp;
if var <= 0 {
return amount;
}
let mut rng = self.root_rng.fork(&format!(
"hit:src{source}:tgt{target}:seq{seq}:tick{}",
self.tick
));
// Uniform factor in [BP_ONE - var, BP_ONE + var].
let factor = (crate::fixed::BP_ONE - var) + rng.range(0, (2 * var + 1) as i32) as i64;
apply_bp(amount, factor)
}
// --- step 3 ------------------------------------------------------------
fn apply_hits(&mut self) {
// Sequential in ascending source-id order, stable within a source: hit 1
// lowers fill before hit 2's stagger check (§10).
let mut pending = std::mem::take(&mut self.pending);
pending.sort_by_key(|h| (h.source, h.seq));
for h in pending {
self.apply_one(h);
}
}
fn apply_one(&mut self, h: PendingHit) {
let tidx = h.target as usize;
if self.actors[tidx].stagger.is_dead() {
return;
}
match h.payload {
HitPayload::Fill { pre_mitigation } => {
// Per-hit damage variance (deterministic, keyed by the hit). The
// genre's damage range; also what desyncs a mirror match.
let pre_mitigation = self.varied(pre_mitigation, h.source, h.target, h.seq);
let con = self
.rules
.con_mult_bp(h.source_eff_level, self.actors[tidx].level);
let inp = MitigationInputs {
armor_reduce_bp: self.actors[tidx].armor_bp,
con_mult_bp: con,
active_defense_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::ActiveDefense),
stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance),
buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff),
};
let mit = mitigation::run(pre_mitigation, inp);
// Capture cast-in-progress to detect interruption.
let was_casting = self.actors[tidx]
.casting
.as_ref()
.map(|c| c.ability_id.clone());
let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger);
self.recorder
.hit(self.tick, h.source, &h.ability_id, &self.actors[tidx], mit, outcome);
if matches!(outcome, HitOutcome::Dazed | HitOutcome::Unconscious | HitOutcome::Killed) {
if let Some(cast_id) = was_casting {
if self.actors[tidx].casting.is_none() {
self.recorder.interrupted(self.tick, h.target, &cast_id);
}
}
}
if matches!(outcome, HitOutcome::Killed) {
self.recorder.death(self.tick, h.target);
}
}
HitPayload::Ceiling { magnitude } => {
// Con effectiveness gates how much of the curse lands (§7).
let con = self
.rules
.con_mult_bp(h.source_eff_level, self.actors[tidx].level);
let landed = apply_bp(magnitude, con);
let before_down = !self.actors[tidx].is_active();
self.actors[tidx].add_fatigue(landed);
self.recorder
.ceiling_damage(self.tick, h.source, h.target, &h.ability_id, landed);
if !before_down && !self.actors[tidx].is_active() {
// Collapse from the curse counts as a death-adjacent event only
// if it actually killed; unconscious is logged via state.
if self.actors[tidx].stagger.is_dead() {
self.recorder.death(self.tick, h.target);
}
}
}
HitPayload::Recovery { magnitude } => {
let was_down = !self.actors[tidx].is_active();
self.actors[tidx]
.recover_fatigue(magnitude, self.tick, self.rules.revive_daze);
let revived = was_down && self.actors[tidx].is_active();
self.recorder
.recovery(self.tick, h.source, h.target, &h.ability_id, magnitude, revived);
}
HitPayload::Heal { magnitude } => {
let was_down = !self.actors[tidx].is_active();
self.actors[tidx]
.restore_fill(magnitude, self.tick, self.rules.revive_daze);
let revived = was_down && self.actors[tidx].is_active();
self.recorder.fill_restored(h.target, magnitude);
if revived {
self.recorder
.recovery(self.tick, h.source, h.target, &h.ability_id, 0, true);
}
}
}
}
// --- step 4 ------------------------------------------------------------
fn tick_over_time(&mut self) {
let n = self.actors.len();
for tidx in 0..n {
if self.actors[tidx].stagger.is_dead() {
continue;
}
// Snapshot the over-time statuses to apply (avoid borrow conflicts).
let tmax = self.actors[tidx].max_vigor;
let tlevel = self.actors[tidx].level;
let mut dots: Vec<(Milli, i32, ActorId)> = Vec::new();
let mut hots: Vec<Milli> = Vec::new();
for s in &self.actors[tidx].statuses {
match s.kind {
StatusKind::Dot { per_tick_bp, source_eff_level } => {
dots.push((apply_bp(tmax, per_tick_bp), source_eff_level, s.source));
}
StatusKind::Hot { per_tick_bp } => {
hots.push(apply_bp(tmax, per_tick_bp));
}
_ => {}
}
}
for (pre, src_level, src) in dots {
let con = self.rules.con_mult_bp(src_level, tlevel);
let inp = MitigationInputs {
armor_reduce_bp: self.actors[tidx].armor_bp,
con_mult_bp: con,
active_defense_reduce_bp: 0, // a poison isn't parried
stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance),
buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff),
};
let mit = mitigation::run(pre, inp);
let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger);
self.recorder
.hit(self.tick, src, "dot", &self.actors[tidx], mit, outcome);
if matches!(outcome, HitOutcome::Killed) {
self.recorder.death(self.tick, self.actors[tidx].id);
}
}
for amount in hots {
self.actors[tidx]
.restore_fill(amount, self.tick, self.rules.revive_daze);
self.recorder.fill_restored(self.actors[tidx].id, amount);
}
}
}
// --- step 5 ------------------------------------------------------------
fn apply_regen(&mut self) {
for a in &mut self.actors {
let before = a.vigor;
a.regen_tick(true, &self.rules.regen);
let gained = a.vigor - before;
if gained > 0 {
self.recorder.regen(a.id, gained);
}
}
}
fn sample(&mut self) {
let tick = self.tick;
// Snapshot ids first to avoid borrowing actors while recorder mutates.
for i in 0..self.actors.len() {
let snapshot = self.actors[i].clone();
self.recorder.sample(tick, &snapshot);
}
}
// --- resolution --------------------------------------------------------
/// `Some(outcome)` once the fight is decided: one team holds the only
/// conscious actor(s) (win), or none are conscious (draw).
fn resolution(&self) -> Option<Outcome> {
let mut teams_up: Vec<u8> = Vec::new();
for a in &self.actors {
if a.is_active() && !teams_up.contains(&a.team) {
teams_up.push(a.team);
}
}
match teams_up.len() {
0 => Some(Outcome::Draw),
1 => Some(Outcome::Win { team: teams_up[0] }),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ability::{AbilitySpec, Delivery, RawCost};
use crate::actor::{compile_actor, StatBlock};
use crate::config::CombatConfig;
use crate::effect::EffectSpec;
use std::collections::BTreeMap;
fn swing_spec() -> AbilitySpec {
AbilitySpec {
id: "swing".into(),
name: "Swing".into(),
school: "martial".into(),
delivery: Delivery::Melee,
effects: vec![EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1_000 }, 0)],
potency_bp: 10_000,
targets: 1,
cast_time: 0,
cooldown: 10,
cost_override: Some(RawCost { fill: 0, fatigue: 1 }),
fatigue_units: None,
difficulty: None,
}
}
fn fighter(name: &str, team: u8, level: i32) -> StatBlock {
StatBlock {
name: name.into(),
team,
level,
max_vigor: 100,
regen_idle_per_sec: 10,
regen_combat_per_sec: 5,
armor_bp: 0,
competency: BTreeMap::new(),
loadout: vec!["swing".into()],
start_vigor_bp: 10_000,
start_fatigue_bp: 0,
}
}
fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> (Vec<Actor>, Rules) {
let rules = cfg.compile();
let actors = blocks
.iter()
.enumerate()
.map(|(i, sb)| {
let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
assert!(missing.is_empty(), "missing abilities: {missing:?}");
compile_actor(i as ActorId, sb, loadout, rules.tick_rate)
})
.collect();
(actors, rules)
}
#[test]
fn deterministic_even_fight_resolves() {
let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 10)];
let (actors, rules) = build(&cfg, &blocks);
let controllers: Vec<Box<dyn Controller>> = vec![
Box::new(crate::controller::SwingOnCooldown),
Box::new(crate::controller::SwingOnCooldown),
];
let enc = Encounter::new(actors, controllers, rules, 42, EncounterOptions::default());
let m = enc.run();
// Two identical swingers: it ends (someone goes down) and doesn't time out.
assert!(matches!(m.outcome, Outcome::Win { .. } | Outcome::Draw));
assert!(m.ticks < 6_000);
}
#[test]
fn same_seed_same_outcome() {
let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 12)];
let run_once = |seed: u64| {
let (actors, rules) = build(&cfg, &blocks);
let controllers: Vec<Box<dyn Controller>> = vec![
Box::new(crate::controller::SwingOnCooldown),
Box::new(crate::controller::SwingOnCooldown),
];
Encounter::new(actors, controllers, rules, seed, EncounterOptions::default()).run()
};
let a = run_once(7);
let b = run_once(7);
assert_eq!(a.ticks, b.ticks);
assert_eq!(format!("{:?}", a.outcome), format!("{:?}", b.outcome));
assert_eq!(a.actors[0].fill_damage_dealt, b.actors[0].fill_damage_dealt);
}
#[test]
fn higher_con_attacker_wins() {
let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() };
// +5 levels of con advantage should win reliably.
let blocks = vec![fighter("Strong", 0, 15), fighter("Weak", 1, 10)];
let (actors, rules) = build(&cfg, &blocks);
let controllers: Vec<Box<dyn Controller>> = vec![
Box::new(crate::controller::SwingOnCooldown),
Box::new(crate::controller::SwingOnCooldown),
];
let m = Encounter::new(actors, controllers, rules, 1, EncounterOptions::default()).run();
assert_eq!(m.outcome, Outcome::Win { team: 0 });
}
}