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>
841 lines
33 KiB
Rust
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 });
|
|
}
|
|
}
|