overlord_event_system/mechanics/
fight.rs

1//! Native Rust fight logic.
2//!
3//! These are the pure-Rust fight primitives (`attack`, `try_cast`,
4//! `spawn_wave`, ...) used by the native combat ports in
5//! [`crate::behaviors`]. Results are emitted through the [`FightSink`]
6//! abstraction and effect reactions through the [`EffectCb`] abstraction; the
7//! shipped reactions live in [`crate::mechanics::effect_cb::OverlordEffectCb`].
8//!
9
10use std::sync::Arc;
11
12use configs::game_config::GameConfig;
13use essences::abilities::Ability;
14use essences::combat_origin::CombatEventOrigin;
15use essences::entity::{Coordinates, Entity, EntityAttributes, EntityId};
16use essences::fight_breakdown::CombatSource;
17use essences::fighting::{ActiveFight, EntityTeam};
18use event_system::script::random::GameRng;
19use uuid::Uuid;
20
21use crate::event::CustomEventData;
22use crate::event::*;
23use crate::game_config_helpers::GameConfigLookup;
24use crate::mechanics::balance;
25use crate::mechanics::content_lookups::{ContentLookups, EffectTpl};
26use crate::state::OverlordState;
27
28pub const BATTLEFIELD_HEIGHT: i64 = 3;
29const DOT_EFFECT_ID: &str = "019589cb-7adf-7466-8c46-92ac45032880";
30const HOT_EFFECT_ID: &str = "01958c0a-329e-744d-a5eb-0369e3f4c024";
31const REGEN_EFFECT_ID: &str = "01978d0c-5a8a-7b50-aaa8-e4cd7782f8bc";
32/// Effect applied to the player below `LOW_CHAPTER_EFFECT_MAX_CHAPTER`.
33const LOW_CHAPTER_EFFECT_ID: &str = "019e27f4-f16b-7881-87cf-bc55605cb80b";
34/// Inclusive chapter ceiling for applying `LOW_CHAPTER_EFFECT_ID`.
35/// Matches `init_fight` in fight.yaml (raised from 10 to 16 in OVT-2405).
36const LOW_CHAPTER_EFFECT_MAX_CHAPTER: i64 = 16;
37const DUNGEON_TALENT_ID: &str = "019d1c47-5a66-76e5-bb18-6d115e5c6942";
38const BOSS_TALENT_ID: &str = "019d1c47-8cc2-7136-b95b-68e15d7efd0c";
39pub const COUNTERATTACK_PROJECTILE: &str = "019aeeed-5bcc-7dcc-a74f-589031a6b8f2";
40
41// ---------------------------------------------------------------------------
42// Output sink — abstracts how a fight method emits its results.
43// ---------------------------------------------------------------------------
44
45// Wave-entrance geometry and paces (offset cells, rush/walk ms-per-cell) live in
46// `fight_settings.wave_entrance_*` since the grid-migration фаза 1 — the former
47// WAVE_ENTRANCE_* constants, now tunable per deploy. Spawn placement and the
48// entrance run scheduling in `handle_spawn_entity` must read the SAME config
49// field, or spawn and entrance stop mirroring each other.
50
51// Slot model (docs/combat-grid-migration-plan.md §2.1): the sliding window of
52// enemy columns hangs off the player's CURRENT column P — melee enemies fight
53// from P+1 (in the hero's range-1 contact), pure-ranged from P+2 (their range 2
54// reaches the hero, his melee doesn't reach them until the promotion step).
55// The offsets are the load-bearing halves of the |Δx| range math and must not
56// drift apart from it; the WORLD spacing between the columns is the Unity
57// ScriptableObject's business (фаза 3), not the server's.
58pub const MELEE_COL_OFFSET: i64 = 1;
59pub const RANGED_COL_OFFSET: i64 = 2;
60
61/// Damage-payload key marking a hit as a DERIVED copy of a cast (support-stone
62/// Split / Chain / Pierce / Repeat / Pulse) rather than an original Core hit.
63/// Anything that reacts to combat events (laws, resonance, mastery, triggers)
64/// must skip a payload carrying this key.
65pub const DERIVED_DAMAGE_KEY: &str = "derived";
66
67/// Entity attributes behind the two defensive supports. `guard_charges` counts
68/// pending Fortify charges, `guard_reduction` is the reduction of the next hit
69/// in ×10000 units (3000 = −30%). Shelter needs no attribute of its own: it is a
70/// plain timed `received_damage.mod`.
71pub const GUARD_CHARGES_ATTR: &str = "guard_charges";
72pub const GUARD_REDUCTION_ATTR: &str = "guard_reduction";
73/// Parts-per-million multiplier preserving A2-BAL-001's measured, previously
74/// shipped boss outgoing budget after the hidden automatic stun was removed.
75/// Read only by [`attack`] after the full hit payload is composed.
76pub const BOSS_OUTGOING_EQUIVALENCE_PPM_ATTR: &str = "boss_outgoing_equivalence_ppm";
77/// Between-wave formation dash distance in logical cells. Advancing fights
78/// assign waves 2+ against the player's landing column (current + this).
79pub const FORMATION_ADVANCE_CELLS: i64 = 4;
80const DEFAULT_BETWEEN_WAVE_SPAWN_DELAY_TICKS: u64 = 500;
81
82/// Per-fight behavior selected for a normal wave transition. Dungeon overrides
83/// are independent: omitting either field preserves that standard behavior.
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub struct BetweenWaveBehavior {
86    pub spawn_delay_ticks: u64,
87    pub advance_formation: bool,
88}
89
90/// Resolves per-dungeon wave behavior without adding state to
91/// [`ActiveFight`]. Campaign fights and dungeons without overrides keep the
92/// shipped 500 ms spawn pause and formation advance.
93pub fn between_wave_behavior(config: &GameConfig, fight: &ActiveFight) -> BetweenWaveBehavior {
94    let dungeon_template = fight
95        .dungeon
96        .as_ref()
97        .and_then(|dungeon| config.dungeon_template(dungeon.id));
98
99    BetweenWaveBehavior {
100        spawn_delay_ticks: dungeon_template
101            .and_then(|dungeon| dungeon.between_wave_spawn_delay_ticks)
102            .unwrap_or(DEFAULT_BETWEEN_WAVE_SPAWN_DELAY_TICKS),
103        advance_formation: dungeon_template
104            .and_then(|dungeon| dungeon.advance_formation_between_waves)
105            .unwrap_or(true),
106    }
107}
108
109/// Entrance floor shared by the live slot scheduler and the balance mini-sim.
110/// Stationary dungeons have no artificial formation wait.
111pub fn later_wave_entrance_floor_ticks(config: &GameConfig, fight: &ActiveFight) -> u64 {
112    if between_wave_behavior(config, fight).advance_formation {
113        config.fight_settings.formation_advance_ticks
114    } else {
115        0
116    }
117}
118
119/// Base run pace, ms per cell, before the ally speed multiplier.
120const DEFAULT_TIME_PER_CELL: f64 = 500.0;
121
122/// Duration of an ally formation run over `cells` logical cells (the promotion
123/// step, the between-wave dash fallback) at the standard ally pace.
124pub fn formation_step_duration_ticks(lookups: &ContentLookups, cells: f64) -> u64 {
125    let mult = if lookups.ally_run_speed_mult > 0.0 {
126        lookups.ally_run_speed_mult
127    } else {
128        1.0
129    };
130    ((DEFAULT_TIME_PER_CELL / mult) * cells).floor() as u64
131}
132
133/// Where a fight method emits its results.
134///
135/// Every method takes the provenance of the work it is emitting. The fight
136/// primitives always pass [`CombatEventOrigin::Core`] — they *are* genuine
137/// combat — and [`OriginSink`] merges its own scope on top, so a modifier's
138/// output is marked without a single primitive knowing about modifiers.
139pub trait FightSink {
140    fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error>;
141    fn push_attack(
142        &mut self,
143        delay: u64,
144        duration: u64,
145        target: Uuid,
146        origin: CombatEventOrigin,
147    ) -> Result<(), anyhow::Error>;
148    fn push_run(
149        &mut self,
150        coords: Coordinates,
151        duration: u64,
152        origin: CombatEventOrigin,
153    ) -> Result<(), anyhow::Error>;
154
155    /// Claims one of the dispatch's one-shot budgets: returns `armed` the first
156    /// time `slot` is claimed and `0` on every later call for that same slot.
157    ///
158    /// Exists because [`attack`] only sees `&Entity` — a snapshot taken before
159    /// the dispatch began — so it cannot tell its second call apart from its
160    /// first. A multi-target ability calls `attack` once per target against
161    /// that same snapshot, so a "next attack only" bonus read straight off the
162    /// entity would boost every target.
163    ///
164    /// Exactly one sink is built per dispatch, so "once per sink" is "once per
165    /// cast". The budgets are independent — a swing can be the first to claim
166    /// the crit chance AND the first to claim the damage bonus — which is why
167    /// the latch is per slot rather than one for the sink.
168    fn claim_once(&mut self, slot: OnceSlot, armed: i64) -> i64;
169}
170
171/// The independent one-shot budgets a single dispatch can claim.
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum OnceSlot {
174    /// Effect Stone `NextAttackCritChance` (`EF-C06`).
175    CritChance,
176    /// Effect Stone `NextAttackDamageBonus` (`EF-C05`, `EF-R06`, `EF-E07`).
177    DamageBonus,
178    /// The law arm for the family of the cast being resolved (`RL-02`, `FL-02`).
179    LawArm,
180    /// The law arm for the strike being resolved right now (`RL-01`).
181    ///
182    /// Separate from [`OnceSlot::LawArm`] because a "this attack" law and a
183    /// "next attack" law can be armed at the same time, and one shared latch
184    /// would silently swallow the second.
185    LawThisArm,
186}
187
188impl OnceSlot {
189    const COUNT: usize = 4;
190
191    fn index(self) -> usize {
192        match self {
193            Self::CritChance => 0,
194            Self::DamageBonus => 1,
195            Self::LawArm => 2,
196            Self::LawThisArm => 3,
197        }
198    }
199}
200
201/// Sink that collects fight results as typed values for native callers. Mirrors
202/// `start_behavior`s fill `casts`.
203#[derive(Default)]
204pub struct NativeSink {
205    pub events: Vec<OverlordEvent>,
206    pub casts: Vec<crate::behaviors::combat::start_cast::StartCastAbilityScriptResult>,
207    /// Which of this dispatch's one-shot budgets have already been taken — see
208    /// [`FightSink::claim_once`].
209    once_claimed: [bool; OnceSlot::COUNT],
210}
211
212impl FightSink for NativeSink {
213    fn push_event(&mut self, event: OverlordEvent) -> Result<(), anyhow::Error> {
214        self.events.push(event);
215        Ok(())
216    }
217
218    fn push_attack(
219        &mut self,
220        delay: u64,
221        duration: u64,
222        target: Uuid,
223        origin: CombatEventOrigin,
224    ) -> Result<(), anyhow::Error> {
225        self.casts.push(
226            crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
227                delay_ticks: Some(delay),
228                animation_duration_ticks: Some(duration),
229                target_entity_id: Some(target),
230                origin,
231                ..Default::default()
232            },
233        );
234        Ok(())
235    }
236
237    fn push_run(
238        &mut self,
239        coords: Coordinates,
240        duration: u64,
241        origin: CombatEventOrigin,
242    ) -> Result<(), anyhow::Error> {
243        self.casts.push(
244            crate::behaviors::combat::start_cast::StartCastAbilityScriptResult {
245                coordinates: Some(coords),
246                run_duration_ticks: Some(duration),
247                origin,
248                ..Default::default()
249            },
250        );
251        Ok(())
252    }
253
254    fn claim_once(&mut self, slot: OnceSlot, armed: i64) -> i64 {
255        let claimed = &mut self.once_claimed[slot.index()];
256        if *claimed {
257            return 0;
258        }
259        *claimed = true;
260        armed
261    }
262}
263
264/// Generic event push: converts any `EventStruct<OverlordEvent>` to the enum and
265/// emits it through the sink.
266fn push_event<ES>(sink: &mut dyn FightSink, event: ES) -> Result<(), anyhow::Error>
267where
268    ES: event_system::event::EventStruct<OverlordEvent> + 'static,
269{
270    sink.push_event(event.to_enum())
271}
272
273/// Sink decorator that stamps the provenance of everything a producer emits,
274/// and refuses whatever it cannot stamp.
275///
276/// The fight primitives below always build `CombatEventOrigin::Core` and know
277/// nothing about modifiers. A modifier runs its work against
278/// `OriginSink::proc(real_sink)` instead, so everything it emits — directly or
279/// through a nested primitive — comes out `Proc` without any call site
280/// threading the marker.
281///
282/// Casts and runs are carried, not refused: `push_attack` / `push_run` write the
283/// scope's provenance into the queued cast, which reaches
284/// [`essences::entity::ActionWithDeadline`], so the damage it lands ticks later
285/// still resolves under this scope's mark.
286///
287/// The sink fails closed for events it cannot mark: a non-Core scope may only
288/// emit events [`OverlordEvent::set_origin`] accepts. `CastEffectFromEvent` has
289/// no field of its own to stamp and `SpawnEntity` would hand a producer a
290/// combatant fighting with its own Core actions, so both are refused. A Core
291/// scope is unrestricted — Core is what every emit site already builds.
292pub struct OriginSink<'a> {
293    inner: &'a mut dyn FightSink,
294    origin: CombatEventOrigin,
295}
296
297impl<'a> OriginSink<'a> {
298    /// Wraps `inner` so that every event it receives is stamped with `origin`.
299    pub fn new(inner: &'a mut dyn FightSink, origin: CombatEventOrigin) -> Self {
300        Self { inner, origin }
301    }
302
303    /// The modifier scope: everything emitted through this sink is `Proc`.
304    pub fn proc(inner: &'a mut dyn FightSink) -> Self {
305        Self::new(inner, CombatEventOrigin::Proc)
306    }
307
308    /// Whether this scope is allowed to emit things it cannot mark.
309    fn unrestricted(&self) -> bool {
310        self.origin.is_core()
311    }
312}
313
314impl FightSink for OriginSink<'_> {
315    fn push_event(&mut self, mut event: OverlordEvent) -> Result<(), anyhow::Error> {
316        // Marking only ever upgrades: a Core scope leaves the event alone, so an
317        // already-Proc event cannot be reset by being emitted through one. The
318        // stamp is what the refusal below is keyed on, hence the explicit
319        // Core short-circuit rather than an unconditional `set_origin`.
320        if self.unrestricted() {
321            return self.inner.push_event(event);
322        }
323        if !event.set_origin(self.origin) {
324            anyhow::bail!(
325                "a {} producer may not emit {event}: it carries no provenance of its own, \
326                 so its combat output would surface as Core",
327                self.origin
328            );
329        }
330        self.inner.push_event(event)
331    }
332
333    fn push_attack(
334        &mut self,
335        delay: u64,
336        duration: u64,
337        target: Uuid,
338        origin: CombatEventOrigin,
339    ) -> Result<(), anyhow::Error> {
340        self.inner
341            .push_attack(delay, duration, target, origin.merge(self.origin))
342    }
343
344    fn push_run(
345        &mut self,
346        coords: Coordinates,
347        duration: u64,
348        origin: CombatEventOrigin,
349    ) -> Result<(), anyhow::Error> {
350        self.inner
351            .push_run(coords, duration, origin.merge(self.origin))
352    }
353
354    /// Forwarded, not re-latched: the budget belongs to the dispatch, and a
355    /// producer scope running inside one is part of that same dispatch.
356    fn claim_once(&mut self, slot: OnceSlot, armed: i64) -> i64 {
357        self.inner.claim_once(slot, armed)
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Effect callbacks.
363//
364// Effect reactions (`on_apply` / `on_change`) are abstracted behind this trait
365// so the native fns call `effects.on_apply(...)` without knowing the concrete
366// dispatcher. The shipped reactions are native, keyed by effect `code`, in
367// [`crate::mechanics::effect_cb::OverlordEffectCb`]. Callers that don't
368// want reactions pass [`NoopEffectCb`].
369// ---------------------------------------------------------------------------
370
371/// Invokes an effect's `on_apply` / `on_change` reaction.
372pub trait EffectCb {
373    /// Called after an effect's duration is (re)applied, when the effect has an
374    /// `on_apply` reaction.
375    fn on_apply(
376        &mut self,
377        sink: &mut dyn FightSink,
378        effect: &EffectTpl,
379        target: &Entity,
380    ) -> Result<(), anyhow::Error>;
381
382    /// Called when an effect's stack count changes, when the effect has an
383    /// `on_change` reaction.
384    fn on_change(
385        &mut self,
386        sink: &mut dyn FightSink,
387        effect: &EffectTpl,
388        target: &Entity,
389        new_stacks: i64,
390        old_stacks: i64,
391    ) -> Result<(), anyhow::Error>;
392}
393
394/// Effect callback that does nothing — for callers that don't want effect
395/// reactions dispatched.
396pub struct NoopEffectCb;
397
398impl EffectCb for NoopEffectCb {
399    fn on_apply(
400        &mut self,
401        _sink: &mut dyn FightSink,
402        _effect: &EffectTpl,
403        _target: &Entity,
404    ) -> Result<(), anyhow::Error> {
405        Ok(())
406    }
407
408    fn on_change(
409        &mut self,
410        _sink: &mut dyn FightSink,
411        _effect: &EffectTpl,
412        _target: &Entity,
413        _new_stacks: i64,
414        _old_stacks: i64,
415    ) -> Result<(), anyhow::Error> {
416        Ok(())
417    }
418}
419
420// ---------------------------------------------------------------------------
421// Attribute helpers
422// ---------------------------------------------------------------------------
423
424fn attr_get(entity: &Entity, attr_code: &str) -> f64 {
425    entity.attributes.0.get(attr_code).copied().unwrap_or(0) as f64
426}
427
428fn attr_present(entity: &Entity, attr_code: &str) -> bool {
429    entity.attributes.0.contains_key(attr_code)
430}
431
432/// Push an `EntityIncrAttribute` event. Rounds f64 deltas to the nearest int.
433fn incr_attr(
434    sink: &mut dyn FightSink,
435    entity_id: Uuid,
436    attr: &str,
437    delta: i64,
438) -> Result<(), anyhow::Error> {
439    push_event(
440        sink,
441        OverlordEventEntityIncrAttribute {
442            entity_id,
443            attribute: attr.to_string(),
444            delta,
445        },
446    )
447}
448
449/// `ctx.get_entity_stat(entity, "armor")` — applies attribute base value (from
450/// content_raw), additive `bonus`, and multiplicative `mod` (`+10000 == +1.0`).
451pub fn get_entity_stat(lookups: &ContentLookups, entity: &Entity, stat_code: &str) -> f64 {
452    let mut stat = attr_get(entity, stat_code);
453    if let Some(attr_id) = lookups.attribute_by_code.get(stat_code)
454        && let Some(base) = lookups.attribute_base_value.get(attr_id)
455    {
456        stat += base;
457    }
458    let bonus = attr_get(entity, &format!("{stat_code}.bonus"));
459    // Balance v2: floor the `.mod` multiplier so a stacking debuff (weakness on
460    // attack, protection on received_damage, …) can't drive a stat to ≤0
461    // (zero-damage / unkillable). Only bites at mod ≤ −9500; buffs unaffected.
462    let mod_v = (attr_get(entity, &format!("{stat_code}.mod")) / 10000.0 + 1.0)
463        .max(balance::MIN_STAT_MOD_MULT);
464    (stat + bonus) * mod_v
465}
466
467/// True iff `stat_code` resolves to a promoted base value in
468/// `attribute_base_value`. Lets combat tell a genuine "base missing" content
469/// regression (the whole `attribute_base_value` lookup empty — see
470/// `content_raw_extract`) apart from a stat that merely composed low via mods.
471fn has_attribute_base(lookups: &ContentLookups, stat_code: &str) -> bool {
472    lookups
473        .attribute_by_code
474        .get(stat_code)
475        .is_some_and(|id| lookups.attribute_base_value.contains_key(id))
476}
477
478pub fn stat_throw(
479    random: &GameRng,
480    lookups: &ContentLookups,
481    entity: &Entity,
482    stat_code: &str,
483    bonus: f64,
484) -> bool {
485    stat_throw_scaled(random, lookups, entity, stat_code, bonus, 1.0)
486}
487
488/// Entropy-based proc check (PoE-style).
489/// Instead of an independent roll per check, each (entity, stat) keeps an
490/// accumulator seeded 0..9999 from the fight RNG on first use; every check
491/// adds the chance (permyriad) and the proc fires exactly when the
492/// accumulator crosses 10000. The expected proc RATE is identical to
493/// independent rolls; streaks — lucky and unlucky — are eliminated, which is
494/// the variance compensation that makes proc stats (crit/dodge/multicast/
495/// block/counter) safe to value at full expected-value price in the honest
496/// scalar (research: PoE entropy, verified 3-0).
497pub fn entropy_throw(
498    random: &GameRng,
499    lookups: &ContentLookups,
500    entity: &Entity,
501    stat_code: &str,
502    bonus: f64,
503    scale: f64,
504) -> bool {
505    let stat_value = (get_entity_stat(lookups, entity, stat_code) + bonus) * scale;
506    if stat_value <= 0.0 {
507        return false;
508    }
509    let add = stat_value.min(10000.0) as i64;
510    if add >= 10000 {
511        return true;
512    }
513    entity
514        .proc_entropy
515        .bump(stat_code, add, || (random.random_f64() * 10000.0) as i64)
516}
517
518/// Entropy variant for a probability that is already a fraction (0..1) rather
519/// than a permyriad stat — the dodge DR curve's output.
520pub fn entropy_throw_p(random: &GameRng, entity: &Entity, key: &str, p: f64) -> bool {
521    if p <= 0.0 {
522        return false;
523    }
524    if p >= 1.0 {
525        return true;
526    }
527    entity.proc_entropy.bump(key, (p * 10000.0) as i64, || {
528        (random.random_f64() * 10000.0) as i64
529    })
530}
531
532/// [`stat_throw`] with the success probability multiplied by `scale` —
533/// the sigmoid-steepening experiments dial down binary per-hit RNG (crit)
534/// without touching entity stats. `scale = 1.0` is byte-identical to the
535/// unscaled throw (same single RNG draw for any positive stat).
536pub fn stat_throw_scaled(
537    random: &GameRng,
538    lookups: &ContentLookups,
539    entity: &Entity,
540    stat_code: &str,
541    bonus: f64,
542    scale: f64,
543) -> bool {
544    let stat_value = get_entity_stat(lookups, entity, stat_code) + bonus;
545    if stat_value > 0.0 {
546        let success = stat_value / 10000.0 * scale;
547        return random.random_f64() < success;
548    }
549    false
550}
551
552// ---------------------------------------------------------------------------
553// Entity geometry / target selection
554// ---------------------------------------------------------------------------
555
556/// True when a mover of `width` can place its front on `c` without overlapping
557/// any other entity's current cell or reserved `move_target`. The body extends
558/// behind the front (left for +x movers, right for -x). `mover_id` is skipped.
559fn entity_get_tpl_cast_time(config: &GameConfig, entity: &Entity) -> i64 {
560    if let Some(tpl_id) = entity.entity_template_id
561        && let Some(tpl) = config.entity_template(tpl_id)
562    {
563        return tpl.cast_time as i64;
564    }
565    if let Some(class_id) = entity.class_id
566        && let Some(class) = config.class(class_id)
567    {
568        return class.cast_time as i64;
569    }
570    0
571}
572
573fn get_distance_to(a: &Entity, b: &Entity) -> i64 {
574    (b.coordinates.x - a.coordinates.x).abs()
575}
576
577fn get_target_with_lowest_hp(targets: &[Entity]) -> Option<&Entity> {
578    targets.iter().min_by_key(|e| e.hp)
579}
580
581fn get_closest_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
582    targets.iter().min_by_key(|t| {
583        (t.coordinates.x - caster.coordinates.x).abs()
584            + (t.coordinates.y - caster.coordinates.y).abs()
585    })
586}
587
588fn get_target<'a>(caster: &Entity, targets: &'a [Entity]) -> Option<&'a Entity> {
589    if caster.entity_template_id.is_some() {
590        get_closest_target(caster, targets)
591    } else {
592        get_target_with_lowest_hp(targets)
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Top-level fight methods
598// ---------------------------------------------------------------------------
599
600/// Native: `ctx.add_entity_attr(entity, attr, value)` — push an `IncrAttribute`
601pub fn add_entity_attr(
602    sink: &mut dyn FightSink,
603    entity: &Entity,
604    attr_code: &str,
605    value: i64,
606) -> Result<(), anyhow::Error> {
607    incr_attr(sink, entity.id, attr_code, value)
608}
609
610/// Native: `ctx.add_entity_stat_mod(entity, stat, value)` — push an
611/// `IncrAttribute` on `"{stat}.mod"` by `value`. Mirrors the engine's
612/// `add_entity_stat_mod` (which lowers to `add_entity_attr(entity, "{stat}.mod",
613/// value)`); used by the native effect-callback dispatcher to replicate the
614pub fn add_entity_stat_mod(
615    sink: &mut dyn FightSink,
616    entity: &Entity,
617    stat: &str,
618    value: i64,
619) -> Result<(), anyhow::Error> {
620    add_entity_attr(sink, entity, &format!("{stat}.mod"), value)
621}
622
623/// Native: `ctx.set_entity_attr(entity, attr, value)` — push an `IncrAttribute`
624/// that moves the attr TO `value` (delta = `round(value - current)`). Emitted
625pub fn set_entity_attr(
626    sink: &mut dyn FightSink,
627    entity: &Entity,
628    attr_code: &str,
629    value: f64,
630) -> Result<(), anyhow::Error> {
631    let current = attr_get(entity, attr_code);
632    incr_attr(sink, entity.id, attr_code, (value - current).round() as i64)
633}
634
635/// Native: returns whether `target` is touched (i.e. did NOT evade). Balance
636/// v2: dodge probability follows the DR curve `ev/(ev+K_DODGE)` (asymptote
637/// < 100%, no 100%-dodge cliff). Attribute-balance track: the dodge OUTCOME is
638/// resolved through the entropy accumulator, not an independent roll — a 20%
639/// dodge evades exactly every 5th incoming touch, never streaks (the variance
640/// compensation that makes evasion safe to grant as a live stat).
641pub fn touch_enemy(rng: &GameRng, lookups: &ContentLookups, target: &Entity) -> bool {
642    let evasion = get_entity_stat(lookups, target, "evasion");
643    if evasion <= 0.0 {
644        return true;
645    }
646    let dodge = evasion / (evasion + balance::tuning().k_dodge);
647    !entropy_throw_p(rng, target, "evasion.dodge", dodge)
648}
649
650/// Native: emit a `screen_shake` visual event.
651pub fn screen_shake(sink: &mut dyn FightSink, power: i64) -> Result<(), anyhow::Error> {
652    let mut data = CustomEventData::default();
653    data.add("screen_shake_power", power);
654    push_event(
655        sink,
656        OverlordEventFightVisualEvent {
657            effect_type: "screen_shake".to_string(),
658            effect_data: data,
659        },
660    )
661}
662
663/// Native: heal `entity`, capping at its missing HP. Returns the applied heal
664/// (`None` if no heal was applied). `by_entity_id` and `source` are the
665/// breakdown attribution — who healed, and with what.
666pub fn heal_entity(
667    sink: &mut dyn FightSink,
668    by_entity_id: Option<EntityId>,
669    entity: &Entity,
670    amount: f64,
671    source: CombatSource,
672) -> Result<Option<i64>, anyhow::Error> {
673    let max_heal = entity.max_hp as i64 - entity.hp as i64;
674    let heal = max_heal.min(amount.floor() as i64);
675    if heal <= 0 {
676        return Ok(None);
677    }
678    push_event(
679        sink,
680        OverlordEventHeal {
681            by_entity_id,
682            entity_id: entity.id,
683            heal: heal as u64,
684            origin: CombatEventOrigin::Core,
685            source,
686        },
687    )?;
688    Ok(Some(heal))
689}
690
691/// Native: apply `raw_dmg` to `entity` (armor, shield, block roll, received
692/// damage multiplier). Returns the floored post-mitigation damage value; `None`
693/// only for the no-damage early-outs (`godmode` / a missing `received_damage`
694/// base value — a content-extraction regression), which short-circuit before
695/// the block roll. When a shield fully absorbs the
696/// hit the function still returns `Some(floored_damage)` (and emits the shield
697/// decrement) — this value drives `attack`'s return / lifesteal, so it
698/// must not be `None` or the caller would discard the shield event.
699/// Consumes one `block` `stat_throw` from `rng` when it is reached.
700#[allow(clippy::too_many_arguments)]
701pub fn damage_entity(
702    sink: &mut dyn FightSink,
703    rng: &GameRng,
704    lookups: &ContentLookups,
705    by_entity_id: Option<EntityId>,
706    entity: &Entity,
707    raw_dmg: f64,
708    mut custom_data: CustomEventData,
709    source: CombatSource,
710) -> Result<Option<i64>, anyhow::Error> {
711    if attr_present(entity, "godmode") {
712        return Ok(None);
713    }
714    let armor = get_entity_stat(lookups, entity, "armor");
715    let shield = attr_get(entity, "shield");
716    // `received_damage` is a ×10000 multiplier whose base value (10000 == 100%
717    // taken) lives in `attribute_base_value`. If that lookup is *entirely absent*
718    // the content pipeline failed to promote it (see `content_raw_extract`) —
719    // keep the loud legacy behaviour (cancel all damage → fights visibly stall)
720    // so a content/extraction regression still surfaces, instead of silently
721    // degrading to the floor below (~1% damage, a 100×-slower near-stalemate).
722    if !has_attribute_base(lookups, "received_damage") {
723        return Ok(None);
724    }
725
726    // Effect Stone defensive states. All three keys are only ever written on the
727    // hero, so for a mob — and for a hero with no stones — this block reads three
728    // absent attributes and changes nothing.
729    //
730    // They live here rather than in the stone runtime because they must act
731    // BEFORE the hit is applied: a post-hoc heal cannot un-kill anybody, and a
732    // blocked hit has to not land at all.
733    let mut raw_dmg = raw_dmg;
734    {
735        use crate::mechanics::stones as stone;
736
737        // `EF-E06` Perfect Guard: the hit is blocked outright and answered.
738        if stone::attr(entity, stone::GUARD_CHARGES) > 0 {
739            let charges = stone::attr(entity, stone::GUARD_CHARGES);
740            incr_attr(sink, entity.id, stone::GUARD_CHARGES, -1)?;
741            let retaliation = stone::attr(entity, stone::GUARD_RETALIATION);
742            if charges <= 1 {
743                incr_attr(sink, entity.id, stone::GUARD_RETALIATION, -retaliation)?;
744            }
745            if let (Some(attacker), true) = (by_entity_id, retaliation > 0) {
746                // `Proc`: a retaliation is the effect's output, not the hero's
747                // own action, so it must not fire a trigger.
748                push_event(
749                    sink,
750                    OverlordEventDamage {
751                        by_entity_id: Some(entity.id),
752                        entity_id: attacker,
753                        damage: retaliation as u64,
754                        damage_data: CustomEventData::default(),
755                        origin: CombatEventOrigin::Proc,
756                        source: CombatSource::Retaliation,
757                    },
758                )?;
759            }
760            return Ok(Some(0));
761        }
762
763        // `EF-C04` Brace Impact (charge-based) and `EF-R04` Fortress Window
764        // (timed) compose multiplicatively — two independent mitigations should
765        // not be able to add up past total immunity.
766        let mut reduction = stone::attr(entity, stone::INCOMING_REDUCTION).clamp(0, 10_000);
767        let charged = stone::attr(entity, stone::HIT_REDUCTION_CHARGES);
768        if charged > 0 {
769            let one_shot = stone::attr(entity, stone::HIT_REDUCTION).clamp(0, 10_000);
770            incr_attr(sink, entity.id, stone::HIT_REDUCTION_CHARGES, -1)?;
771            if charged <= 1 {
772                incr_attr(
773                    sink,
774                    entity.id,
775                    stone::HIT_REDUCTION,
776                    -stone::attr(entity, stone::HIT_REDUCTION),
777                )?;
778            }
779            reduction = 10_000 - ((10_000 - reduction) * (10_000 - one_shot)) / 10_000;
780        }
781        if reduction > 0 {
782            raw_dmg *= (10_000 - reduction) as f64 / 10_000.0;
783        }
784    }
785    // Balance v2: with the base present, floor received-damage at a small
786    // positive instead of treating `≤0` as full invuln — a negative
787    // `received_damage.mod` (e.g. stacked `protection`, −50%/stack) would
788    // otherwise cancel ALL damage forever and make the entity unkillable.
789    // `godmode` above remains the only true invuln.
790    let mut received_damage_k =
791        get_entity_stat(lookups, entity, "received_damage").max(balance::MIN_RECEIVED_DAMAGE_K);
792
793    if entropy_throw(rng, lookups, entity, "block", 0.0, 1.0) {
794        received_damage_k *= 0.5;
795        custom_data.add("block", 1);
796        // BAL-033: the Warrior passive turns a block into sustain. No ICD —
797        // the block roll is the rate limit, and a blocked hit stays a hit
798        // event, so hit-taken counters and triggers see it either way.
799        let heal = crate::mechanics::class_passives::block_heal(entity);
800        if heal > 0.0 {
801            // Class-passive sustain: no producing catalog id exists at this
802            // site, so it lands in the passive-regeneration bucket.
803            heal_entity(
804                sink,
805                Some(entity.id),
806                entity,
807                heal,
808                CombatSource::Regeneration,
809            )?;
810        }
811    }
812
813    // Fortify (support stone): the cast left a guard charge that softens the
814    // NEXT incoming hit and is spent doing so.
815    if attr_get(entity, GUARD_CHARGES_ATTR) >= 1.0 {
816        let reduction = (attr_get(entity, GUARD_REDUCTION_ATTR) / 10000.0).clamp(0.0, 0.95);
817        if reduction > 0.0 {
818            received_damage_k *= 1.0 - reduction;
819            custom_data.add("guard", 1);
820        }
821        incr_attr(sink, entity.id, GUARD_CHARGES_ATTR, -1)?;
822    }
823
824    let dmg_k = balance::armor_k(armor) * received_damage_k / 10000.0;
825    let mut dmg = raw_dmg * dmg_k;
826
827    // `EF-L05` Last Light: while the guard is up, a hit may take the hero to
828    // 1 HP but never past it. Clamped on the post-mitigation number, so the
829    // client still sees a real (if truncated) hit rather than a silent no-op.
830    if crate::mechanics::stones::attr(entity, crate::mechanics::stones::DAMAGE_FLOOR) > 0 {
831        let survivable = entity.hp.saturating_sub(1) as f64;
832        dmg = dmg.min(survivable);
833        // At exactly 1 HP there is nothing left to take. Returning here rather
834        // than falling through matters: the `.max(1)` floor below exists so a
835        // landed hit never renders as a phantom dodge, and it would otherwise
836        // turn a fully-guarded hit into the killing blow.
837        if dmg <= 0.0 {
838            return Ok(Some(0));
839        }
840    }
841    // A landed hit deals at least 1: sub-1 values (tiny early-game attacks ×
842    // the wave damage skew) otherwise floor to 0, which the client renders as
843    // "Dodge" — a phantom miss. True negation stays possible via godmode and
844    // evasion only.
845    let res = (dmg.floor() as i64).max(1);
846    if shield == 0.0 {
847        push_event(
848            sink,
849            OverlordEventDamage {
850                by_entity_id,
851                entity_id: entity.id,
852                damage: res.max(0) as u64,
853                damage_data: custom_data,
854                origin: CombatEventOrigin::Core,
855                source,
856            },
857        )?;
858    } else if dmg > shield {
859        incr_attr(sink, entity.id, "shield", -(shield as i64))?;
860        let dmg_hp = (dmg - shield).floor() as i64;
861        custom_data.add("shield_damage", shield as i64);
862        push_event(
863            sink,
864            OverlordEventDamage {
865                by_entity_id,
866                entity_id: entity.id,
867                damage: dmg_hp.max(0) as u64,
868                damage_data: custom_data,
869                origin: CombatEventOrigin::Core,
870                source,
871            },
872        )?;
873    } else {
874        incr_attr(sink, entity.id, "shield", -(dmg.round() as i64))?;
875    }
876    Ok(Some(res))
877}
878
879/// Typed form of the `attack` params.
880#[derive(Clone, Debug, Default)]
881pub struct AttackParams {
882    pub no_counterattack: bool,
883    pub crit_chance_bonus: f64,
884    /// Forced crit value; `None` => roll `crit_chance`.
885    pub is_crit: Option<bool>,
886    pub power: Option<f64>,
887    pub dot_power: Option<f64>,
888    /// This hit is a DERIVED copy produced by a support stone, not the original
889    /// cast. It is tagged `derived` in the damage payload so no Core-event
890    /// reader (laws, resonance, mastery, triggers) counts it, and it never
891    /// shakes the screen like an original cast.
892    pub derived: bool,
893}
894
895impl AttackParams {
896    /// The default `attack(caster, target)` overload uses `power = 1.0`.
897    pub fn default_power() -> Self {
898        Self {
899            power: Some(1.0),
900            ..Default::default()
901        }
902    }
903}
904
905/// Native: perform an attack from `caster` on `target`. Returns the floored
906/// damage dealt (`None` => the no-damage paths: evasion, or no `power` given).
907/// RNG is consumed in the exact original order: evasion, counterattack_chance,
908/// deceit (+ `randint(0,2)` if it lands), crit_chance, then `damage_entity`'s
909/// block roll.
910#[allow(clippy::too_many_arguments)]
911pub fn attack(
912    sink: &mut dyn FightSink,
913    rng: &GameRng,
914    lookups: &ContentLookups,
915    effects: &mut dyn EffectCb,
916    player_id: Uuid,
917    caster: &Entity,
918    target: &Entity,
919    params: &AttackParams,
920    source: CombatSource,
921) -> Result<Option<i64>, anyhow::Error> {
922    if !touch_enemy(rng, lookups, target) {
923        push_event(
924            sink,
925            OverlordEventEvasion {
926                entity_id: target.id,
927                origin: CombatEventOrigin::Core,
928            },
929        )?;
930        return Ok(None);
931    }
932    // §2 consumer 3: scale the counter proc probability by the ATTACKER's
933    // (caster's) wave_share so N weak hits yield ~N×(1/N) = the original counter
934    // rate. Legacy content (share 1.0) is byte-identical. The entropy accumulator
935    // handles the fractional chance rhythmically; no extra RNG draw.
936    if !params.no_counterattack
937        && entropy_throw(
938            rng,
939            lookups,
940            target,
941            "counterattack_chance",
942            0.0,
943            caster.attributes.wave_share(),
944        )
945    {
946        push_event(
947            sink,
948            OverlordEventCounterAttack {
949                by_entity_id: target.id,
950                to_entity_id: caster.id,
951                duration_ticks: 200,
952                origin: CombatEventOrigin::Core,
953            },
954        )?;
955        push_event(
956            sink,
957            OverlordEventStartCastProjectile {
958                by_entity_id: target.id,
959                to_entity_id: caster.id,
960                projectile_id: Uuid::parse_str(COUNTERATTACK_PROJECTILE).unwrap(),
961                level: 1,
962                delay: 0,
963                origin: CombatEventOrigin::Core,
964                source: CombatSource::Counterattack,
965            },
966        )?;
967    }
968
969    if entropy_throw(rng, lookups, caster, "deceit", 0.0, 1.0) {
970        let pool = ["vulnerability", "weakness"];
971        let idx = rng.random_index(pool.len());
972        let code = pool[idx];
973        change_entity_effect_duration(
974            sink,
975            lookups,
976            effects,
977            target,
978            code,
979            (balance::DECEIT_DEBUFF_DURATION * 1000.0).floor() as i64,
980        )?;
981    }
982
983    let attack = get_entity_stat(lookups, caster, "attack");
984
985    // Effect Stone `NextAttackCritChance` (`EF-C06`): a one-shot crit-chance
986    // bonus armed by a trigger fire, spent by the FIRST swing of this dispatch
987    // that actually rolls a crit. Same `claim_once` discipline as the damage
988    // bonus below, and for the same reason — `caster` is a snapshot shared by
989    // every target of an AoE dispatch.
990    let mut crit_chance_bonus = params.crit_chance_bonus;
991    if params.is_crit.is_none() {
992        let armed = crate::mechanics::stones::armed_magnitude(
993            caster,
994            crate::mechanics::stones::NEXT_ATTACK_CRIT,
995            crate::mechanics::stones::NEXT_ATTACK_CRIT_CHARGES,
996        )
997        .unwrap_or(0);
998        let bonus = if armed > 0 {
999            sink.claim_once(OnceSlot::CritChance, armed)
1000        } else {
1001            0
1002        };
1003        if bonus > 0 {
1004            crit_chance_bonus += bonus as f64 / 10_000.0;
1005            incr_attr(
1006                sink,
1007                caster.id,
1008                crate::mechanics::stones::NEXT_ATTACK_CRIT_CHARGES,
1009                -1,
1010            )?;
1011            if attr_get(caster, crate::mechanics::stones::NEXT_ATTACK_CRIT_CHARGES) <= 1.0 {
1012                incr_attr(
1013                    sink,
1014                    caster.id,
1015                    crate::mechanics::stones::NEXT_ATTACK_CRIT,
1016                    -bonus,
1017                )?;
1018            }
1019        }
1020    }
1021
1022    let mut dmg_mod = 1.0;
1023    let is_crit = match params.is_crit {
1024        Some(v) => v,
1025        // Crit is the largest binary per-hit RNG (a ×2+ damage coin-flip);
1026        // `crit_chance_scale` (env `OVERLORD_BAL_CRIT_SCALE`) dials it for the
1027        // sigmoid-steepening experiments. 1.0 = shipped behavior.
1028        None => entropy_throw(
1029            rng,
1030            lookups,
1031            caster,
1032            "crit_chance",
1033            crit_chance_bonus,
1034            balance::tuning().crit_chance_scale,
1035        ),
1036    };
1037    if is_crit {
1038        dmg_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
1039    }
1040
1041    // Effect Stone `NextAttackDamageBonus`: a bonus armed by a trigger fire and
1042    // spent one charge at a time by the FIRST swing of each dispatch that
1043    // actually deals damage — the design is "the next N attacks +X%", counted in
1044    // attacks, so a multi-target ability boosts one target and not the whole
1045    // cone. `EF-C05` arms one charge, `EF-R06` three, `EF-E07` five.
1046    //
1047    // `claim_once` is what enforces the "first" (see its doc): `caster` is a
1048    // snapshot shared by every target of an AoE dispatch, so the read alone
1049    // cannot. This is also what keeps the charge count from being driven
1050    // negative by one consume per target, which would have swallowed the next
1051    // arms.
1052    //
1053    // Consumed through the sink (an `EntityIncrAttribute` — bookkeeping, no
1054    // provenance, no trigger surface) rather than by mutating the caster,
1055    // because the fight primitives only ever see `&Entity`. Placed after the
1056    // evasion gate so a dodged swing never spends a charge.
1057    if params.power.is_some() {
1058        // The stone bonus: charge-gated, one charge per dispatch.
1059        let stone_armed = crate::mechanics::stones::armed_magnitude(
1060            caster,
1061            crate::mechanics::stones::NEXT_ATTACK_BONUS,
1062            crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES,
1063        )
1064        .unwrap_or(0)
1065        .max(0);
1066        let stone_bonus = if stone_armed > 0 {
1067            sink.claim_once(OnceSlot::DamageBonus, stone_armed)
1068        } else {
1069            0
1070        };
1071        if stone_bonus > 0 {
1072            incr_attr(
1073                sink,
1074                caster.id,
1075                crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES,
1076                -1,
1077            )?;
1078            // Last charge: clear the magnitude too, so a stale bonus can never
1079            // apply for free once the charges run out.
1080            if attr_get(caster, crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES) <= 1.0 {
1081                incr_attr(
1082                    sink,
1083                    caster.id,
1084                    crate::mechanics::stones::NEXT_ATTACK_BONUS,
1085                    -stone_bonus,
1086                )?;
1087            }
1088        }
1089
1090        // The law arm for the family of THIS cast — `RL-02` ("next Basic
1091        // Attack") and `FL-02` ("next original Skill") keep their own keys and
1092        // the cast kind picks between them. One-shot, no charges: the first
1093        // swing of the dispatch that gets this far spends it whole.
1094        //
1095        // Two keys read through the cast kind, rather than one shared key the
1096        // families take turns borrowing: the borrow could only tell them apart
1097        // by the window it was open for, and a change on the stones side to
1098        // the shared key silently broke it once already.
1099        let law_arm_key = crate::logic::combat_facts::current_cast_kind(caster)
1100            .map(crate::mechanics::cores::law_arm_key);
1101        let law_armed = law_arm_key
1102            .map(|key| attr_get(caster, key) as i64)
1103            .unwrap_or(0)
1104            .max(0);
1105        let law_bonus = if law_armed > 0 {
1106            sink.claim_once(OnceSlot::LawArm, law_armed)
1107        } else {
1108            0
1109        };
1110        if let Some(key) = law_arm_key
1111            && law_bonus > 0
1112        {
1113            incr_attr(sink, caster.id, key, -law_bonus)?;
1114        }
1115
1116        // The "this strike" arm (`RL-01`). Deliberately NOT filtered by cast
1117        // kind: the law boosts whatever swing triggered it, basic or skill. Its
1118        // own latch, so a build carrying both a "this attack" and a "next
1119        // attack" law spends both.
1120        let this_armed = attr_get(caster, crate::mechanics::cores::ARM_THIS) as i64;
1121        let this_bonus = if this_armed > 0 {
1122            sink.claim_once(OnceSlot::LawThisArm, this_armed)
1123        } else {
1124            0
1125        };
1126        if this_bonus > 0 {
1127            incr_attr(
1128                sink,
1129                caster.id,
1130                crate::mechanics::cores::ARM_THIS,
1131                -this_bonus,
1132            )?;
1133        }
1134
1135        // Summed into one multiplier, which is what the borrow produced when it
1136        // moved the law value into the stone key.
1137        let bonus = stone_bonus + law_bonus + this_bonus;
1138        if bonus > 0 {
1139            dmg_mod *= 1.0 + bonus as f64 / 10000.0;
1140        }
1141    }
1142
1143    // BAL-033 removed the hidden 3-cycle class counter: a matchup modifier the
1144    // player could neither see nor plan around decided PvP fights on class
1145    // choice alone. Class identity now lives entirely in visible stats,
1146    // passives and kits.
1147
1148    let mut dmg_dealt = None;
1149    // A2-BAL-001: restore the measured pre-removal boss output after ALL
1150    // Attack/power/crit/law/stone composition. Stamping only real boss entities
1151    // makes every non-boss path byte-identical. The same multiplier is also
1152    // used for DoT below, so flat and over-time payload shares are not silently
1153    // omitted as they would be by scaling the base `attack` attribute.
1154    let outgoing_equivalence = match attr_get(caster, BOSS_OUTGOING_EQUIVALENCE_PPM_ATTR) {
1155        ppm if ppm > 0.0 => ppm / 1_000_000.0,
1156        _ => 1.0,
1157    };
1158
1159    if let Some(power) = params.power {
1160        let dmg = attack * power * dmg_mod * outgoing_equivalence;
1161        let mut data = CustomEventData::default();
1162        if is_crit {
1163            data.add("crit", 1);
1164        }
1165        if params.derived {
1166            data.add(DERIVED_DAMAGE_KEY, 1);
1167        }
1168        dmg_dealt = damage_entity(
1169            sink,
1170            rng,
1171            lookups,
1172            Some(caster.id),
1173            target,
1174            dmg * balance::DMG_K,
1175            data,
1176            source,
1177        )?;
1178    }
1179    if let Some(dot_power) = params.dot_power {
1180        apply_entity_over_time_effect(
1181            sink,
1182            target,
1183            attack * dot_power * dmg_mod * outgoing_equivalence * balance::DMG_K,
1184            "dot",
1185            source,
1186        )?;
1187    }
1188
1189    if caster.id == player_id && !params.derived {
1190        let power = params.power.unwrap_or(0.0);
1191        if is_crit || power > 3.5 {
1192            let _ = screen_shake(sink, 25);
1193        }
1194    }
1195    Ok(dmg_dealt)
1196}
1197
1198/// Typed form of the `spell_heal` params.
1199#[derive(Clone, Debug, Default)]
1200pub struct SpellHealParams {
1201    pub is_crit: Option<bool>,
1202    pub power: Option<f64>,
1203    pub hot_power: Option<f64>,
1204}
1205
1206impl SpellHealParams {
1207    pub fn default_power() -> Self {
1208        Self {
1209            power: Some(1.0),
1210            ..Default::default()
1211        }
1212    }
1213}
1214
1215/// Native: heal `target` from `caster`'s attack stat. Returns the applied heal
1216/// (`None` when no heal). Consumes `crit_chance` from `rng` (unless `is_crit` is
1217/// forced), then `heal_entity`'s path (no RNG).
1218pub fn spell_heal(
1219    sink: &mut dyn FightSink,
1220    rng: &GameRng,
1221    lookups: &ContentLookups,
1222    caster: &Entity,
1223    target: &Entity,
1224    params: &SpellHealParams,
1225    source: CombatSource,
1226) -> Result<Option<i64>, anyhow::Error> {
1227    let attack = get_entity_stat(lookups, caster, "attack");
1228    let mut heal_mod = 1.0;
1229    let is_crit = match params.is_crit {
1230        Some(v) => v,
1231        None => entropy_throw(
1232            rng,
1233            lookups,
1234            caster,
1235            "crit_chance",
1236            0.0,
1237            balance::tuning().crit_chance_scale,
1238        ),
1239    };
1240    if is_crit {
1241        heal_mod = 2.0 + get_entity_stat(lookups, caster, "crit_modifier") / 10000.0;
1242    }
1243    let mut heal_event = None;
1244    if let Some(power) = params.power {
1245        heal_event = heal_entity(
1246            sink,
1247            Some(caster.id),
1248            target,
1249            attack * power * heal_mod * balance::DMG_K,
1250            source,
1251        )?;
1252    }
1253    if let Some(hot_power) = params.hot_power {
1254        apply_entity_over_time_effect(
1255            sink,
1256            target,
1257            attack * hot_power * heal_mod * balance::DMG_K,
1258            "hot",
1259            source,
1260        )?;
1261    }
1262    Ok(heal_event)
1263}
1264
1265/// Native: apply `effect_code` to `target` for `duration_seconds` (converted to
1266/// ticks). Effect `on_apply` reactions are dispatched through `effects`.
1267pub fn apply_entity_effect(
1268    sink: &mut dyn FightSink,
1269    lookups: &ContentLookups,
1270    effects: &mut dyn EffectCb,
1271    target: &Entity,
1272    effect_code: &str,
1273    duration_seconds: f64,
1274) -> Result<(), anyhow::Error> {
1275    change_entity_effect_duration(
1276        sink,
1277        lookups,
1278        effects,
1279        target,
1280        effect_code,
1281        (duration_seconds * 1000.0).floor() as i64,
1282    )
1283}
1284
1285/// Native: add `duration` ticks of `effect_code` to `target` (capped at the
1286/// effect's `max_duration_ticks`), emitting `EntityApplyEffect` on first
1287/// application and dispatching the effect's `on_apply` reaction.
1288pub fn change_entity_effect_duration(
1289    sink: &mut dyn FightSink,
1290    lookups: &ContentLookups,
1291    effects: &mut dyn EffectCb,
1292    target: &Entity,
1293    effect_code: &str,
1294    duration: i64,
1295) -> Result<(), anyhow::Error> {
1296    let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
1297        Some(t) => t.clone(),
1298        None => {
1299            return Err(anyhow::anyhow!(
1300                "apply_entity_effect: unknown effect code {effect_code}"
1301            ));
1302        }
1303    };
1304
1305    let duration_attr = format!("effect.{effect_code}.duration");
1306    let current_duration_ticks = attr_get(target, &duration_attr) as i64;
1307    let max_duration = effect_tpl.max_duration_ticks.unwrap_or(5000);
1308    let new_duration = (current_duration_ticks + duration).min(max_duration);
1309    incr_attr(
1310        sink,
1311        target.id,
1312        &duration_attr,
1313        new_duration - current_duration_ticks,
1314    )?;
1315    if current_duration_ticks == 0 {
1316        push_event(
1317            sink,
1318            OverlordEventEntityApplyEffect {
1319                entity_id: target.id,
1320                effect_id: effect_tpl.id,
1321                origin: CombatEventOrigin::Core,
1322            },
1323        )?;
1324    }
1325    effects.on_apply(sink, &effect_tpl, target)?;
1326    Ok(())
1327}
1328
1329/// The ability a `CombatSource` names, if any.
1330fn source_ability(source: CombatSource) -> Option<Uuid> {
1331    match source {
1332        CombatSource::AbilityCast { ability_id }
1333        | CombatSource::AbilityDerived { ability_id }
1334        | CombatSource::PetUlt { ability_id } => Some(ability_id),
1335        _ => None,
1336    }
1337}
1338
1339/// Width of one chunk of the ability id an over-time schedule records. An
1340/// attribute is an `i64` that other code freely adds to, and the client reads
1341/// it back through JSON, so a chunk must stay both far from `i64::MAX` (a
1342/// rewrite is applied as a delta — full-width halves overflow the add) and
1343/// under 2^53 (exact as a double). 43 bits satisfies both and covers a 128-bit
1344/// UUID in three chunks.
1345const OVER_TIME_SRC_BITS: u32 = 43;
1346const OVER_TIME_SRC_CHUNKS: u32 = 3;
1347
1348/// Attributes carrying the ability that applied an over-time effect: the id's
1349/// [`OVER_TIME_SRC_CHUNKS`] chunks, all zero when no ability was named — the
1350/// tick then falls back to the generic [`CombatSource::Dot`] /
1351/// [`CombatSource::Hot`].
1352///
1353/// One set per kind, not per slot: the five slots accumulate every applier's
1354/// amounts into shared totals, so the last applier owns the whole schedule.
1355fn over_time_source_attr(kind: &str, chunk: u32) -> String {
1356    format!("effect.{kind}.src.{chunk}")
1357}
1358
1359/// Shift and value mask of one chunk. The last chunk is narrower than the rest
1360/// — 128 is not a multiple of [`OVER_TIME_SRC_BITS`] — and shifting a full-width
1361/// value into it would overflow the `u128`.
1362fn over_time_source_chunk(chunk: u32) -> (u32, u128) {
1363    let shift = OVER_TIME_SRC_BITS * chunk;
1364    let width = OVER_TIME_SRC_BITS.min(128 - shift);
1365    (shift, (1u128 << width) - 1)
1366}
1367
1368/// Record `source` as the owner of `target`'s `kind` schedule, clearing the
1369/// attribution when the applier names no ability.
1370fn set_over_time_source(
1371    sink: &mut dyn FightSink,
1372    target: &Entity,
1373    kind: &str,
1374    source: CombatSource,
1375) -> Result<(), anyhow::Error> {
1376    let bits = source_ability(source).map_or(0u128, |id| id.as_u128());
1377    for chunk in 0..OVER_TIME_SRC_CHUNKS {
1378        let (shift, mask) = over_time_source_chunk(chunk);
1379        let attr = over_time_source_attr(kind, chunk);
1380        let value = ((bits >> shift) & mask) as i64;
1381        let current = target.attributes.0.get(&attr).copied().unwrap_or(0);
1382        if current != value {
1383            incr_attr(sink, target.id, &attr, value - current)?;
1384        }
1385    }
1386    Ok(())
1387}
1388
1389/// The source an over-time tick of `kind` carries: the ability recorded by the
1390/// last applier, or `fallback` when none was.
1391pub fn over_time_tick_source(entity: &Entity, kind: &str, fallback: CombatSource) -> CombatSource {
1392    let mut bits = 0u128;
1393    for chunk in 0..OVER_TIME_SRC_CHUNKS {
1394        let (shift, mask) = over_time_source_chunk(chunk);
1395        let attr = over_time_source_attr(kind, chunk);
1396        // Masked, not trusted: an out-of-range chunk would overflow the shift.
1397        let value = entity.attributes.0.get(&attr).copied().unwrap_or(0).max(0) as u128 & mask;
1398        bits |= value << shift;
1399    }
1400    if bits == 0 {
1401        return fallback;
1402    }
1403    // An over-time tick is an extra hit produced out of the cast that applied
1404    // it, which is what `AbilityDerived` marks — it names the ability without
1405    // claiming the tick was a cast of its own.
1406    CombatSource::AbilityDerived {
1407        ability_id: Uuid::from_u128(bits),
1408    }
1409}
1410
1411/// Native: apply a damage-/heal-over-time effect (`kind` = `"dot"` / `"hot"`),
1412/// splitting `amount` over 5 ticks. No effect callbacks, no RNG.
1413pub fn apply_entity_over_time_effect(
1414    sink: &mut dyn FightSink,
1415    target: &Entity,
1416    amount: f64,
1417    kind: &str,
1418    source: CombatSource,
1419) -> Result<(), anyhow::Error> {
1420    let effect_id = if kind == "dot" {
1421        Uuid::parse_str(DOT_EFFECT_ID).unwrap()
1422    } else {
1423        Uuid::parse_str(HOT_EFFECT_ID).unwrap()
1424    };
1425    let per_tick = (amount / 5.0).floor() as i64;
1426    for i in 1..=5 {
1427        let tick_attr = format!("effect.{kind}.tick.{i}");
1428        incr_attr(sink, target.id, &tick_attr, per_tick)?;
1429    }
1430    set_over_time_source(sink, target, kind, source)?;
1431    let next_attr = format!("effect.{kind}.next");
1432    if !attr_present(target, &next_attr) {
1433        // set to 1 (current is 0)
1434        incr_attr(sink, target.id, &next_attr, 1)?;
1435        push_event(
1436            sink,
1437            OverlordEventEntityApplyEffect {
1438                entity_id: target.id,
1439                effect_id,
1440                origin: CombatEventOrigin::Core,
1441            },
1442        )?;
1443    }
1444    Ok(())
1445}
1446
1447/// Native: remove all stacks of `effect_code` from `target`, dispatching the
1448/// effect's `on_change` reaction with `(new_stacks=0, old_stacks=current)`.
1449pub fn remove_entity_effect(
1450    sink: &mut dyn FightSink,
1451    lookups: &ContentLookups,
1452    effects: &mut dyn EffectCb,
1453    target: &Entity,
1454    effect_code: &str,
1455) -> Result<(), anyhow::Error> {
1456    let effect_tpl: Arc<EffectTpl> = match lookups.effects_by_code.get(effect_code) {
1457        Some(t) => t.clone(),
1458        None => {
1459            return Err(anyhow::anyhow!(
1460                "remove_entity_effect: unknown effect code {effect_code}"
1461            ));
1462        }
1463    };
1464    let stacks_attr = format!("effect.{effect_code}.stacks");
1465    let current_stacks = attr_get(target, &stacks_attr) as i64;
1466    incr_attr(sink, target.id, &stacks_attr, -current_stacks)?;
1467    effects.on_change(sink, &effect_tpl, target, 0, current_stacks)?;
1468    Ok(())
1469}
1470
1471/// Native: emit the per-entity init events for a fight (regen effect, low-chapter
1472/// effect, party power adjustment, dungeon/boss talent attack mods). No RNG, no
1473/// effect callbacks. `fight` supplies player/party ids + the entity roster +
1474/// `party_adjusted_power`; reads talent levels / chapter from `state`.
1475#[allow(clippy::too_many_arguments)]
1476pub fn init_fight(
1477    sink: &mut dyn FightSink,
1478    lookups: &ContentLookups,
1479    fight: &ActiveFight,
1480    state: &OverlordState,
1481    fight_template_id: Uuid,
1482) -> Result<(), anyhow::Error> {
1483    let is_dungeon = lookups
1484        .fight_template_is_dungeon
1485        .get(&fight_template_id)
1486        .copied()
1487        .unwrap_or(false);
1488    let is_bossfight = lookups
1489        .fight_template_is_bossfight
1490        .get(&fight_template_id)
1491        .copied()
1492        .unwrap_or(false);
1493
1494    let player_id = fight.player_id;
1495    let party_player_id = fight.party_player_id;
1496    let entities = fight.entities.clone();
1497    let regen_effect = Uuid::parse_str(REGEN_EFFECT_ID).unwrap();
1498    let low_chapter_effect = Uuid::parse_str(LOW_CHAPTER_EFFECT_ID).unwrap();
1499    let dungeon_talent = Uuid::parse_str(DUNGEON_TALENT_ID).unwrap();
1500    let boss_talent = Uuid::parse_str(BOSS_TALENT_ID).unwrap();
1501
1502    for entity in &entities {
1503        if attr_present(entity, "regeneration_rate") {
1504            push_event(
1505                sink,
1506                OverlordEventEntityApplyEffect {
1507                    entity_id: entity.id,
1508                    effect_id: regen_effect,
1509                    origin: CombatEventOrigin::Core,
1510                },
1511            )?;
1512        }
1513
1514        let mut dungeon_level: Option<i64> = None;
1515        let mut boss_level: Option<i64> = None;
1516
1517        if entity.id == player_id {
1518            dungeon_level = state
1519                .character_state
1520                .talent_levels
1521                .get(&dungeon_talent)
1522                .copied();
1523            boss_level = state
1524                .character_state
1525                .talent_levels
1526                .get(&boss_talent)
1527                .copied();
1528            if state.character_state.character.current_chapter_level
1529                <= LOW_CHAPTER_EFFECT_MAX_CHAPTER
1530            {
1531                push_event(
1532                    sink,
1533                    OverlordEventEntityApplyEffect {
1534                        entity_id: entity.id,
1535                        effect_id: low_chapter_effect,
1536                        origin: CombatEventOrigin::Core,
1537                    },
1538                )?;
1539            }
1540        }
1541        if Some(entity.id) == party_player_id {
1542            let party = &state.party;
1543            if let Some(party_state) = &party.party_state {
1544                dungeon_level = party_state.talent_levels.get(&dungeon_talent).copied();
1545                boss_level = party_state.talent_levels.get(&boss_talent).copied();
1546                if let Some(adjusted) = party.party_adjusted_power {
1547                    let raw = party_state.character.power as f64;
1548                    if raw > 0.0 {
1549                        let adjust_k = adjusted as f64 / raw;
1550                        let stat_k = adjust_k.sqrt();
1551                        let current_attack = attr_get(entity, "attack");
1552                        let delta =
1553                            (current_attack * stat_k).floor() as i64 - current_attack as i64;
1554                        incr_attr(sink, entity.id, "attack", delta)?;
1555                        let new_max_hp = ((entity.hp as f64) * stat_k).floor() as u64;
1556                        push_event(
1557                            sink,
1558                            OverlordEventSetMaxHp {
1559                                entity_id: entity.id,
1560                                new_max_hp,
1561                                new_hp: new_max_hp,
1562                            },
1563                        )?;
1564                    }
1565                }
1566            }
1567        }
1568
1569        // Dungeon/boss talents grant +2%/talent-level attack. The bonus is
1570        // additive on top of the entity's other `attack.mod` sources (class
1571        // levels, the Strength talent, …) — a set-to-target write here would
1572        // wipe them for the whole fight. Both talents stack in a fight that is
1573        // simultaneously a dungeon and a bossfight. `init_fight` runs once per
1574        // fight, so the increment cannot double-apply.
1575        let mut talent_attack_mod = 0;
1576        if is_dungeon && let Some(level) = dungeon_level {
1577            talent_attack_mod += 200 * level;
1578        }
1579        if is_bossfight && let Some(level) = boss_level {
1580            talent_attack_mod += 200 * level;
1581        }
1582        if talent_attack_mod != 0 {
1583            incr_attr(sink, entity.id, "attack.mod", talent_attack_mod)?;
1584        }
1585    }
1586    Ok(())
1587}
1588
1589// ---------------------------------------------------------------------------
1590// spawn_wave & simulated_wave helpers
1591// ---------------------------------------------------------------------------
1592
1593/// Typed form of the `fight_data` prepare-fight blob fed to `spawn_wave`.
1594#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
1595pub struct WaveFightData {
1596    /// `entities[].{ entity_id, power }` — the per-template relative powers.
1597    pub entities: Vec<WaveEntityPower>,
1598    /// `waves[][].{ data: { entity_id, delay }, position }`.
1599    pub waves: Vec<Vec<WaveSpawn>>,
1600    /// `time` — the fight time budget used to normalise mob HP.
1601    pub time: f64,
1602    /// The per-fight reference power (`base_power` for `spawn_wave`).
1603    /// `None` mirrors a missing/unconfigured value (treated as 0.0 by callers).
1604    pub power: Option<f64>,
1605    /// Max enemies on the field at once (the death-gated exit cap, revived under
1606    /// the slot model — docs/growing-enemy-waves-plan.md). The first `cap` mobs
1607    /// of a wave exit on their timers; the rest park off-screen (`exit_gated`)
1608    /// and are released one per enemy kill, lowest queue position first.
1609    /// `None` (or cap ≥ wave size) = all exits on timers, previous behaviour
1610    /// bit-for-bit.
1611    pub stream_active_count: Option<u64>,
1612    /// Reward/weight budget (growing-enemy-waves plan §2): the fight's ORIGINAL
1613    /// total mob count. `spawn_wave` stamps `wave_share = reward_mob_budget /
1614    /// actual_total_mob_count` on every mob so per-kill drops / pet-ult charge /
1615    /// counters stay count-invariant. `None` ⇒ share 1.0 (legacy, bit-identical).
1616    pub reward_mob_budget: Option<u64>,
1617    /// Boss summon: when set, the LAST wave is a summon wave — spawned by the
1618    /// damage handler when a boss drops below this HP fraction, never by wave
1619    /// clear (see `PrepareFightWaves::summon_wave_at_hp_fraction`). Its slots
1620    /// anchor at the player's CURRENT column (no formation dash precedes it).
1621    pub summon_wave_at_hp_fraction: Option<f64>,
1622    /// Per-fight enemy damage multiplier: scales the fight's damage budget
1623    /// (`eff_hp`) only — HP, fight length and loot pacing unchanged.
1624    /// `None` ⇒ 1.0 (see `PrepareFightWaves::enemy_damage_mult`).
1625    pub enemy_damage_mult: Option<f64>,
1626}
1627
1628#[derive(Clone, Debug, PartialEq, serde::Serialize)]
1629pub struct WaveEntityPower {
1630    /// `None` mirrors an entry with no readable `entity_id`. The original
1631    /// `min_power` pass ignores `entity_id` entirely (so such an entry can still
1632    /// lower `min_power`), while the `entity_powers` pass skips it — both
1633    /// behaviours are preserved by keeping this optional.
1634    pub entity_id: Option<String>,
1635    /// `None` mirrors a missing/unreadable `power`. The original `min_power`
1636    /// pass skips a missing power; the `entity_powers` pass defaults it to 1.0.
1637    pub power: Option<f64>,
1638}
1639
1640#[derive(Clone, Debug, PartialEq, serde::Serialize)]
1641pub struct WaveSpawn {
1642    pub entity_id: String,
1643    /// Legacy stagger in SECONDS. Fully dead since the slot model: the exit
1644    /// director AND the balance mini-sim both run on `cooldown_seconds`
1645    /// (see `slot_arrival_delay_seconds`). Kept only to deserialize old
1646    /// config blobs.
1647    pub delay: Option<f64>,
1648    /// Legacy template position. The slot director only uses `y` as the row
1649    /// fallback for unmigrated content; `x` is dead since the slot model.
1650    pub position: Option<Coordinates>,
1651    /// Slot model: exit cooldown from wave start, seconds. `None` ⇒ 0.0 (the
1652    /// admin build drops zero optionals — see the config-side doc).
1653    pub cooldown_seconds: Option<f64>,
1654    /// Slot model: landing row (0..BATTLEFIELD_HEIGHT). `None` → position.y → 1.
1655    pub row: Option<i64>,
1656}
1657
1658#[derive(Clone, Debug)]
1659struct SimEnemy {
1660    eff_damage: f64,
1661    ttk: f64,
1662    delay: f64,
1663    /// Death-gated exit queue position (`Some` = parked until a kill releases
1664    /// it, lowest first). Mirrors the live `exit_gated` mechanic so the budget
1665    /// mini-sim models the closed loop instead of guessing a kill rate.
1666    gated_pos: Option<i64>,
1667    /// Marks the fight's boss so the summon-overlap path can watch its
1668    /// remaining ttk (the HP proxy) for the summon trigger.
1669    is_boss: bool,
1670}
1671
1672/// Death-gated exit plan for one wave under `stream_active_count` (the revived
1673/// streaming cap — docs/growing-enemy-waves-plan.md): the first `cap` mobs (by
1674/// exit cooldown, ties by config order) exit on their timers; every mob beyond
1675/// the cap waits parked and is released one-per-enemy-death in this order.
1676/// Returns, per spawn in CONFIG order, `None` for a timer exit or
1677/// `Some(queue_pos)` (1-based — attribute zero-drop safe) for a gated one.
1678/// `None` cap or `cap >= N` ⇒ all timers (previous behaviour bit-for-bit).
1679fn wave_exit_gate_plan(wave: &[WaveSpawn], cap: Option<u64>) -> Vec<Option<i64>> {
1680    let mut plan = vec![None; wave.len()];
1681    let Some(cap) = cap else {
1682        return plan;
1683    };
1684    let cap = cap.max(1) as usize;
1685    if wave.len() <= cap {
1686        return plan;
1687    }
1688    let mut order: Vec<usize> = (0..wave.len()).collect();
1689    order.sort_by_key(|&i| (spawn_exit_cooldown_ms(&wave[i]), i));
1690    for (queue_idx, &spawn_idx) in order[cap..].iter().enumerate() {
1691        plan[spawn_idx] = Some(queue_idx as i64 + 1);
1692    }
1693    plan
1694}
1695
1696/// The wave spawn's authored landing row (migrated `row`, legacy `position.y`,
1697/// default the middle), clamped to the battlefield.
1698fn spawn_authored_row(spawn: &WaveSpawn) -> i64 {
1699    spawn
1700        .row
1701        .or(spawn.position.as_ref().map(|p| p.y))
1702        .unwrap_or(1)
1703        .clamp(0, BATTLEFIELD_HEIGHT - 1)
1704}
1705
1706/// Balanced landing rows for a wave: the ACTIVE (non-gated) spawns are spread
1707/// across their column's rows in exit order so the opening group doesn't pile
1708/// onto one tile. Each mob keeps its authored row unless that row is already
1709/// busier than another (so a lone mob — a boss — never moves); ties prefer the
1710/// authored row, then the middle. Gated spawns keep their authored row as the
1711/// park row — their real row is picked at release time from live occupancy
1712/// (`gated_release_row`).
1713///
1714/// `live_enemy_cells` seeds the occupancy with enemies already alive — a summon
1715/// wave fires mid-fight, so reinforcements must spread AROUND the summoner
1716/// instead of landing on its tile (normal transitions fire on wave clear: no
1717/// survivors, the seed is a no-op). `is_summon_wave` also flips the row
1718/// preference to top → bottom → middle: summoned creeps flank, the middle stays
1719/// the summoner's stage.
1720fn wave_row_plan(
1721    wave: &[WaveSpawn],
1722    gate_plan: &[Option<i64>],
1723    config: &GameConfig,
1724    lookups: &ContentLookups,
1725    live_enemy_cells: &[(i64, i64)],
1726    anchor_x: i64,
1727    is_summon_wave: bool,
1728) -> Vec<i64> {
1729    let mut plan: Vec<i64> = wave.iter().map(spawn_authored_row).collect();
1730    let mut occupancy: std::collections::HashMap<i64, [i64; BATTLEFIELD_HEIGHT as usize]> =
1731        std::collections::HashMap::new();
1732    for &(x, y) in live_enemy_cells {
1733        let col = x - anchor_x;
1734        if (col == MELEE_COL_OFFSET || col == RANGED_COL_OFFSET)
1735            && (0..BATTLEFIELD_HEIGHT).contains(&y)
1736        {
1737            occupancy
1738                .entry(col)
1739                .or_insert([0; BATTLEFIELD_HEIGHT as usize])[y as usize] += 1;
1740        }
1741    }
1742    let mut order: Vec<usize> = (0..wave.len())
1743        .filter(|&i| gate_plan[i].is_none())
1744        .collect();
1745    order.sort_by_key(|&i| (spawn_exit_cooldown_ms(&wave[i]), i));
1746    for i in order {
1747        let col = match Uuid::parse_str(&wave[i].entity_id) {
1748            Ok(tpl) if template_has_melee_ability(config, lookups, tpl) => MELEE_COL_OFFSET,
1749            Ok(_) => RANGED_COL_OFFSET,
1750            Err(_) => continue,
1751        };
1752        let counts = occupancy
1753            .entry(col)
1754            .or_insert([0; BATTLEFIELD_HEIGHT as usize]);
1755        let cands: [i64; 4] = if is_summon_wave {
1756            [0, 2, plan[i], 1]
1757        } else {
1758            [plan[i], 1, 0, 2]
1759        };
1760        let mut best = cands[0];
1761        for cand in cands {
1762            if counts[cand as usize] < counts[best as usize] {
1763                best = cand;
1764            }
1765        }
1766        plan[i] = best;
1767        counts[best as usize] += 1;
1768    }
1769    plan
1770}
1771
1772/// Row for a death-gated mob at RELEASE time: the least-occupied row of its
1773/// own column, counting living enemies standing on it, running to it, or
1774/// parked pending arrival at the same park column (exit-cooldown waiters and
1775/// same-tick earlier releases — their park `y` IS their landing row). Ties
1776/// prefer the mob's authored row, then the middle — so the freed space is
1777/// taken instead of stacking onto survivors.
1778pub fn gated_release_row(fight: &ActiveFight, released_id: Uuid, entrance_offset: i64) -> i64 {
1779    let Some(released) = fight.entities.iter().find(|e| e.id == released_id) else {
1780        return 1;
1781    };
1782    let park_x = released.coordinates.x;
1783    let target_x = park_x - entrance_offset;
1784    let authored_row = released.coordinates.y.clamp(0, BATTLEFIELD_HEIGHT - 1);
1785    let mut counts = [0i64; BATTLEFIELD_HEIGHT as usize];
1786    for e in &fight.entities {
1787        if e.id == released_id
1788            || e.team != EntityTeam::Enemy
1789            || e.hp == 0
1790            || e.attributes.0.contains_key("exit_gated")
1791        {
1792            continue;
1793        }
1794        let (ex, ey) = e
1795            .move_target
1796            .as_ref()
1797            .map(|t| (t.x, t.y))
1798            .unwrap_or((e.coordinates.x, e.coordinates.y));
1799        if (ex == target_x || ex == park_x) && (0..BATTLEFIELD_HEIGHT).contains(&ey) {
1800            counts[ey as usize] += 1;
1801        }
1802    }
1803    let mut best = authored_row;
1804    for cand in [authored_row, 1, 0, 2] {
1805        if counts[cand as usize] < counts[best as usize] {
1806            best = cand;
1807        }
1808    }
1809    best
1810}
1811
1812/// A spawn's exit cooldown in ms (`None` ⇒ 0: the admin build drops zero
1813/// optionals, absent IS the zero cooldown). Single source for the balance
1814/// mini-sim and the spawn emission below — they must agree on the opening
1815/// (min-cooldown) group.
1816fn spawn_exit_cooldown_ms(spawn: &WaveSpawn) -> i64 {
1817    (spawn.cooldown_seconds.unwrap_or(0.0).max(0.0) * 1000.0) as i64
1818}
1819
1820/// When a mob actually starts fighting, in SECONDS, relative to the moment the
1821/// ally side is ready — the input the balance mini-sim needs under the slot
1822/// model. Mirrors `handle_spawn_entity`'s exit scheduling exactly:
1823/// exit = `max(exit_cooldown_ms, floor)` counted from the spawn batch, where
1824/// `floor` is `start_fight_delay_ticks` for wave 1 (the hero plants at
1825/// StartFight) and `formation_advance_ticks` for waves 2+ (the formation dash
1826/// lands) — i.e. the ally side becomes ready exactly at `floor`, so relative
1827/// arrival = `max(cooldown − floor, 0)` + the entrance run
1828/// (`wave_entrance_offset_cells` × rush/walk tempo; the wave's min-cooldown
1829/// group rushes, everyone else walks). During the run the mob cannot act
1830/// (movement gates casting), so its fighting window opens at landing.
1831fn slot_arrival_delay_seconds(
1832    fight_settings: &configs::fighting::FightSettings,
1833    cooldown_ms: i64,
1834    wave_min_cooldown_ms: i64,
1835    floor_ms: i64,
1836) -> f64 {
1837    let ms_per_cell = if cooldown_ms == wave_min_cooldown_ms {
1838        fight_settings.wave_entrance_rush_ms_per_cell
1839    } else {
1840        fight_settings.wave_entrance_walk_ms_per_cell
1841    };
1842    let run_ms = fight_settings.wave_entrance_offset_cells.max(0) * ms_per_cell as i64;
1843    ((cooldown_ms - floor_ms).max(0) + run_ms) as f64 / 1000.0
1844}
1845
1846fn sim_wave_choose_next_target(enemies: &[SimEnemy]) -> Option<usize> {
1847    let mut fastest = 1800.0f64;
1848    let mut idx = None;
1849    for (i, e) in enemies.iter().enumerate() {
1850        if e.gated_pos.is_some() {
1851            continue; // parked behind the death gate: not arrived, not targetable
1852        }
1853        if e.delay <= 0.0 && e.ttk < fastest {
1854            idx = Some(i);
1855            // Track the lowest time-to-kill among the arrived enemies so we
1856            // pick the fastest-dying target. (The original legacy script stored
1857            // `enemy.delay` here — always 0 for arrived enemies — which pinned
1858            // `fastest` to 0 and made the loop always keep the FIRST arrived
1859            // enemy regardless of ttk; corrected to `e.ttk`.)
1860            fastest = e.ttk;
1861        }
1862    }
1863    idx
1864}
1865
1866fn sim_wave_choose_sim_time(enemies: &[SimEnemy], current_target: Option<usize>) -> f64 {
1867    let mut next_step = 1800.0f64;
1868    for e in enemies {
1869        if e.gated_pos.is_none() && e.delay > 0.0 && e.delay < next_step {
1870            next_step = e.delay;
1871        }
1872    }
1873    if let Some(idx) = current_target
1874        && let Some(t) = enemies.get(idx)
1875        && t.ttk < next_step
1876    {
1877        next_step = t.ttk;
1878    }
1879    next_step
1880}
1881
1882fn sim_wave_advance(
1883    enemies: &mut Vec<SimEnemy>,
1884    mob_damage: &mut f64,
1885    gated_release_run: f64,
1886    step_cap: Option<f64>,
1887) {
1888    let target = sim_wave_choose_next_target(enemies);
1889    let mut time = sim_wave_choose_sim_time(enemies, target);
1890    // The summon-overlap path caps the step at the boss's trigger crossing so
1891    // the splice lands mid-kill instead of after the boss's one-step death.
1892    if let Some(cap) = step_cap {
1893        time = time.min(cap);
1894    }
1895    let mut killed = false;
1896    if let Some(t) = target {
1897        // Total per-second damage of the arrived enemies. Matches the shipped
1898        // legacy `reduce(|sum| sum + this.eff_damage, 0)` script — in that
1899        // language's array closures `this` binds to the CURRENT ELEMENT, so the
1900        // original did compute this exact per-enemy sum (the #2046 note calling
1901        // it broken misread that binding; the code below was already parity).
1902        let dmg: f64 = enemies
1903            .iter()
1904            .filter(|e| e.delay <= 0.0 && e.gated_pos.is_none())
1905            .map(|e| e.eff_damage)
1906            .sum();
1907        *mob_damage += dmg * time;
1908        enemies[t].ttk -= time;
1909        if enemies[t].ttk <= 0.0 {
1910            enemies.remove(t);
1911            killed = true;
1912        }
1913    }
1914    for e in enemies.iter_mut() {
1915        if e.gated_pos.is_none() && e.delay > 0.0 {
1916            e.delay = (e.delay - time).max(0.0);
1917        }
1918    }
1919    // Mirror the live death-gated release: a kill frees the lowest queue
1920    // position, which then runs in (walk tempo) before fighting. Released
1921    // AFTER this step's delay decrement: the kill lands at the END of the
1922    // step, so the walk-in must consume time in the FOLLOWING steps.
1923    // (Releasing before the decrement swallowed up to a whole run per kill,
1924    // modeling gated waves as overlapping more than they do → under-tuned.)
1925    if killed
1926        && let Some(next) = enemies
1927            .iter_mut()
1928            .filter(|e| e.gated_pos.is_some())
1929            .min_by_key(|e| e.gated_pos)
1930    {
1931        next.gated_pos = None;
1932        next.delay = gated_release_run;
1933    }
1934}
1935
1936/// Native: the `prepare_fight` interpreter. Computes normalised enemy stats from
1937/// the chapter power curve + the typed wave blob, then emits spawn events for the
1938/// current wave. RNG is consumed once per spawned enemy (the spawn-entity uuid),
1939/// in iteration order — identical to the original. `fight` supplies
1940/// `current_wave` + the live `entities` (for the multi-wave x-offset).
1941#[allow(clippy::too_many_arguments)]
1942pub fn spawn_wave(
1943    sink: &mut dyn FightSink,
1944    rng: &GameRng,
1945    config: &GameConfig,
1946    lookups: &ContentLookups,
1947    fight: &ActiveFight,
1948    fight_data: &WaveFightData,
1949    base_power: f64,
1950    current_chapter: i64,
1951    fight_type: &str,
1952) -> Result<(), anyhow::Error> {
1953    const POWER_EFF_HP: f64 = 0.75;
1954    const POWER_ATTACK: f64 = 1.0 - POWER_EFF_HP;
1955    // Summoned reinforcements keep only this fraction of their power-law
1956    // attack weight (HP untouched — the detour to kill them is real). The
1957    // `power^0.25` law makes even 0.05-power adds hit at ~half the boss's
1958    // rate, so an un-carved summon phase nearly DOUBLES concurrent DPS at
1959    // the tail of an on-the-edge fight — same total budget, unsurvivable
1960    // shape. Carving keeps the post-summon phase's rate near the boss-alone
1961    // steady rate; the normalization redistributes the carved share.
1962    const SUMMON_ATTACK_SHARE: f64 = 0.15;
1963
1964    // Campaign enemy power follows the geometric curve past the hand-made
1965    // chapters, via `balance::enemy_power_scalar` — shared with the battle-end
1966    // analytics so the report stamps exactly what spawned. The `eff` divide below
1967    // is float (no integer truncation).
1968    let is_dungeon = lookups
1969        .fight_template_is_dungeon
1970        .get(&fight.fight_id)
1971        .copied()
1972        .unwrap_or(false);
1973    let power = balance::enemy_power_scalar(base_power, current_chapter, fight_type, is_dungeon);
1974
1975    let hp_k = balance::hp_k_for_chapter(config, current_chapter);
1976    let eff = power / balance::BASE_POWER as f64;
1977    let mut eff_hp = balance::BASE_HP * (eff * hp_k).powf(0.5);
1978    let mut eff_attack = balance::BASE_ATTACK * (eff / hp_k).powf(0.5);
1979
1980    // Two independent wave-budget knobs: `wave_damage_skew` scales the wave's
1981    // damage normalization (lethality / hp_end pressure); `wave_hp_skew`
1982    // shrinks the wave's HP budget (kill-time / DPS check). See the field docs
1983    // on `BalanceTuning` for the break-even/tempo law.
1984    eff_hp *= balance::tuning().wave_damage_skew;
1985    eff_attack /= balance::tuning().wave_hp_skew;
1986    // Per-fight damage-budget multiplier (`enemy_damage_mult` on the template):
1987    // compensates fight shapes whose sustained concurrency makes the same
1988    // total damage less survivable. Damage only — HP/length/loot unchanged.
1989    eff_hp *= fight_data.enemy_damage_mult.unwrap_or(1.0);
1990
1991    let player_dps = eff_attack * balance::BASE_SPELL_EFF * balance::DMG_K;
1992
1993    // `min_power` over all readable powers (the original ignored `entity_id`
1994    // here, so an entry with a power but no id can still lower the minimum).
1995    let mut min_power: Option<f64> = None;
1996    for ent in &fight_data.entities {
1997        if let Some(p) = ent.power {
1998            min_power = Some(min_power.map(|mp| mp.min(p)).unwrap_or(p));
1999        }
2000    }
2001    let min_power = min_power.unwrap_or(1.0);
2002
2003    // `entity_powers` keyed by id (entries with no id are skipped); a missing
2004    // power defaults to 1.0.
2005    let mut entity_powers: std::collections::HashMap<String, f64> =
2006        std::collections::HashMap::new();
2007    for ent in &fight_data.entities {
2008        let Some(id) = &ent.entity_id else {
2009            continue;
2010        };
2011        let p = ent.power.unwrap_or(1.0);
2012        entity_powers.insert(id.clone(), p / min_power);
2013    }
2014
2015    let mut mobs_eff_hp = 0.0f64;
2016    for wave in &fight_data.waves {
2017        for spawn in wave {
2018            let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
2019            mobs_eff_hp += enemy_power.powf(POWER_EFF_HP);
2020        }
2021    }
2022
2023    let time = fight_data.time;
2024    let player_damage = player_dps * time;
2025    let mob_hp_norm = if mobs_eff_hp > 0.0 {
2026        player_damage / mobs_eff_hp
2027    } else {
2028        0.0
2029    };
2030    let mob_norm_ttk = if player_dps > 0.0 {
2031        mob_hp_norm / player_dps
2032    } else {
2033        0.0
2034    };
2035
2036    // The mini-sim's join times come from the SLOT-MODEL timeline (exit
2037    // cooldowns + floors + entrance runs), not the legacy `delay` field — the
2038    // slot director retired `delay` as a timing input, and budgeting from it
2039    // made real mob damage start later than the sim assumed (waves easier
2040    // than their `time` budget). Floors mirror `handle_spawn_entity`.
2041    let wave1_floor_ms = config
2042        .require_fight_template(fight.fight_id)
2043        .ok()
2044        .and_then(|t| t.start_fight_delay_ticks)
2045        .unwrap_or(config.fight_settings.start_fight_delay_ticks_default)
2046        as i64;
2047    let later_floor_ms = later_wave_entrance_floor_ticks(config, fight) as i64;
2048
2049    let mut overall_damage = 0.0f64;
2050    // A death-released mob walks in (never rushes) — its fighting window
2051    // opens one walk-run after the kill that freed it.
2052    let gated_release_run = (config.fight_settings.wave_entrance_offset_cells.max(0)
2053        * config.fight_settings.wave_entrance_walk_ms_per_cell as i64)
2054        as f64
2055        / 1000.0;
2056    // NOTE: the roster keeps FULL power-law attack weights even for summon
2057    // waves. The `SUMMON_ATTACK_SHARE` carve applies ONLY to the spawned
2058    // stats below — if it were modeled here too, the normalization would
2059    // compensate by raising the whole wave's (incl. the boss's) attack to
2060    // still meet the budget, transferring the carved damage INTO the boss
2061    // (observed: Ch1-09 Boss 34/35 lost). Keeping the model at full weight
2062    // means real total damage lands slightly UNDER budget for summon
2063    // fights — that under-run is the survivability the carve buys.
2064    let build_wave_roster = |wave: &[WaveSpawn], floor_ms: i64| -> Vec<SimEnemy> {
2065        let wave_min_cooldown_ms = wave.iter().map(spawn_exit_cooldown_ms).min().unwrap_or(0);
2066        let gate_plan = wave_exit_gate_plan(wave, fight_data.stream_active_count);
2067        let mut sim_enemies: Vec<SimEnemy> = Vec::new();
2068        for (spawn_idx, spawn) in wave.iter().enumerate() {
2069            let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
2070            let gated_pos = gate_plan[spawn_idx];
2071            let delay = if gated_pos.is_some() {
2072                0.0 // unused while gated; set to the release run on release
2073            } else {
2074                slot_arrival_delay_seconds(
2075                    &config.fight_settings,
2076                    spawn_exit_cooldown_ms(spawn),
2077                    wave_min_cooldown_ms,
2078                    floor_ms,
2079                )
2080            };
2081            let is_boss = Uuid::parse_str(&spawn.entity_id)
2082                .ok()
2083                .and_then(|u| lookups.entity_template_is_boss.get(&u).copied())
2084                .unwrap_or(false);
2085            sim_enemies.push(SimEnemy {
2086                eff_damage: enemy_power.powf(POWER_ATTACK),
2087                ttk: mob_norm_ttk * enemy_power.powf(POWER_EFF_HP),
2088                delay,
2089                gated_pos,
2090                is_boss,
2091            });
2092        }
2093        sim_enemies
2094    };
2095
2096    // Boss-summon fights: the LAST wave really spawns the moment the boss drops
2097    // below `summon_wave_at_hp_fraction` of max HP (`logic/fighting.rs`),
2098    // OVERLAPPING the boss — while the player detours to kill the
2099    // reinforcements, the boss keeps attacking. Modeling it as a sequential
2100    // wave (the pre-fix behaviour) omitted that extended boss uptime from the
2101    // damage budget, over-tuning exactly the on-the-edge stage bosses. Here the
2102    // boss wave's sim is capped at the trigger point and the summon roster is
2103    // spliced in, so the normalization sees the true timeline.
2104    let summon_frac = fight_data.summon_wave_at_hp_fraction;
2105    let n_waves = fight_data.waves.len();
2106    let summon_overlap = summon_frac.filter(|_| {
2107        n_waves >= 2
2108            && fight_data.waves[..n_waves - 1]
2109                .iter()
2110                .any(|w| build_wave_roster(w, 0).iter().any(|e| e.is_boss))
2111    });
2112
2113    if let Some(frac) = summon_overlap {
2114        for (sim_wave_idx, wave) in fight_data.waves[..n_waves - 1].iter().enumerate() {
2115            let floor_ms = if sim_wave_idx == 0 {
2116                wave1_floor_ms
2117            } else {
2118                later_floor_ms
2119            };
2120            let mut sim_enemies = build_wave_roster(wave, floor_ms);
2121            let boss_orig_ttk = sim_enemies
2122                .iter()
2123                .find(|e| e.is_boss)
2124                .map(|e| e.ttk)
2125                .unwrap_or(0.0);
2126            // Only the wave that hosts the boss receives the summon splice.
2127            let mut summon_pending = boss_orig_ttk > 0.0;
2128            let mut mob_damage = 0.0f64;
2129            let mut iter = 0;
2130            while !sim_enemies.is_empty() && iter < 800 {
2131                iter += 1;
2132                // Remaining boss ttk is an exact HP proxy (ttk ∝ hp at the
2133                // reference DPS). Cap the step at the trigger crossing so the
2134                // splice lands mid-kill, not after the boss's one-step death.
2135                let boss_margin = sim_enemies
2136                    .iter()
2137                    .find(|e| e.is_boss)
2138                    .map(|b| b.ttk - frac * boss_orig_ttk);
2139                if summon_pending {
2140                    match boss_margin {
2141                        Some(margin) if margin <= 1e-9 => {
2142                            summon_pending = false;
2143                            // Summon entrances have no formation floor — they
2144                            // fire mid-fight at the player's current column.
2145                            sim_enemies
2146                                .extend(build_wave_roster(&fight_data.waves[n_waves - 1], 0));
2147                        }
2148                        // In-model boss death without crossing can't happen
2149                        // (the crossing precedes ttk 0); guard for safety.
2150                        None => summon_pending = false,
2151                        _ => {}
2152                    }
2153                }
2154                let step_cap = if summon_pending {
2155                    boss_margin.map(|m| m.max(1e-6))
2156                } else {
2157                    None
2158                };
2159                sim_wave_advance(
2160                    &mut sim_enemies,
2161                    &mut mob_damage,
2162                    gated_release_run,
2163                    step_cap,
2164                );
2165            }
2166            overall_damage += mob_damage;
2167        }
2168    } else {
2169        for (sim_wave_idx, wave) in fight_data.waves.iter().enumerate() {
2170            let floor_ms = if sim_wave_idx == 0 {
2171                wave1_floor_ms
2172            } else {
2173                later_floor_ms
2174            };
2175            let mut sim_enemies = build_wave_roster(wave, floor_ms);
2176            let mut mob_damage = 0.0f64;
2177            let mut iter = 0;
2178            while !sim_enemies.is_empty() && iter < 400 {
2179                iter += 1;
2180                sim_wave_advance(&mut sim_enemies, &mut mob_damage, gated_release_run, None);
2181            }
2182            overall_damage += mob_damage;
2183        }
2184    }
2185
2186    let mob_dps_norm = if overall_damage > 0.0 {
2187        eff_hp / overall_damage
2188    } else {
2189        0.0
2190    };
2191    let mob_attack_norm = mob_dps_norm / balance::BASE_SPELL_EFF / balance::DMG_K;
2192
2193    let wave_idx = (fight.current_wave - 1) as usize;
2194
2195    let Some(current_wave) = fight_data.waves.get(wave_idx) else {
2196        return Ok(());
2197    };
2198
2199    // Slot-model anchor (docs/combat-grid-migration-plan.md §2.1): the enemy
2200    // columns hang off the player's column. Advancing waves 2+ use the player's
2201    // landing column; stationary dungeons keep the current column.
2202    let player_x = fight
2203        .entities
2204        .iter()
2205        .find(|e| e.id == fight.player_id)
2206        .map(|e| e.coordinates.x)
2207        .unwrap_or(0);
2208    // A summon wave (the LAST wave when `summon_wave_at_hp_fraction` is set)
2209    // anchors at the player's CURRENT column: it fires mid-boss-fight with no
2210    // formation dash preceding it, so the +FORMATION_ADVANCE_CELLS anchor of a
2211    // normal wave transition would land its slots out of everyone's reach.
2212    let is_summon_wave =
2213        fight_data.summon_wave_at_hp_fraction.is_some() && wave_idx + 1 == fight_data.waves.len();
2214    let advances_formation = between_wave_behavior(config, fight).advance_formation;
2215    let anchor_x = if wave_idx > 0 && !is_summon_wave && advances_formation {
2216        player_x + FORMATION_ADVANCE_CELLS
2217    } else {
2218        player_x
2219    };
2220
2221    // Opening group = the wave's minimum exit cooldown; it keeps the rush
2222    // entrance pace (the lab opening-echelon feel), later exits walk.
2223    let min_cooldown_ms = current_wave
2224        .iter()
2225        .map(spawn_exit_cooldown_ms)
2226        .min()
2227        .unwrap_or(0);
2228
2229    // Death-gated exits (revived streaming cap): everyone beyond
2230    // `stream_active_count` parks with a queue position instead of a timer;
2231    // `handle_entity_death` releases them one per enemy kill.
2232    let gate_plan = wave_exit_gate_plan(current_wave, fight_data.stream_active_count);
2233
2234    // Balanced landing rows for the opening group (gated mobs re-pick theirs
2235    // from live occupancy at release). Living survivors (summon waves fire
2236    // mid-fight) seed the plan so reinforcements never land on the summoner.
2237    let live: Vec<(i64, i64)> = fight
2238        .entities
2239        .iter()
2240        .filter(|e| e.team == EntityTeam::Enemy && e.hp > 0)
2241        .map(|e| {
2242            let c = e.move_target.as_ref().unwrap_or(&e.coordinates);
2243            (c.x, c.y)
2244        })
2245        .collect();
2246    let row_plan = wave_row_plan(
2247        current_wave,
2248        &gate_plan,
2249        config,
2250        lookups,
2251        &live,
2252        anchor_x,
2253        is_summon_wave,
2254    );
2255
2256    // §2 wave_share: reward/weight per mob = reward_mob_budget / actual total
2257    // mob count of the fight (all waves), stored per-10000 (like crit_chance).
2258    // Stamped on every mob so per-kill drops / pet-ult charge / counter procs
2259    // stay count-invariant when the migrator inflates concurrency. `None`
2260    // budget ⇒ no attribute (absent = 1.0, legacy content bit-identical). No
2261    // RNG draws.
2262    let total_mob_count: usize = fight_data.waves.iter().map(|w| w.len()).sum();
2263    let wave_share_per10000: Option<i64> = fight_data.reward_mob_budget.map(|budget| {
2264        if total_mob_count == 0 {
2265            10000
2266        } else {
2267            ((budget as f64 / total_mob_count as f64) * 10000.0).round() as i64
2268        }
2269    });
2270
2271    let mut spawn_evts = Vec::new();
2272    for (spawn_idx, spawn) in current_wave.iter().enumerate() {
2273        let enemy_tpl_id = match Uuid::parse_str(&spawn.entity_id) {
2274            Ok(u) => u,
2275            Err(_) => continue,
2276        };
2277        let enemy_power = entity_powers.get(&spawn.entity_id).copied().unwrap_or(1.0);
2278        let enemy_hp_norm = enemy_power.powf(POWER_EFF_HP);
2279        // Mirror of the mini-sim's summon carve (`SUMMON_ATTACK_SHARE`) —
2280        // model and spawned stats must agree or the budget drifts. Gated on
2281        // `summon_overlap` (boss-hosted summon fights), the exact condition
2282        // under which the model carved.
2283        let enemy_attack_norm = enemy_power.powf(POWER_ATTACK)
2284            * if is_summon_wave && summon_overlap.is_some() {
2285                SUMMON_ATTACK_SHARE
2286            } else {
2287                1.0
2288            };
2289        // A2-BAL-002 §2.5: the two difficulty axes are steered independently.
2290        //
2291        // Above, one scalar becomes both HP and Attack through fixed exponents.
2292        // These two curves are what let a refit move encounter HP without
2293        // touching outgoing damage, or the reverse — the correction matrix in
2294        // §2.7 is written entirely in terms of that separation. Both remain
2295        // identity through the chapter-21 gate; the all-free refit authors
2296        // independent HP/outgoing knots only after that seam.
2297        let mut enemy_hp = enemy_hp_norm * mob_hp_norm * balance::k_hp(current_chapter);
2298        let mut enemy_attack =
2299            enemy_attack_norm * mob_attack_norm * balance::k_out(current_chapter);
2300
2301        let has_big_hp_bar = lookups
2302            .entity_template_is_boss
2303            .get(&enemy_tpl_id)
2304            .copied()
2305            .unwrap_or(false);
2306
2307        let mut attrs = EntityAttributes::default();
2308        // Bosses carry crit + armor for
2309        // combat TEXTURE, budget-neutrally — the scalar factors these stats
2310        // multiply are carved back out of attack/hp, so boss power (and the
2311        // co-derived E-curve calibration) is unchanged: crit spikes come out
2312        // of steady attack (same DPS), armor mitigation comes out of the HP
2313        // bar (same EHP, same TTK both ways). Entropy resolution makes boss
2314        // crits RHYTHMIC (one per 1/chance hits) — a telegraphed spike, not a
2315        // streak lottery.
2316        if has_big_hp_bar {
2317            let t = balance::tuning();
2318            let crit = t.boss_crit_chance;
2319            if crit > 0.0 {
2320                attrs.add("crit_chance", crit as i64);
2321                enemy_attack /= 1.0 + (crit / 10000.0).clamp(0.0, 1.0);
2322            }
2323            let armor = t.boss_armor;
2324            if armor > 0.0 {
2325                attrs.add("armor", armor as i64);
2326                enemy_hp *= balance::armor_k(armor);
2327            }
2328            // A2-BAL-001: hidden automatic stun remains removed. Preserve the
2329            // actual previously shipped outgoing budget independently by
2330            // stamping the frozen trace ratio. `attack` consumes it only after
2331            // composing the hit, which covers Attack-derived, flat and DoT
2332            // payload shares without restoring any control mechanic.
2333            attrs.add(
2334                BOSS_OUTGOING_EQUIVALENCE_PPM_ATTR,
2335                (balance::boss_outgoing_equivalence(current_chapter) * 1_000_000.0).round() as i64,
2336            );
2337        }
2338        attrs.add("attack", enemy_attack.floor() as i64);
2339        attrs.add("hp", enemy_hp.floor() as i64);
2340        attrs.add("speed", 10000);
2341
2342        let cooldown_ms = spawn_exit_cooldown_ms(spawn);
2343        // Column by attack type (§2.1/§2.6): melee (any range ≤ 1 ability)
2344        // fights from the near column, pure-ranged from the far one. Row from
2345        // the balanced plan (authored row, spread within the column so the
2346        // opening group doesn't stack on one tile); stacking is still legal —
2347        // the client scatters whatever remains.
2348        let col_offset = if template_has_melee_ability(config, lookups, enemy_tpl_id) {
2349            MELEE_COL_OFFSET
2350        } else {
2351            RANGED_COL_OFFSET
2352        };
2353        let row = row_plan[spawn_idx];
2354        // Spawn beyond the battle slot (off-screen edge): handle_spawn_entity
2355        // schedules the exit run back onto the slot at the unit's cooldown.
2356        let position = Coordinates {
2357            x: anchor_x + col_offset + config.fight_settings.wave_entrance_offset_cells,
2358            y: row,
2359        };
2360        // NB: a zero value never materializes as an attribute
2361        // (EntityAttributes::add drops zeros) — absent IS the zero cooldown,
2362        // and handle_spawn_entity reads it via unwrap_or(0).
2363        if let Some(queue_pos) = gate_plan[spawn_idx] {
2364            // Death-gated: no timer, no rush — the mob parks off-screen and
2365            // `handle_entity_death` schedules its (walk-tempo) entrance when a
2366            // kill frees its queue position (1-based, zero-drop safe).
2367            attrs.add("exit_gated", queue_pos);
2368        } else {
2369            attrs.add("exit_cooldown_ms", cooldown_ms);
2370            if cooldown_ms == min_cooldown_ms {
2371                attrs.add("entrance_rush", 1);
2372            }
2373        }
2374        // §2: stamp reward/weight share (drop-zero pattern; absent ⇒ 1.0).
2375        if let Some(ws) = wave_share_per10000 {
2376            attrs.add("wave_share", ws);
2377        }
2378        // Boss-summon: reinforcements from a summon wave carry NO economy weight
2379        // (drops, pet-ult charge, counters — all keyed off `wave_share()`, which
2380        // returns 0 for a `summoned` mob — plus kill-quest ticks). The boss keeps
2381        // its own full, unscaled payout, so the boss fight's faucet stays flat.
2382        if is_summon_wave {
2383            attrs.add("summoned", 1);
2384        }
2385
2386        // The spawn id is drawn from the RNG stream in config order — the
2387        // stream must stay stable across content edits that only touch timing.
2388        let spawn_id = uuid::Builder::from_random_bytes(rng.random_bytes()).into_uuid();
2389
2390        let spawn_evt = OverlordEventSpawnEntity {
2391            id: spawn_id,
2392            entity_template_id: enemy_tpl_id,
2393            position,
2394            entity_team: EntityTeam::Enemy,
2395            has_big_hp_bar,
2396            entity_attributes: attrs,
2397        };
2398        spawn_evts.push(spawn_evt);
2399    }
2400
2401    // BAL-026: stamp each mob's share of the wave's initial HP budget — the
2402    // outgoing damage gauge's normalizer. The denominator is fixed at this
2403    // spawn snapshot and never re-normalized by deaths. A summon wave stays
2404    // unstamped: `summoned` already reads as share 0.
2405    if !is_summon_wave {
2406        let total_hp: i64 = spawn_evts
2407            .iter()
2408            .map(|evt| {
2409                evt.entity_attributes
2410                    .0
2411                    .get("hp")
2412                    .copied()
2413                    .unwrap_or(0)
2414                    .max(0)
2415            })
2416            .sum();
2417        if total_hp > 0 {
2418            for evt in &mut spawn_evts {
2419                let hp = evt.entity_attributes.0.get("hp").copied().unwrap_or(0);
2420                let share = (hp.max(0) as f64 / total_hp as f64 * 10_000.0).round() as i64;
2421                evt.entity_attributes.add("gauge_hp_share", share);
2422            }
2423        } else {
2424            // BAL-026: a wave with no HP budget is an invalid fixture and must
2425            // be worth ZERO outgoing gauge. Stamp explicit zeros — unstamped
2426            // entities would fall back to `gauge_hp_share()`'s full-share
2427            // default.
2428            for evt in &mut spawn_evts {
2429                // -1 is the explicit zero-share marker: the attribute map
2430                // drops literal zeros, and absent reads as full share.
2431                evt.entity_attributes.add("gauge_hp_share", -1);
2432            }
2433            tracing::error!(
2434                fight_id = %fight.fight_id,
2435                "spawn_wave: wave has no HP budget — gauge shares stamped to zero"
2436            );
2437        }
2438    }
2439    for spawn_evt in spawn_evts {
2440        push_event(sink, spawn_evt)?;
2441    }
2442    Ok(())
2443}
2444
2445/// Slot-model promotion predicate (docs/combat-grid-migration-plan.md §2.2):
2446/// the ally formation steps one column forward when the near (melee) column
2447/// holds no living enemy while the far column does. An enemy's effective
2448/// column is where it stands or is running to (`move_target`); units still
2449/// parked beyond the far column (waiting out their exit cooldown behind the
2450/// screen edge) do not count — stepping toward them would drag the hero to
2451/// the spawn edge. Recursion needs no special code: after a step the old far
2452/// column IS the near column, and the next death/landing re-checks.
2453pub fn slot_promotion_needed(fight: &ActiveFight) -> bool {
2454    let Some(player) = fight.entities.iter().find(|e| e.id == fight.player_id) else {
2455        return false;
2456    };
2457    if player.hp == 0 || player.move_target.is_some() {
2458        return false;
2459    }
2460    let p = player.coordinates.x;
2461    let mut far_occupied = false;
2462    for e in &fight.entities {
2463        if e.team != EntityTeam::Enemy || e.hp == 0 {
2464            continue;
2465        }
2466        let col = e
2467            .move_target
2468            .as_ref()
2469            .map(|t| t.x)
2470            .unwrap_or(e.coordinates.x);
2471        if col <= p + MELEE_COL_OFFSET {
2472            return false;
2473        }
2474        if col == p + RANGED_COL_OFFSET {
2475            far_occupied = true;
2476        }
2477    }
2478    far_occupied
2479}
2480
2481/// A TEMPLATE has a melee ability if any of its abilities has range ≤ 1 —
2482/// the slot director's column choice (§2.6). Unknown template/range counts as
2483/// melee: the near column is the conservative slot (the unit can always act
2484/// from contact).
2485fn template_has_melee_ability(config: &GameConfig, lookups: &ContentLookups, tpl_id: Uuid) -> bool {
2486    config
2487        .entity_template(tpl_id)
2488        .map(|tpl| {
2489            tpl.ability_ids
2490                .iter()
2491                .any(|ability_id| lookups.ability_range.get(ability_id).copied().unwrap_or(0) <= 1)
2492        })
2493        .unwrap_or(true)
2494}
2495
2496/// Convert the typed config mirror (`prepare_fight_waves`) into the runtime
2497/// [`WaveFightData`] consumed by [`spawn_wave`]. This is the native data
2498/// source for the prepare_fight interpreter.
2499pub fn wave_data_from_config(cfg: &essences::fighting::PrepareFightWaves) -> WaveFightData {
2500    WaveFightData {
2501        entities: cfg
2502            .entities
2503            .iter()
2504            .map(|e| WaveEntityPower {
2505                entity_id: e.entity_id.clone(),
2506                power: e.power,
2507            })
2508            .collect(),
2509        waves: cfg
2510            .waves
2511            .iter()
2512            .map(|wave| {
2513                wave.iter()
2514                    .map(|s| WaveSpawn {
2515                        entity_id: s.entity_id.clone(),
2516                        // The deployed prepare_fight always carried an explicit
2517                        // `delay: 0` for spawns that omit it; mirror that default
2518                        // here (the typed config omits the 0s).
2519                        delay: Some(s.delay.unwrap_or(0.0)),
2520                        position: s.position.clone(),
2521                        cooldown_seconds: s.cooldown_seconds,
2522                        row: s.row,
2523                    })
2524                    .collect()
2525            })
2526            .collect(),
2527        time: cfg.time,
2528        power: Some(cfg.power),
2529        stream_active_count: cfg.stream_active_count,
2530        reward_mob_budget: cfg.reward_mob_budget,
2531        summon_wave_at_hp_fraction: cfg.summon_wave_at_hp_fraction,
2532        enemy_damage_mult: cfg.enemy_damage_mult,
2533    }
2534}
2535
2536// ---------------------------------------------------------------------------
2537// Casts and movement
2538// ---------------------------------------------------------------------------
2539
2540fn is_valid_target(
2541    lookups: &ContentLookups,
2542    target: &Entity,
2543    ability: &Ability,
2544    caster: &Entity,
2545) -> bool {
2546    let target_type = lookups
2547        .ability_target_type
2548        .get(&ability.template_id)
2549        .map(|s| s.as_str())
2550        .unwrap_or("");
2551    let by_team = match target_type {
2552        "Enemy" => target.team != caster.team,
2553        "Ally" => target.team == caster.team,
2554        _ => false,
2555    };
2556    let range = lookups
2557        .ability_range
2558        .get(&ability.template_id)
2559        .copied()
2560        .unwrap_or(0);
2561    by_team && get_distance_to(caster, target) <= range
2562}
2563
2564/// Native: queue `casts` attack actions for `caster` against `valid_targets`.
2565/// Consumes `multicast_chance` from `rng`, then (per cast after the first, when
2566/// there are multiple targets) a `randint` for target selection. `_ability` is
2567/// unused, matching the original signature.
2568#[allow(clippy::too_many_arguments)]
2569pub fn cast(
2570    sink: &mut dyn FightSink,
2571    rng: &GameRng,
2572    config: &GameConfig,
2573    lookups: &ContentLookups,
2574    caster: &Entity,
2575    _ability: &Ability,
2576    valid_targets: &[Entity],
2577    natural_casts_number: i64,
2578) -> Result<(), anyhow::Error> {
2579    let mut casts_number = natural_casts_number;
2580    let is_multicast = entropy_throw(rng, lookups, caster, "multicast_chance", 0.0, 1.0);
2581    if is_multicast {
2582        casts_number *= 2;
2583    }
2584
2585    // Effect Stone `NextAttackDoubleHit`: extra swings armed by a trigger fire.
2586    //
2587    // They ride this cast's own swing list — NOT a re-dispatched
2588    // `StartCastAbility`, which would go through
2589    // `push_start_cast_replacing`'s `max(existing, now + cooldown)` and *delay*
2590    // the entity's next natural swing instead of adding one. They are queued
2591    // through `OriginSink::proc` so the damage they land one or more ticks later
2592    // is `Proc` and cannot re-enter the trigger that granted them.
2593    let extra_swings = attr_get(caster, crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS) as i64;
2594    let extra_swings = extra_swings.max(0);
2595    if extra_swings > 0 {
2596        incr_attr(
2597            sink,
2598            caster.id,
2599            crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS,
2600            -extra_swings,
2601        )?;
2602    }
2603
2604    let total_swings = casts_number + extra_swings;
2605    let base_cast_time = entity_get_tpl_cast_time(config, caster) as f64;
2606    // Divided over ALL swings, so an extra swing lands inside the cast window
2607    // instead of extending it past the entity's next natural swing.
2608    let cast_time = (base_cast_time / total_swings as f64).floor() as i64;
2609    for i in 0..total_swings {
2610        let target = if valid_targets.len() > 1 {
2611            if i == 0 {
2612                get_target(caster, valid_targets)
2613                    .cloned()
2614                    .unwrap_or_else(|| valid_targets[0].clone())
2615            } else {
2616                let idx = rng.random_index(valid_targets.len());
2617                valid_targets[idx].clone()
2618            }
2619        } else {
2620            valid_targets[0].clone()
2621        };
2622        let delay = (i * cast_time).max(0) as u64;
2623        let duration = cast_time.max(0) as u64;
2624        if i < casts_number {
2625            sink.push_attack(delay, duration, target.id, CombatEventOrigin::Core)?;
2626        } else {
2627            OriginSink::proc(sink).push_attack(
2628                delay,
2629                duration,
2630                target.id,
2631                CombatEventOrigin::Core,
2632            )?;
2633        }
2634    }
2635    Ok(())
2636}
2637
2638/// Native: the ability `on_cast` hook. Rolls `bravery`; on success applies a
2639/// random buff (`protection`/`empower`). Consumes `bravery` then (if it lands)
2640/// a `randint(0,2)` from `rng`. Effect `on_apply` reactions dispatch via
2641/// `effects`.
2642pub fn on_cast(
2643    sink: &mut dyn FightSink,
2644    rng: &GameRng,
2645    lookups: &ContentLookups,
2646    effects: &mut dyn EffectCb,
2647    caster: &Entity,
2648) -> Result<(), anyhow::Error> {
2649    if entropy_throw(rng, lookups, caster, "bravery", 0.0, 1.0) {
2650        let pool = ["protection", "empower"];
2651        let idx = rng.random_index(pool.len());
2652        let code = pool[idx];
2653        apply_entity_effect(
2654            sink,
2655            lookups,
2656            effects,
2657            caster,
2658            code,
2659            balance::BRAVERY_BUFF_DURATION,
2660        )?;
2661    }
2662    Ok(())
2663}
2664
2665/// Native: move `entity` toward the enemy line in a single multi-cell run
2666/// (one `StartMove` instead of one per cell — every per-cell seam quantizes
2667/// to the 100ms game tick and gives the client a chance to stutter).
2668///
2669/// Movement is fixed-direction (allies always advance +x, enemies −x), so we
2670/// only ever advance toward the nearest opponent *ahead* — never chase one we
2671/// have already reached or passed, which would march us away from the fight
2672/// forever. We cover the **whole gap** toward the opponent (one column short)
2673/// in one `StartMove`, so an approach is a single smooth run, not one per cell.
2674///
2675/// In the two-phase anchor model exactly one side moves at any moment (enemies
2676/// charge the stationary hero-anchor; in phase 2 the Ally team charges the
2677/// stationary casting ranged enemies), so there is no "meet in the middle" —
2678/// the mover simply closes the whole distance. Two same-team runners planning
2679/// on the same tick still can't land on or cross each other: a `Run` lowers to
2680/// an *immediate* `StartMove` and the event loop is depth-first LIFO, so the
2681/// first planner's `handle_start_move` sets its `move_target` reservation
2682/// BEFORE the second plans this tick; the second's probe routes every cell
2683/// through `cell_exists_and_free` (checks coordinates AND reserved
2684/// `move_target`), so it sidesteps around the reservation.
2685///
2686/// (The entrance "run onto the screen → run in the fight" seam is a separate,
2687/// client-side concern — Unity's run-in tween must flow into the first
2688/// `StartMove` instead of force-settling to Idle; no server distance can cover
2689/// it.)
2690///
2691/// The walk also stops on the first cell where `ability` gains a valid target,
2692/// and never steps onto a cell occupied or reserved by another entity.
2693///
2694/// Neither team may cross the other's front line. A mover advances at most to
2695/// the column directly in front of the opposing team's frontmost LIVING unit and
2696/// never onto or past it, so the two sides meet exactly one column apart instead
2697/// of stacking on the same column (an enemy standing right under the hero). The
2698/// opposing front is taken over both current cells AND reserved `move_target`s,
2699/// so two mutual approachers don't both land on the same midpoint column on an
2700/// even gap: the depth-first / LIFO second planner sees the first's reservation
2701/// and stops one column short. This is the hard guarantee layered on top of the
2702/// full-gap `max_steps` walk and the in-range stop.
2703/// Native: emit a run action moving `entity` to `to` (no-op if speed is 0 or
2704/// the move covers no distance).
2705pub fn entity_run(
2706    sink: &mut dyn FightSink,
2707    lookups: &ContentLookups,
2708    entity: &Entity,
2709    to: Coordinates,
2710) -> Result<(), anyhow::Error> {
2711    const DEFAULT_TIME_PER_CELL: f64 = 500.0;
2712    let speed = get_entity_stat(lookups, entity, "speed");
2713    if speed == 0.0 {
2714        return Ok(());
2715    }
2716    let speed_norm = speed / 10000.0;
2717    // Euclidean, not |dx|: a diagonal sidestep covers √2 cells and must take
2718    // √2× the time, otherwise the unit visibly speeds up on lane changes.
2719    let dx = (to.x - entity.coordinates.x) as f64;
2720    let dy = (to.y - entity.coordinates.y) as f64;
2721    let distance = (dx * dx + dy * dy).sqrt();
2722    if distance == 0.0 {
2723        return Ok(());
2724    }
2725    let mut time_per_cell = DEFAULT_TIME_PER_CELL / speed_norm;
2726    // Ally runs (advance dash, in-fight repositioning) are sped up by the config multiplier
2727    // (docs/combat-feel-porting-plan.md [3.4]) — NOT via the `speed` attribute, which would
2728    // also scale ability cooldowns. A non-positive value (Default-built lookups) means 1.0.
2729    if entity.team == EntityTeam::Ally && lookups.ally_run_speed_mult > 0.0 {
2730        time_per_cell /= lookups.ally_run_speed_mult;
2731    }
2732    let duration = (time_per_cell * distance).floor() as u64;
2733    sink.push_run(to, duration, CombatEventOrigin::Core)
2734}
2735
2736/// Native: attempt to cast `ability_id` from `caster`. Picks valid targets and
2737/// delegates to `cast`; if there are none and the caster is not `static`,
2738/// advances toward the enemy. `casts` is the natural cast count (default 1).
2739/// Consumes RNG only via `cast` / `advance_entity`.
2740#[allow(clippy::too_many_arguments)]
2741pub fn try_cast(
2742    sink: &mut dyn FightSink,
2743    rng: &GameRng,
2744    config: &GameConfig,
2745    lookups: &ContentLookups,
2746    fight: &ActiveFight,
2747    caster: &Entity,
2748    ability_id: Uuid,
2749    casts: i64,
2750) -> Result<(), anyhow::Error> {
2751    if attr_present(caster, "sleep") {
2752        return Ok(());
2753    }
2754    if config.ability_template(ability_id).is_none() {
2755        return Err(anyhow::anyhow!("try_cast: unknown ability {ability_id}"));
2756    }
2757    let target_type = lookups
2758        .ability_target_type
2759        .get(&ability_id)
2760        .cloned()
2761        .unwrap_or_default();
2762    let ability_val = Ability {
2763        template_id: ability_id,
2764        level: 1,
2765        shards_amount: 0,
2766    };
2767    if target_type == "Self" {
2768        cast(
2769            sink,
2770            rng,
2771            config,
2772            lookups,
2773            caster,
2774            &ability_val,
2775            std::slice::from_ref(caster),
2776            casts,
2777        )?;
2778        return Ok(());
2779    }
2780    let mut valid_targets = Vec::new();
2781    for ent in &fight.entities {
2782        if is_valid_target(lookups, ent, &ability_val, caster) {
2783            valid_targets.push(ent.clone());
2784        }
2785    }
2786    if !valid_targets.is_empty() {
2787        cast(
2788            sink,
2789            rng,
2790            config,
2791            lookups,
2792            caster,
2793            &ability_val,
2794            &valid_targets,
2795            casts,
2796        )?;
2797        return Ok(());
2798    }
2799    // Slot model: no in-combat approach. Units fight from their slots; the
2800    // formation promotion step (logic::fighting::slot_promotion_events) closes
2801    // the gap when the near column empties. A caster with no target in range
2802    // simply re-arms and retries on its next cadence tick.
2803    Ok(())
2804}
2805
2806#[cfg(test)]
2807mod tests {
2808    use super::*;
2809
2810    fn entity_at(team: EntityTeam, x: i64) -> Entity {
2811        Entity {
2812            id: uuid::Uuid::new_v4(),
2813            team,
2814            coordinates: Coordinates { x, y: 1 },
2815            ..Default::default()
2816        }
2817    }
2818
2819    /// `target_type` / `range` must reach `ContentLookups`. When they are absent
2820    /// (the bug), `target_type` resolves to "" → `by_team` is always false → no
2821    /// ability ever has a valid target → entities advance every tick and run
2822    /// through the enemy line without attacking.
2823    #[test]
2824    fn is_valid_target_requires_populated_target_type_and_range() {
2825        let ability_id = uuid::Uuid::new_v4();
2826        let ability = Ability {
2827            template_id: ability_id,
2828            level: 1,
2829            shards_amount: 0,
2830        };
2831        let caster = entity_at(EntityTeam::Ally, 0);
2832        let enemy_in_range = entity_at(EntityTeam::Enemy, 1);
2833        let enemy_out_of_range = entity_at(EntityTeam::Enemy, 5);
2834        let ally = entity_at(EntityTeam::Ally, 1);
2835
2836        // The bug: empty lookups (target_type "" → by_team false) — never valid.
2837        let empty = ContentLookups::default();
2838        assert!(
2839            !is_valid_target(&empty, &enemy_in_range, &ability, &caster),
2840            "empty lookups must yield no valid target (this was the run-through bug)"
2841        );
2842
2843        // Fixed: target_type "Enemy" + range 1 sourced from the typed config.
2844        let mut populated = ContentLookups::default();
2845        populated
2846            .ability_target_type
2847            .insert(ability_id, "Enemy".to_string());
2848        populated.ability_range.insert(ability_id, 1);
2849
2850        assert!(
2851            is_valid_target(&populated, &enemy_in_range, &ability, &caster),
2852            "an in-range enemy must be a valid target once lookups are populated"
2853        );
2854        assert!(
2855            !is_valid_target(&populated, &enemy_out_of_range, &ability, &caster),
2856            "an out-of-range enemy must not be targetable (caster should advance)"
2857        );
2858        assert!(
2859            !is_valid_target(&populated, &ally, &ability, &caster),
2860            "an ally must not be a valid target for an Enemy-typed ability"
2861        );
2862    }
2863
2864    /// Baseline: the fight primitives themselves emit Core. Every combat event
2865    /// the game produces today comes from this path, so nothing in the live
2866    /// game is Proc until a modifier exists.
2867    #[test]
2868    fn fight_primitives_emit_core() {
2869        let mut target = entity_at(EntityTeam::Enemy, 1);
2870        target.hp = 10;
2871        target.max_hp = 100;
2872
2873        let mut sink = NativeSink::default();
2874        heal_entity(&mut sink, None, &target, 5.0, CombatSource::Other).unwrap();
2875
2876        assert_eq!(sink.events.len(), 1);
2877        assert_eq!(
2878            sink.events[0].combat_origin(),
2879            Some(CombatEventOrigin::Core)
2880        );
2881        assert!(sink.events[0].is_core_combat_event());
2882    }
2883
2884    /// Acceptance criterion #2 (plumbing half): an Effect-Stone-style modifier
2885    /// runs the very same primitives against an `OriginSink::proc`, and every
2886    /// combat event it produces comes out Proc — so no trigger can react to it.
2887    /// No stones exist yet, hence the synthetic modifier.
2888    #[test]
2889    fn modifier_scope_marks_emitted_events_proc() {
2890        let mut target = entity_at(EntityTeam::Enemy, 1);
2891        target.hp = 10;
2892        target.max_hp = 100;
2893
2894        let mut sink = NativeSink::default();
2895        {
2896            // Stand-in for an Effect Stone's execution scope.
2897            let mut modifier_sink = OriginSink::proc(&mut sink);
2898            heal_entity(&mut modifier_sink, None, &target, 5.0, CombatSource::Other).unwrap();
2899        }
2900
2901        assert_eq!(sink.events.len(), 1);
2902        assert_eq!(
2903            sink.events[0].combat_origin(),
2904            Some(CombatEventOrigin::Proc)
2905        );
2906        assert!(
2907            !sink.events[0].is_core_combat_event(),
2908            "a modifier-produced event must never read as Core"
2909        );
2910    }
2911
2912    /// The decorator only restamps provenance: the payload the primitive built
2913    /// is forwarded untouched, so a modifier's heal heals exactly as much.
2914    #[test]
2915    fn modifier_scope_changes_nothing_but_provenance() {
2916        let mut target = entity_at(EntityTeam::Enemy, 1);
2917        target.hp = 10;
2918        target.max_hp = 100;
2919
2920        let mut core_sink = NativeSink::default();
2921        let core_applied =
2922            heal_entity(&mut core_sink, None, &target, 5.0, CombatSource::Other).unwrap();
2923
2924        let mut proc_sink = NativeSink::default();
2925        let proc_applied = {
2926            let mut modifier_sink = OriginSink::proc(&mut proc_sink);
2927            heal_entity(&mut modifier_sink, None, &target, 5.0, CombatSource::Other).unwrap()
2928        };
2929
2930        assert_eq!(core_applied, proc_applied);
2931        assert_eq!(
2932            core_sink.events[0]
2933                .clone()
2934                .with_origin(CombatEventOrigin::Proc),
2935            proc_sink.events[0]
2936        );
2937    }
2938
2939    /// B1 lift: a producer scope may now schedule casts and runs, because the
2940    /// queued cast carries the scope's provenance all the way to
2941    /// `ActionWithDeadline` — the damage it lands a tick later can no longer
2942    /// surface as Core.
2943    #[test]
2944    fn modifier_scope_carries_provenance_onto_scheduled_casts_and_runs() {
2945        let mut sink = NativeSink::default();
2946        {
2947            let mut modifier_sink = OriginSink::proc(&mut sink);
2948            modifier_sink
2949                .push_attack(0, 100, uuid::Uuid::from_u128(1), CombatEventOrigin::Core)
2950                .unwrap();
2951            modifier_sink
2952                .push_run(Coordinates { x: 1, y: 1 }, 100, CombatEventOrigin::Core)
2953                .unwrap();
2954        }
2955        assert_eq!(sink.casts.len(), 2);
2956        for cast in &sink.casts {
2957            assert_eq!(cast.origin, CombatEventOrigin::Proc);
2958        }
2959
2960        // A Core scope is the ambient case: the primitive's own mark stands.
2961        let mut core_sink = NativeSink::default();
2962        {
2963            let mut scope = OriginSink::new(&mut core_sink, CombatEventOrigin::Core);
2964            scope
2965                .push_attack(0, 100, uuid::Uuid::from_u128(1), CombatEventOrigin::Core)
2966                .unwrap();
2967            scope
2968                .push_run(Coordinates { x: 1, y: 1 }, 100, CombatEventOrigin::Core)
2969                .unwrap();
2970        }
2971        assert_eq!(core_sink.casts.len(), 2);
2972        for cast in &core_sink.casts {
2973            assert_eq!(cast.origin, CombatEventOrigin::Core);
2974        }
2975    }
2976
2977    /// Same rule for events: what a producer scope cannot stamp, it cannot
2978    /// emit. `SpawnEntity` carries no provenance by design — a spawned
2979    /// combatant fights with its own Core actions — so a producer may not
2980    /// reach for it and get Core work for free.
2981    #[test]
2982    fn modifier_scope_refuses_events_it_cannot_mark() {
2983        let spawn = || OverlordEvent::SpawnEntity {
2984            id: uuid::Uuid::from_u128(1),
2985            entity_template_id: uuid::Uuid::from_u128(2),
2986            position: Coordinates { x: 1, y: 1 },
2987            entity_team: EntityTeam::Enemy,
2988            has_big_hp_bar: false,
2989            entity_attributes: EntityAttributes::default(),
2990        };
2991
2992        let mut sink = NativeSink::default();
2993        {
2994            let mut modifier_sink = OriginSink::proc(&mut sink);
2995            assert!(modifier_sink.push_event(spawn()).is_err());
2996        }
2997        assert!(sink.events.is_empty());
2998
2999        let mut core_sink = NativeSink::default();
3000        {
3001            let mut scope = OriginSink::new(&mut core_sink, CombatEventOrigin::Core);
3002            scope.push_event(spawn()).unwrap();
3003        }
3004        assert_eq!(core_sink.events.len(), 1, "Core scope stays unrestricted");
3005    }
3006
3007    /// Marking only upgrades — the rule `CombatEventOrigin::merge` states and the
3008    /// clock and `post_event` already follow. A Core scope must not reset an
3009    /// event a modifier already marked, or a producer that nests a plain sink
3010    /// would launder its own output back to Core.
3011    #[test]
3012    fn a_core_scope_never_downgrades_an_already_marked_event() {
3013        let mut sink = NativeSink::default();
3014        {
3015            let mut scope = OriginSink::new(&mut sink, CombatEventOrigin::Core);
3016            scope
3017                .push_event(OverlordEvent::Heal {
3018                    entity_id: uuid::Uuid::from_u128(1),
3019                    heal: 1,
3020                    origin: CombatEventOrigin::Proc,
3021                    by_entity_id: None,
3022                    source: essences::fight_breakdown::CombatSource::Other,
3023                })
3024                .unwrap();
3025        }
3026        assert_eq!(
3027            sink.events[0].combat_origin(),
3028            Some(CombatEventOrigin::Proc)
3029        );
3030
3031        // And a Core scope still stamps nothing onto genuinely Core work.
3032        let mut sink = NativeSink::default();
3033        {
3034            let mut scope = OriginSink::new(&mut sink, CombatEventOrigin::Core);
3035            scope
3036                .push_event(OverlordEvent::Heal {
3037                    entity_id: uuid::Uuid::from_u128(1),
3038                    heal: 1,
3039                    origin: CombatEventOrigin::Core,
3040                    by_entity_id: None,
3041                    source: essences::fight_breakdown::CombatSource::Other,
3042                })
3043                .unwrap();
3044        }
3045        assert_eq!(
3046            sink.events[0].combat_origin(),
3047            Some(CombatEventOrigin::Core)
3048        );
3049    }
3050
3051    /// B1 laundering fix. `CastEffectFromEvent` answers `carries_origin()` from
3052    /// its boxed caller, but `set_origin` is a no-op on it — so a guard keyed on
3053    /// the predicate let a producer push one wrapping a Core caller and have it
3054    /// come out Core. The guard is keyed on the stamp actually landing.
3055    #[test]
3056    fn modifier_scope_refuses_to_launder_through_cast_effect_from_event() {
3057        let core_caller = OverlordEvent::Damage {
3058            by_entity_id: None,
3059            entity_id: uuid::Uuid::from_u128(1),
3060            damage: 1,
3061            damage_data: Default::default(),
3062            origin: CombatEventOrigin::Core,
3063            source: essences::fight_breakdown::CombatSource::Other,
3064        };
3065        let laundered = OverlordEvent::CastEffectFromEvent {
3066            entity_id: uuid::Uuid::from_u128(1),
3067            effect_id: uuid::Uuid::from_u128(2),
3068            caller_event: Box::new(core_caller),
3069        };
3070        assert!(
3071            laundered.carries_origin(),
3072            "the predicate is satisfied — that is exactly the trap"
3073        );
3074
3075        let mut sink = NativeSink::default();
3076        {
3077            let mut modifier_sink = OriginSink::proc(&mut sink);
3078            assert!(modifier_sink.push_event(laundered).is_err());
3079        }
3080        assert!(sink.events.is_empty());
3081    }
3082
3083    /// A carrier passes through a producer scope marked, so the work it goes on
3084    /// to do inherits the mark instead of resetting to Core.
3085    #[test]
3086    fn modifier_scope_marks_carriers_too() {
3087        let mut sink = NativeSink::default();
3088        {
3089            let mut modifier_sink = OriginSink::proc(&mut sink);
3090            modifier_sink
3091                .push_event(OverlordEvent::CastEffect {
3092                    entity_id: uuid::Uuid::from_u128(1),
3093                    effect_id: uuid::Uuid::from_u128(2),
3094                    origin: CombatEventOrigin::Core,
3095                })
3096                .unwrap();
3097        }
3098        assert_eq!(
3099            sink.events[0].carried_origin(),
3100            Some(CombatEventOrigin::Proc)
3101        );
3102        assert_eq!(
3103            sink.events[0].combat_origin(),
3104            None,
3105            "a carrier is not trigger surface"
3106        );
3107    }
3108
3109    fn lookups_with_speed(speed: f64) -> ContentLookups {
3110        let speed_id = uuid::Uuid::new_v4();
3111        let mut lookups = ContentLookups::default();
3112        lookups
3113            .attribute_by_code
3114            .insert("speed".to_string(), speed_id);
3115        lookups.attribute_base_value.insert(speed_id, speed);
3116        lookups
3117    }
3118
3119    fn advance_lookups(ability_id: uuid::Uuid, range: i64) -> ContentLookups {
3120        let mut lookups = lookups_with_speed(10000.0);
3121        lookups
3122            .ability_target_type
3123            .insert(ability_id, "Enemy".to_string());
3124        lookups.ability_range.insert(ability_id, range);
3125        lookups
3126    }
3127
3128    /// A real ability template present in the test game config (range 1). Used as
3129    /// the hero's own attack in `try_cast` tests, where the caster's `ability_id`
3130    /// must resolve through `config.ability_template`.
3131    fn hero_ability_id() -> Uuid {
3132        Uuid::parse_str("da6c582b-7364-40bd-9b2d-946d8e20eaac").unwrap()
3133    }
3134
3135    /// Drive `try_cast` for `caster` (using the hero's real config ability) with
3136    /// `others` in the fight, returning the emitted sink. The two-phase gate reads
3137    /// the enemies' `abilities` + `lookups.ability_range`, so tests set those up.
3138    fn run_try_cast(caster: &Entity, others: Vec<Entity>, lookups: &ContentLookups) -> NativeSink {
3139        use rand::SeedableRng;
3140        let config = configs::tests_game_config::generate_game_config_for_tests();
3141        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3142        let mut entities = vec![caster.clone()];
3143        entities.extend(others);
3144        let fight = ActiveFight {
3145            entities,
3146            ..Default::default()
3147        };
3148        let mut sink = NativeSink::default();
3149        try_cast(
3150            &mut sink,
3151            &rng,
3152            &config,
3153            lookups,
3154            &fight,
3155            caster,
3156            hero_ability_id(),
3157            1,
3158        )
3159        .unwrap();
3160        sink
3161    }
3162
3163    /// Slot model: a caster with no target in range emits NOTHING — no cast,
3164    /// no run. The formation promotion step (logic::fighting) is the only
3165    /// approach mechanism left.
3166    #[test]
3167    fn try_cast_without_target_in_range_stands_still() {
3168        let mut hero = entity_at(EntityTeam::Ally, 1);
3169        hero.hp = 100;
3170        let mut enemy = entity_at(EntityTeam::Enemy, 5);
3171        enemy.hp = 100;
3172        let lookups = advance_lookups(hero_ability_id(), 1);
3173        let sink = run_try_cast(&hero, vec![enemy], &lookups);
3174        assert!(sink.casts.is_empty(), "out of range: no cast and no run");
3175        assert!(sink.events.is_empty(), "out of range: no events either");
3176    }
3177
3178    fn living_enemy_at(x: i64) -> Entity {
3179        let mut e = entity_at(EntityTeam::Enemy, x);
3180        e.hp = 100;
3181        e
3182    }
3183
3184    fn promo_fight(player_x: i64, enemies: Vec<Entity>) -> ActiveFight {
3185        let mut player = entity_at(EntityTeam::Ally, player_x);
3186        player.hp = 100;
3187        let player_id = player.id;
3188        let mut entities = vec![player];
3189        entities.extend(enemies);
3190        ActiveFight {
3191            player_id,
3192            entities,
3193            ..Default::default()
3194        }
3195    }
3196
3197    /// §2.2: near column empty, far column held → the formation steps forward.
3198    #[test]
3199    fn promotion_fires_when_near_empty_and_far_occupied() {
3200        let fight = promo_fight(5, vec![living_enemy_at(5 + RANGED_COL_OFFSET)]);
3201        assert!(slot_promotion_needed(&fight));
3202    }
3203
3204    /// A living enemy standing in — or running into — the near column keeps
3205    /// the formation planted.
3206    #[test]
3207    fn promotion_blocked_by_near_column() {
3208        let fight = promo_fight(5, vec![living_enemy_at(5 + MELEE_COL_OFFSET)]);
3209        assert!(!slot_promotion_needed(&fight));
3210
3211        let mut runner = living_enemy_at(9);
3212        runner.move_target = Some(Coordinates {
3213            x: 5 + MELEE_COL_OFFSET,
3214            y: 1,
3215        });
3216        let fight = promo_fight(5, vec![runner, living_enemy_at(5 + RANGED_COL_OFFSET)]);
3217        assert!(!slot_promotion_needed(&fight));
3218    }
3219
3220    /// Units still parked behind the screen edge (their exit cooldown pending)
3221    /// must not drag the formation toward the spawn edge.
3222    #[test]
3223    fn promotion_ignores_parked_spawns_beyond_far_column() {
3224        let fight = promo_fight(5, vec![living_enemy_at(5 + RANGED_COL_OFFSET + 4)]);
3225        assert!(!slot_promotion_needed(&fight));
3226    }
3227
3228    /// No step while the player is missing, dead or already running — and dead
3229    /// enemies hold no column.
3230    #[test]
3231    fn promotion_requires_standing_living_player() {
3232        let mut fight = promo_fight(5, vec![living_enemy_at(5 + RANGED_COL_OFFSET)]);
3233        fight.entities[0].move_target = Some(Coordinates { x: 6, y: 1 });
3234        assert!(!slot_promotion_needed(&fight));
3235
3236        fight.entities[0].move_target = None;
3237        fight.entities[0].hp = 0;
3238        assert!(!slot_promotion_needed(&fight));
3239
3240        let mut corpse = living_enemy_at(5 + RANGED_COL_OFFSET);
3241        corpse.hp = 0;
3242        let fight = promo_fight(5, vec![corpse]);
3243        assert!(!slot_promotion_needed(&fight));
3244    }
3245
3246    /// Diagonal sidesteps must take √2× a straight step — duration comes from
3247    /// the euclidean distance, not |Δx| (which made lane changes visibly fast).
3248    #[test]
3249    fn entity_run_duration_uses_euclidean_distance() {
3250        let entity = entity_at(EntityTeam::Ally, 0); // at (0, 1)
3251        let lookups = lookups_with_speed(10000.0);
3252
3253        let mut sink = NativeSink::default();
3254        entity_run(&mut sink, &lookups, &entity, Coordinates { x: 1, y: 2 }).unwrap();
3255
3256        assert_eq!(sink.casts.len(), 1);
3257        assert_eq!(
3258            sink.casts[0].run_duration_ticks,
3259            Some(707),
3260            "√2 cells × 500ms, floored"
3261        );
3262    }
3263
3264    /// TC-2: `get_entity_stat` floors the `.mod` multiplier at
3265    /// `MIN_STAT_MOD_MULT`, so a stacking debuff (weakness on attack, protection
3266    /// on received_damage) can't drive a stat to ≤0 (zero-damage / unkillable).
3267    #[test]
3268    fn get_entity_stat_mod_floor_prevents_zeroing() {
3269        let attack_id = uuid::Uuid::new_v4();
3270        let mut lookups = ContentLookups::default();
3271        lookups
3272            .attribute_by_code
3273            .insert("attack".to_string(), attack_id);
3274        lookups.attribute_base_value.insert(attack_id, 600.0);
3275
3276        // −500% `.mod` would be ×(−4) pre-floor → must clamp to ×0.05.
3277        let mut weak = Entity {
3278            id: uuid::Uuid::new_v4(),
3279            ..Default::default()
3280        };
3281        weak.attributes.add("attack.mod", -50_000);
3282        let v = get_entity_stat(&lookups, &weak, "attack");
3283        assert!(v > 0.0, "floored stat must stay positive, got {v}");
3284        assert!(
3285            (v - 600.0 * balance::MIN_STAT_MOD_MULT).abs() < 1e-9,
3286            "attack.mod must floor at MIN_STAT_MOD_MULT (×0.05), got {v}"
3287        );
3288
3289        // No mod → unaffected (base, mult 1.0).
3290        let neutral = Entity {
3291            id: uuid::Uuid::new_v4(),
3292            ..Default::default()
3293        };
3294        assert!((get_entity_stat(&lookups, &neutral, "attack") - 600.0).abs() < 1e-9);
3295    }
3296
3297    /// TC-6 / CM-2 (entropy rev): dodge resolves through the ENTROPY
3298    /// accumulator — at evasion == K (dodge prob 0.5) exactly every 2nd
3299    /// incoming touch is dodged, deterministically, no streaks; and the DR
3300    /// asymptote still holds (any finite evasion keeps letting SOME touches
3301    /// through at the exact DR rate).
3302    #[test]
3303    fn touch_enemy_dodge_entropy_rate_and_asymptote() {
3304        let k = balance::tuning().k_dodge;
3305        let mut target = Entity {
3306            id: uuid::Uuid::new_v4(),
3307            ..Default::default()
3308        };
3309        target.attributes.add("evasion", k as i64);
3310
3311        // Seed the accumulator at 0 → add 5000/check: touched, dodged, touched…
3312        let rng = GameRng::from_values(vec![0.0]);
3313        let touched: Vec<bool> = (0..10)
3314            .map(|_| touch_enemy(&rng, &ContentLookups::default(), &target))
3315            .collect();
3316        let dodges = touched.iter().filter(|t| !**t).count();
3317        assert_eq!(dodges, 5, "dodge 0.5 ⇒ exactly every 2nd touch dodged");
3318        for w in touched.windows(2) {
3319            assert_ne!(w[0], w[1], "50% entropy dodge must strictly alternate");
3320        }
3321
3322        // Asymptote < 100%: enormous evasion (dodge ≈ 0.99985) still lets
3323        // touches through at the exact DR rate — never a permanent immunity.
3324        let mut glass = Entity {
3325            id: uuid::Uuid::new_v4(),
3326            ..Default::default()
3327        };
3328        glass.attributes.add("evasion", 10_000_000);
3329        let rng = GameRng::from_values(vec![0.0]);
3330        let touched_count = (0..10_000)
3331            .filter(|_| touch_enemy(&rng, &ContentLookups::default(), &glass))
3332            .count();
3333        assert!(
3334            touched_count >= 1,
3335            "even huge evasion must not become full immunity (got {touched_count} touches)"
3336        );
3337    }
3338
3339    /// Regression guard for the "fight works but no damage" break AND the v2
3340    /// anti-unkillable floor. `received_damage` is a ×10000 multiplier whose base
3341    /// value (10000 == 100% taken) lives in `attribute_base_value`:
3342    /// * base lookup ENTIRELY ABSENT (content-extraction regression) →
3343    ///   `damage_entity` returns `None` (loud: fights visibly stall) — preserved
3344    ///   so the missing-base bug still surfaces.
3345    /// * base PRESENT but mitigated to ≤0 by a huge negative `received_damage.mod`
3346    ///   (e.g. stacked `protection`) → floored at `MIN_RECEIVED_DAMAGE_K`, so the
3347    ///   entity stays killable (`godmode` is the only true invuln).
3348    #[test]
3349    fn damage_requires_received_damage_base_value() {
3350        use rand::SeedableRng;
3351
3352        let target = Entity {
3353            id: uuid::Uuid::new_v4(),
3354            hp: 1000,
3355            max_hp: 1000,
3356            ..Default::default()
3357        };
3358        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3359
3360        // Missing base: empty lookups → no received_damage base → all damage
3361        // cancelled (the loud content-regression guard).
3362        let empty = ContentLookups::default();
3363        let mut sink = NativeSink::default();
3364        let res = damage_entity(
3365            &mut sink,
3366            &rng,
3367            &empty,
3368            None,
3369            &target,
3370            100.0,
3371            CustomEventData::default(),
3372            CombatSource::Other,
3373        )
3374        .unwrap();
3375        assert!(
3376            res.is_none(),
3377            "missing attribute_base_value cancels all damage (loud no-damage guard)"
3378        );
3379
3380        // Base present (10000 = 100% taken, sourced from config) → a hit lands.
3381        let rd_id = uuid::Uuid::new_v4();
3382        let mut lookups = ContentLookups::default();
3383        lookups
3384            .attribute_by_code
3385            .insert("received_damage".to_string(), rd_id);
3386        lookups.attribute_base_value.insert(rd_id, 10000.0);
3387
3388        let mut sink = NativeSink::default();
3389        let res = damage_entity(
3390            &mut sink,
3391            &rng,
3392            &lookups,
3393            None,
3394            &target,
3395            100.0,
3396            CustomEventData::default(),
3397            CombatSource::Other,
3398        )
3399        .unwrap();
3400        assert!(
3401            res.is_some_and(|d| d > 0),
3402            "with received_damage base populated, a hit must deal damage"
3403        );
3404
3405        // Anti-unkillable (TC-1): base present but stacked `protection` drives
3406        // `received_damage.mod` hugely negative — the entity must STILL take
3407        // positive damage (floored), never become invulnerable.
3408        let mut tank = target.clone();
3409        tank.attributes.add("received_damage.mod", -50_000); // −500% ⇒ ≤0 pre-floor
3410        let mut sink = NativeSink::default();
3411        let res = damage_entity(
3412            &mut sink,
3413            &rng,
3414            &lookups,
3415            None,
3416            &tank,
3417            100.0,
3418            CustomEventData::default(),
3419            CombatSource::Other,
3420        )
3421        .unwrap();
3422        assert!(
3423            res.is_some_and(|d| d > 0),
3424            "stacked protection must not make an entity unkillable (received_damage floor)"
3425        );
3426    }
3427
3428    /// Builds a minimal single-wave / single-spawn [`WaveFightData`] with the
3429    /// given reference `power`, runs [`spawn_wave`], and returns the `hp`
3430    /// attribute of the emitted `SpawnEntity` (the enemy that wave produced).
3431    fn spawn_wave_enemy_hp(power: Option<f64>, is_dungeon: bool) -> i64 {
3432        use rand::SeedableRng;
3433
3434        let enemy_id = uuid::Uuid::new_v4();
3435        let fight_data = WaveFightData {
3436            entities: vec![WaveEntityPower {
3437                entity_id: Some(enemy_id.to_string()),
3438                power: Some(1.0),
3439            }],
3440            waves: vec![vec![WaveSpawn {
3441                entity_id: enemy_id.to_string(),
3442                delay: Some(0.0),
3443                position: Some(Coordinates { x: 3, y: 3 }),
3444                cooldown_seconds: None,
3445                row: None,
3446            }]],
3447            time: 1.0,
3448            power,
3449            stream_active_count: None,
3450            reward_mob_budget: None,
3451            summon_wave_at_hp_fraction: None,
3452            enemy_damage_mult: None,
3453        };
3454
3455        // `spawn_wave` reads only `current_wave` + `entities` off the
3456        // fight; the fight kind is supplied separately via the `fight_type` arg.
3457        let fight = ActiveFight {
3458            current_wave: 1,
3459            ..Default::default()
3460        };
3461
3462        let config = configs::tests_game_config::generate_game_config_for_tests();
3463        let mut lookups = ContentLookups::default();
3464        lookups
3465            .fight_template_is_dungeon
3466            .insert(fight.fight_id, is_dungeon);
3467        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3468        let mut sink = NativeSink::default();
3469
3470        spawn_wave(
3471            &mut sink,
3472            &rng,
3473            &config,
3474            &lookups,
3475            &fight,
3476            &fight_data,
3477            fight_data.power.unwrap_or(0.0),
3478            1,
3479            "CampaignFight",
3480        )
3481        .unwrap();
3482
3483        let spawn = sink
3484            .events
3485            .iter()
3486            .find_map(|ev| match ev {
3487                OverlordEvent::SpawnEntity {
3488                    entity_attributes, ..
3489                } => Some(entity_attributes),
3490                _ => None,
3491            })
3492            .expect("spawn_wave must emit a SpawnEntity for the current wave");
3493
3494        // `hp == 0` is stored as an absent key by `EntityAttributes::add`.
3495        spawn.0.get("hp").copied().unwrap_or(0)
3496    }
3497
3498    /// Regression guard for the "wave enemies spawn with 0 HP" bug: the typed
3499    /// config extraction dropped the per-fight reference `power`, so both
3500    /// `spawn_wave` call sites passed `base_power = 0.0`. With `base_power
3501    /// = 0`, `eff = 0` → `player_dps = 0` → `mob_hp_norm = 0` → every spawned
3502    /// enemy got `hp: 0` (instant death, no HP bar).
3503    ///
3504    /// Since BAL-037 the campaign path takes its power from the signed chapter
3505    /// curve and ignores `base_power` entirely, so the bug is only reproducible
3506    /// on the DUNGEON path, which still spawns from the authored per-difficulty
3507    /// `FightTemplate.power`.
3508    #[test]
3509    fn spawn_wave_enemy_hp_requires_nonzero_base_power() {
3510        // The bug: base_power 0 → zero player DPS → zero-HP dungeon enemies.
3511        assert_eq!(
3512            spawn_wave_enemy_hp(Some(0.0), true),
3513            0,
3514            "base_power 0 must reproduce the zero-HP bug on the dungeon path"
3515        );
3516
3517        // Fixed: a non-zero reference power yields a positive enemy HP / HP bar.
3518        assert!(
3519            spawn_wave_enemy_hp(Some(100.0), true) > 0,
3520            "a non-zero reference power must produce wave enemies with positive HP"
3521        );
3522
3523        // Campaign waves read the signed BAL-037 curve, so a dropped reference
3524        // power can no longer zero their HP.
3525        assert!(
3526            spawn_wave_enemy_hp(Some(0.0), false) > 0,
3527            "campaign waves take power from the signed chapter curve, not base_power"
3528        );
3529    }
3530
3531    /// Shared harness: one wave of `(cooldown_seconds, legacy delay)` spawns of
3532    /// equal power; returns the normalized `attack` attr of the first emitted
3533    /// spawn (equal powers → identical attack on every spawn).
3534    fn spawn_wave_attack_for(spawns: &[(Option<f64>, Option<f64>)]) -> i64 {
3535        spawn_wave_attack_capped(spawns, None)
3536    }
3537
3538    fn spawn_wave_attack_capped(spawns: &[(Option<f64>, Option<f64>)], cap: Option<u64>) -> i64 {
3539        use rand::SeedableRng;
3540        let enemy_id = uuid::Uuid::from_u128(7);
3541        let wave: Vec<WaveSpawn> = spawns
3542            .iter()
3543            .map(|(cooldown, delay)| WaveSpawn {
3544                entity_id: enemy_id.to_string(),
3545                delay: *delay,
3546                position: Some(Coordinates { x: 3, y: 1 }),
3547                cooldown_seconds: *cooldown,
3548                row: None,
3549            })
3550            .collect();
3551        let fight_data = WaveFightData {
3552            entities: vec![WaveEntityPower {
3553                entity_id: Some(enemy_id.to_string()),
3554                power: Some(1.0),
3555            }],
3556            waves: vec![wave],
3557            time: 30.0,
3558            power: Some(100.0),
3559            stream_active_count: cap,
3560            reward_mob_budget: None,
3561            summon_wave_at_hp_fraction: None,
3562            enemy_damage_mult: None,
3563        };
3564        let fight = ActiveFight {
3565            current_wave: 1,
3566            ..Default::default()
3567        };
3568        let config = configs::tests_game_config::generate_game_config_for_tests();
3569        let lookups = ContentLookups::default();
3570        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3571        let mut sink = NativeSink::default();
3572        spawn_wave(
3573            &mut sink,
3574            &rng,
3575            &config,
3576            &lookups,
3577            &fight,
3578            &fight_data,
3579            100.0,
3580            1,
3581            "CampaignFight",
3582        )
3583        .unwrap();
3584        sink.events
3585            .iter()
3586            .find_map(|ev| match ev {
3587                OverlordEvent::SpawnEntity {
3588                    entity_attributes, ..
3589                } => Some(entity_attributes.0.get("attack").copied().unwrap_or(0)),
3590                _ => None,
3591            })
3592            .expect("spawn_wave must emit spawns")
3593    }
3594
3595    /// The balance mini-sim budgets mob damage from the SLOT timeline: a mob
3596    /// arriving later overlaps less with its wave (less total incoming damage
3597    /// against the same survivability budget), so the normalization
3598    /// compensates with a higher per-hit attack. With the legacy `delay`
3599    /// input this was invisible — cooldown-staggered waves were budgeted as
3600    /// if fully overlapped (the "waves easier than budgeted" drift).
3601    #[test]
3602    fn mini_sim_budgets_from_exit_cooldowns_not_legacy_delay() {
3603        let overlapped = spawn_wave_attack_for(&[(None, None), (None, None)]);
3604        let staggered = spawn_wave_attack_for(&[(None, None), (Some(10.0), None)]);
3605        assert!(overlapped > 0);
3606        assert!(
3607            staggered > overlapped,
3608            "a cooldown-staggered wave overlaps less and must normalize to a \
3609             higher attack (staggered {staggered} vs overlapped {overlapped})"
3610        );
3611
3612        // The legacy `delay` field no longer moves the budget at all.
3613        let legacy_delay_only = spawn_wave_attack_for(&[(None, None), (None, Some(10.0))]);
3614        assert_eq!(
3615            legacy_delay_only, overlapped,
3616            "legacy `delay` must be dead as a balance input"
3617        );
3618    }
3619
3620    /// Returns the `wave_share` attribute of every emitted `SpawnEntity` for a
3621    /// single wave of `n` identical spawns under `reward_mob_budget = budget`.
3622    fn spawn_wave_shares(budget: Option<u64>, n: usize) -> Vec<Option<i64>> {
3623        use rand::SeedableRng;
3624        let enemy_id = uuid::Uuid::from_u128(13);
3625        let wave: Vec<WaveSpawn> = (0..n)
3626            .map(|_| WaveSpawn {
3627                entity_id: enemy_id.to_string(),
3628                delay: Some(0.0),
3629                position: Some(Coordinates { x: 3, y: 1 }),
3630                cooldown_seconds: None,
3631                row: None,
3632            })
3633            .collect();
3634        let fight_data = WaveFightData {
3635            entities: vec![WaveEntityPower {
3636                entity_id: Some(enemy_id.to_string()),
3637                power: Some(1.0),
3638            }],
3639            waves: vec![wave],
3640            time: 30.0,
3641            power: Some(100.0),
3642            stream_active_count: None,
3643            reward_mob_budget: budget,
3644            summon_wave_at_hp_fraction: None,
3645            enemy_damage_mult: None,
3646        };
3647        let fight = ActiveFight {
3648            current_wave: 1,
3649            ..Default::default()
3650        };
3651        let config = configs::tests_game_config::generate_game_config_for_tests();
3652        let lookups = ContentLookups::default();
3653        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3654        let mut sink = NativeSink::default();
3655        spawn_wave(
3656            &mut sink,
3657            &rng,
3658            &config,
3659            &lookups,
3660            &fight,
3661            &fight_data,
3662            100.0,
3663            1,
3664            "CampaignFight",
3665        )
3666        .unwrap();
3667        sink.events
3668            .iter()
3669            .filter_map(|ev| match ev {
3670                OverlordEvent::SpawnEntity {
3671                    entity_attributes, ..
3672                } => Some(entity_attributes.0.get("wave_share").copied()),
3673                _ => None,
3674            })
3675            .collect()
3676    }
3677
3678    /// §2: `spawn_wave` stamps `wave_share = reward_mob_budget / total mob count`
3679    /// (per-10000) on EVERY mob, so per-kill drops / pet-ult charge / counters
3680    /// stay count-invariant. Absent budget ⇒ no attribute (legacy bit-identical).
3681    #[test]
3682    fn spawn_wave_stamps_wave_share_per_mob() {
3683        // 4 mobs on field paying out as if 2 ⇒ share 0.5 ⇒ 5000 per-10000, on all.
3684        let shares = spawn_wave_shares(Some(2), 4);
3685        assert_eq!(shares.len(), 4);
3686        assert!(
3687            shares.iter().all(|s| *s == Some(5000)),
3688            "each mob must carry wave_share 5000, got {shares:?}"
3689        );
3690
3691        // Budget == count ⇒ share 1.0 ⇒ 10000.
3692        let unit = spawn_wave_shares(Some(3), 3);
3693        assert!(unit.iter().all(|s| *s == Some(10000)), "got {unit:?}");
3694
3695        // Legacy content (no budget) ⇒ NO wave_share attribute (bit-identical).
3696        let legacy = spawn_wave_shares(None, 4);
3697        assert_eq!(legacy.len(), 4);
3698        assert!(
3699            legacy.iter().all(|s| s.is_none()),
3700            "legacy content must not stamp wave_share, got {legacy:?}"
3701        );
3702    }
3703
3704    /// §2 consumer 3: scaling the counter proc probability by the attacker's
3705    /// wave_share keeps the TOTAL counter rate per fight count-invariant — 3×
3706    /// more incoming hits, each at 1/3 the proc chance, yields the same number
3707    /// of counters as the single-mob baseline. Legacy (scale 1.0) is unchanged.
3708    #[test]
3709    fn wave_share_makes_counter_proc_count_invariant() {
3710        use rand::SeedableRng;
3711        let lookups = ContentLookups::default();
3712        let counter_entity = || {
3713            let mut e = essences::entity::Entity::default();
3714            e.attributes.add("counterattack_chance", 2000); // 20% (permyriad)
3715            e
3716        };
3717
3718        // Baseline: 1 mob ⇒ 90 incoming hits at full share.
3719        let base = counter_entity();
3720        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(1));
3721        let base_procs = (0..90)
3722            .filter(|_| entropy_throw(&rng, &lookups, &base, "counterattack_chance", 0.0, 1.0))
3723            .count() as i64;
3724
3725        // Feature: 3 weak mobs ⇒ 270 incoming hits, each scaled by share 1/3.
3726        let weak = counter_entity();
3727        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(1));
3728        let weak_procs = (0..270)
3729            .filter(|_| {
3730                entropy_throw(
3731                    &rng,
3732                    &lookups,
3733                    &weak,
3734                    "counterattack_chance",
3735                    0.0,
3736                    1.0 / 3.0,
3737                )
3738            })
3739            .count() as i64;
3740
3741        assert!(
3742            (base_procs - weak_procs).abs() <= 2,
3743            "counter procs must stay count-invariant: base {base_procs} vs 3x-weak {weak_procs}"
3744        );
3745    }
3746
3747    /// Arrival math mirrors `handle_spawn_entity`'s exit scheduling: exits are
3748    /// floored (wave-1 floor here = 2000), the min-cooldown group rushes
3749    /// (4 cells × 200ms), the rest walk (× 500ms), and the run itself delays
3750    /// the first hit.
3751    #[test]
3752    fn slot_arrival_matches_exit_scheduling() {
3753        let config = configs::tests_game_config::generate_game_config_for_tests();
3754        let fs = &config.fight_settings;
3755        // Opening group under the floor: exits AT the floor, rush run only.
3756        assert_eq!(slot_arrival_delay_seconds(fs, 0, 0, 2000), 0.8);
3757        // Opening group with a shared cooldown above the floor: remainder + rush.
3758        assert_eq!(slot_arrival_delay_seconds(fs, 3000, 3000, 1150), 2.65);
3759        // A later echelon below the floor still walks in right at the floor.
3760        assert_eq!(slot_arrival_delay_seconds(fs, 500, 0, 2000), 2.0);
3761        // A later echelon above the floor: its remainder + walk run.
3762        assert_eq!(slot_arrival_delay_seconds(fs, 10_000, 0, 2000), 10.0);
3763    }
3764
3765    /// The gate plan keeps the first `cap` exits (by cooldown, ties by config
3766    /// order) on timers and queues the rest 1-based in the same order. No cap
3767    /// or cap ≥ N ⇒ all timers.
3768    #[test]
3769    fn wave_exit_gate_plan_caps_by_cooldown_order() {
3770        let mk = |cd: Option<f64>| WaveSpawn {
3771            entity_id: "e".into(),
3772            delay: None,
3773            position: None,
3774            cooldown_seconds: cd,
3775            row: None,
3776        };
3777        let wave = vec![mk(Some(1.0)), mk(None), mk(Some(0.5)), mk(None)];
3778
3779        assert_eq!(wave_exit_gate_plan(&wave, None), vec![None; 4]);
3780        assert_eq!(wave_exit_gate_plan(&wave, Some(4)), vec![None; 4]);
3781        assert_eq!(wave_exit_gate_plan(&wave, Some(9)), vec![None; 4]);
3782
3783        // cap 2: exit order is idx1(0ms), idx3(0ms), idx2(500ms), idx0(1000ms)
3784        // → timers {1, 3}; gated: idx2 = queue 1, idx0 = queue 2.
3785        assert_eq!(
3786            wave_exit_gate_plan(&wave, Some(2)),
3787            vec![Some(2), None, Some(1), None]
3788        );
3789    }
3790
3791    /// Spawns beyond the cap are stamped `exit_gated` (queue position) and get
3792    /// NEITHER an exit timer NOR the rush marker; the timer group is unchanged.
3793    #[test]
3794    fn spawn_stamps_death_gated_exits_beyond_cap() {
3795        use rand::SeedableRng;
3796        let enemy_id = uuid::Uuid::new_v4();
3797        let mk = |cd: Option<f64>| WaveSpawn {
3798            entity_id: enemy_id.to_string(),
3799            delay: None,
3800            position: Some(Coordinates { x: 3, y: 1 }),
3801            cooldown_seconds: cd,
3802            row: Some(1),
3803        };
3804        let fight_data = WaveFightData {
3805            entities: vec![WaveEntityPower {
3806                entity_id: Some(enemy_id.to_string()),
3807                power: Some(1.0),
3808            }],
3809            waves: vec![vec![mk(None), mk(Some(2.5)), mk(Some(5.0))]],
3810            time: 30.0,
3811            power: Some(100.0),
3812            stream_active_count: Some(1),
3813            reward_mob_budget: None,
3814            summon_wave_at_hp_fraction: None,
3815            enemy_damage_mult: None,
3816        };
3817        let fight = ActiveFight {
3818            current_wave: 1,
3819            ..Default::default()
3820        };
3821        let config = configs::tests_game_config::generate_game_config_for_tests();
3822        let lookups = ContentLookups::default();
3823        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3824        let mut sink = NativeSink::default();
3825        spawn_wave(
3826            &mut sink,
3827            &rng,
3828            &config,
3829            &lookups,
3830            &fight,
3831            &fight_data,
3832            100.0,
3833            1,
3834            "CampaignFight",
3835        )
3836        .unwrap();
3837
3838        let attrs: Vec<_> = sink
3839            .events
3840            .iter()
3841            .filter_map(|ev| match ev {
3842                OverlordEvent::SpawnEntity {
3843                    entity_attributes, ..
3844                } => Some(entity_attributes.clone()),
3845                _ => None,
3846            })
3847            .collect();
3848        assert_eq!(attrs.len(), 3);
3849
3850        // Timer group (cap 1 = the min-cooldown spawn): rush, no gate.
3851        assert!(attrs[0].0.contains_key("entrance_rush"));
3852        assert!(!attrs[0].0.contains_key("exit_gated"));
3853
3854        // Gated: queue positions in cooldown order, no timer, no rush.
3855        assert_eq!(attrs[1].0.get("exit_gated").copied(), Some(1));
3856        assert_eq!(attrs[2].0.get("exit_gated").copied(), Some(2));
3857        for a in &attrs[1..] {
3858            assert!(!a.0.contains_key("exit_cooldown_ms"));
3859            assert!(!a.0.contains_key("entrance_rush"));
3860        }
3861    }
3862
3863    /// The mini-sim models the death gate as the closed loop it is: a capped
3864    /// wave arrives serially (each kill releases the next), overlapping less
3865    /// than the same wave uncapped → higher normalized attack. A cap that
3866    /// never binds is budget-identical to no cap.
3867    #[test]
3868    fn mini_sim_models_death_gated_releases() {
3869        let overlapped =
3870            spawn_wave_attack_capped(&[(None, None), (None, None), (None, None)], None);
3871        let capped = spawn_wave_attack_capped(&[(None, None), (None, None), (None, None)], Some(1));
3872        let cap_unbound =
3873            spawn_wave_attack_capped(&[(None, None), (None, None), (None, None)], Some(3));
3874        assert!(overlapped > 0);
3875        assert!(
3876            capped > overlapped,
3877            "a death-gated wave overlaps less and must normalize to a higher \
3878             attack (capped {capped} vs overlapped {overlapped})"
3879        );
3880        assert_eq!(
3881            cap_unbound, overlapped,
3882            "a cap that never binds must be budget-identical to no cap"
3883        );
3884    }
3885
3886    /// Slot director (§2.6): every spawn carries `exit_cooldown_ms`
3887    /// (= cooldown_seconds × 1000); ONLY the wave's min-cooldown group is
3888    /// marked `entrance_rush`; nobody sleeps — the |Δx| range gate replaces
3889    /// the wake machinery. Slots: unknown templates count as melee (near
3890    /// column), the row comes from the migrated field, and the spawn parks
3891    /// entrance-offset cells beyond the slot.
3892    #[test]
3893    fn spawn_sets_exit_cooldown_rush_and_slots() {
3894        use rand::SeedableRng;
3895
3896        let enemy_id = uuid::Uuid::new_v4();
3897        let mk = |cooldown: Option<f64>, row: i64| WaveSpawn {
3898            entity_id: enemy_id.to_string(),
3899            delay: Some(0.0),
3900            position: Some(Coordinates { x: 3, y: 3 }),
3901            cooldown_seconds: cooldown,
3902            row: Some(row),
3903        };
3904        let fight_data = WaveFightData {
3905            entities: vec![WaveEntityPower {
3906                entity_id: Some(enemy_id.to_string()),
3907                power: Some(1.0),
3908            }],
3909            waves: vec![vec![mk(None, 0), mk(Some(2.5), 2)]],
3910            time: 1.0,
3911            power: Some(100.0),
3912            stream_active_count: None,
3913            reward_mob_budget: None,
3914            summon_wave_at_hp_fraction: None,
3915            enemy_damage_mult: None,
3916        };
3917        let fight = ActiveFight {
3918            current_wave: 1,
3919            ..Default::default()
3920        };
3921        let config = configs::tests_game_config::generate_game_config_for_tests();
3922        let lookups = ContentLookups::default();
3923        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
3924        let mut sink = NativeSink::default();
3925        spawn_wave(
3926            &mut sink,
3927            &rng,
3928            &config,
3929            &lookups,
3930            &fight,
3931            &fight_data,
3932            100.0,
3933            1,
3934            "CampaignFight",
3935        )
3936        .unwrap();
3937
3938        let spawns: Vec<_> = sink
3939            .events
3940            .iter()
3941            .filter_map(|ev| match ev {
3942                OverlordEvent::SpawnEntity {
3943                    position,
3944                    entity_attributes,
3945                    ..
3946                } => Some((position.clone(), entity_attributes.clone())),
3947                _ => None,
3948            })
3949            .collect();
3950        assert_eq!(spawns.len(), 2);
3951
3952        // No player entity in the fight → anchor x = 0; unknown template →
3953        // melee column (+1); entrance offset 4 → spawn x = 5, y = row.
3954        assert_eq!(spawns[0].0, Coordinates { x: 5, y: 0 });
3955        assert_eq!(spawns[1].0, Coordinates { x: 5, y: 2 });
3956
3957        // A zero attr is never materialized (EntityAttributes::add drops
3958        // zeros) — absent IS the zero cooldown, mirroring the config rule.
3959        assert_eq!(spawns[0].1.0.get("exit_cooldown_ms").copied(), None);
3960        assert_eq!(spawns[1].1.0.get("exit_cooldown_ms").copied(), Some(2500));
3961        assert!(spawns[0].1.0.contains_key("entrance_rush"));
3962        assert!(!spawns[1].1.0.contains_key("entrance_rush"));
3963        for (_, attrs) in &spawns {
3964            assert!(
3965                !attrs.0.contains_key("wake_up_delay"),
3966                "slot model spawns never sleep"
3967            );
3968        }
3969    }
3970
3971    /// The row plan spreads a stacked opening group across the column's rows
3972    /// in exit order (authored row → middle → top → bottom rotation via
3973    /// least-occupancy), leaves an unstacked wave untouched, and never
3974    /// re-rows gated spawns (their row is picked live at release).
3975    #[test]
3976    fn wave_row_plan_spreads_stacked_opening_rows() {
3977        let mk = |row: i64| WaveSpawn {
3978            entity_id: uuid::Uuid::nil().to_string(),
3979            delay: None,
3980            position: None,
3981            cooldown_seconds: None,
3982            row: Some(row),
3983        };
3984        let config = configs::tests_game_config::generate_game_config_for_tests();
3985        let lookups = ContentLookups::default();
3986
3987        // Five mobs all authored onto the middle tile → 2/2/1 spread:
3988        // 1 (authored), 0, 2, back to 1, back to 0.
3989        let wave: Vec<_> = (0..5).map(|_| mk(1)).collect();
3990        let plan = wave_row_plan(&wave, &[None; 5], &config, &lookups, &[], 0, false);
3991        assert_eq!(plan, vec![1, 0, 2, 1, 0]);
3992
3993        // Already-distinct rows stay authored.
3994        let wave = vec![mk(0), mk(2)];
3995        let plan = wave_row_plan(&wave, &[None; 2], &config, &lookups, &[], 0, false);
3996        assert_eq!(plan, vec![0, 2]);
3997
3998        // A lone spawn (boss) never moves off its authored row.
3999        let wave = vec![mk(1)];
4000        assert_eq!(
4001            wave_row_plan(&wave, &[None], &config, &lookups, &[], 0, false),
4002            vec![1]
4003        );
4004
4005        // Gated spawns keep the authored row; only the two active ones spread.
4006        let wave: Vec<_> = (0..4).map(|_| mk(1)).collect();
4007        let gate = vec![None, None, Some(1), Some(2)];
4008        let plan = wave_row_plan(&wave, &gate, &config, &lookups, &[], 0, false);
4009        assert_eq!(plan, vec![1, 0, 1, 1]);
4010    }
4011
4012    /// A summon wave flanks the summoner: reinforcements take the top and
4013    /// bottom rows first and the middle last, and never land on a living
4014    /// enemy's tile while a freer row exists (live cells seed the occupancy).
4015    #[test]
4016    fn summon_wave_flanks_top_and_bottom_first() {
4017        let mk = |row: i64| WaveSpawn {
4018            entity_id: uuid::Uuid::nil().to_string(),
4019            delay: None,
4020            position: None,
4021            cooldown_seconds: None,
4022            row: Some(row),
4023        };
4024        let config = configs::tests_game_config::generate_game_config_for_tests();
4025        let lookups = ContentLookups::default();
4026        let anchor = 7;
4027        // The summoner boss stands mid-row of the melee column.
4028        let boss = [(anchor + MELEE_COL_OFFSET, 1)];
4029
4030        // Two summoned creeps (authored 0 and 1, like the shipped slime pair)
4031        // flank top and bottom — the middle (the boss's tile) stays clear.
4032        let wave = vec![mk(0), mk(1)];
4033        let plan = wave_row_plan(&wave, &[None; 2], &config, &lookups, &boss, anchor, true);
4034        assert_eq!(plan, vec![0, 2]);
4035
4036        // Boss standing on the TOP row: first creep skips it (bottom), the
4037        // second takes the now-least-occupied middle.
4038        let boss_top = [(anchor + MELEE_COL_OFFSET, 0)];
4039        let plan = wave_row_plan(
4040            &wave, &[None; 2], &config, &lookups, &boss_top, anchor, true,
4041        );
4042        assert_eq!(plan, vec![2, 1]);
4043    }
4044
4045    /// A death-released mob lands on the least-occupied row of its own column,
4046    /// counting standers, runners, and pending park-column arrivals; ties keep
4047    /// the authored row. Parked still-gated queue mates never count.
4048    #[test]
4049    fn gated_release_row_targets_the_empty_tile() {
4050        let offset = 4;
4051        let park_x = 5; // battle column x = 1
4052        let row_enemy = |x: i64, y: i64| {
4053            let mut e = living_enemy_at(x);
4054            e.coordinates.y = y;
4055            e
4056        };
4057        let mut released = row_enemy(park_x, 1);
4058        released.attributes.add("exit_gated", 1);
4059        let released_id = released.id;
4060
4061        // Rows 0 and 1 held, row 2 free → land on 2.
4062        let fight = promo_fight(0, vec![released.clone(), row_enemy(1, 0), row_enemy(1, 1)]);
4063        assert_eq!(gated_release_row(&fight, released_id, offset), 2);
4064
4065        // Authored row free → tie resolves to it, not the middle.
4066        let fight = promo_fight(0, vec![released.clone(), row_enemy(1, 0), row_enemy(1, 2)]);
4067        assert_eq!(gated_release_row(&fight, released_id, offset), 1);
4068
4069        // A runner heading to (1, 1) occupies its DESTINATION row; a parked
4070        // queue mate (still gated) at the same park column does not count.
4071        let mut runner = row_enemy(9, 0);
4072        runner.move_target = Some(Coordinates { x: 1, y: 1 });
4073        let mut queued = row_enemy(park_x, 0);
4074        queued.attributes.add("exit_gated", 2);
4075        let fight = promo_fight(0, vec![released.clone(), runner, queued]);
4076        assert_eq!(gated_release_row(&fight, released_id, offset), 0);
4077
4078        // A same-tick earlier release still parked (gate cleared, park y set)
4079        // counts on its landing row: rows 0+1 taken → 2.
4080        let earlier = row_enemy(park_x, 0);
4081        let fight = promo_fight(0, vec![released, earlier, row_enemy(1, 1)]);
4082        assert_eq!(gated_release_row(&fight, released_id, offset), 2);
4083    }
4084
4085    /// Drive `init_fight` for a lone player entity and collect the emitted
4086    /// `attack.mod` deltas.
4087    fn init_fight_attack_mod_deltas(
4088        is_dungeon: bool,
4089        is_bossfight: bool,
4090        dungeon_talent_level: Option<i64>,
4091        boss_talent_level: Option<i64>,
4092        existing_attack_mod: i64,
4093    ) -> Vec<i64> {
4094        let fight_template_id = uuid::Uuid::new_v4();
4095        let mut lookups = ContentLookups::default();
4096        lookups
4097            .fight_template_is_dungeon
4098            .insert(fight_template_id, is_dungeon);
4099        lookups
4100            .fight_template_is_bossfight
4101            .insert(fight_template_id, is_bossfight);
4102
4103        let mut player = entity_at(EntityTeam::Ally, 0);
4104        player.attributes.add("attack.mod", existing_attack_mod);
4105
4106        let fight = ActiveFight {
4107            player_id: player.id,
4108            entities: vec![player.clone()],
4109            ..Default::default()
4110        };
4111
4112        let mut state = crate::state::OverlordState::default();
4113        if let Some(level) = dungeon_talent_level {
4114            state
4115                .character_state
4116                .talent_levels
4117                .0
4118                .insert(Uuid::parse_str(DUNGEON_TALENT_ID).unwrap(), level);
4119        }
4120        if let Some(level) = boss_talent_level {
4121            state
4122                .character_state
4123                .talent_levels
4124                .0
4125                .insert(Uuid::parse_str(BOSS_TALENT_ID).unwrap(), level);
4126        }
4127
4128        let mut sink = NativeSink::default();
4129        init_fight(&mut sink, &lookups, &fight, &state, fight_template_id).unwrap();
4130
4131        sink.events
4132            .iter()
4133            .filter_map(|e| match e {
4134                OverlordEvent::EntityIncrAttribute {
4135                    entity_id,
4136                    attribute,
4137                    delta,
4138                } if *entity_id == player.id && attribute == "attack.mod" => Some(*delta),
4139                _ => None,
4140            })
4141            .collect()
4142    }
4143
4144    /// The dungeon/boss talent attack bonus is +200/talent-level ADDED to the
4145    /// entity's existing `attack.mod` (class levels, Strength talent). A
4146    /// set-to-target write would emit `200·level − existing` and wipe those
4147    /// sources for the fight.
4148    #[test]
4149    fn init_fight_talent_attack_mod_adds_on_top_of_existing_sources() {
4150        // Dungeon talent 3 on top of an existing +3000 (e.g. class levels).
4151        assert_eq!(
4152            init_fight_attack_mod_deltas(true, false, Some(3), None, 3000),
4153            vec![600]
4154        );
4155        // Boss talent alone behaves the same.
4156        assert_eq!(
4157            init_fight_attack_mod_deltas(false, true, None, Some(2), 3000),
4158            vec![400]
4159        );
4160        // A dungeon bossfight stacks both talents in a single increment.
4161        assert_eq!(
4162            init_fight_attack_mod_deltas(true, true, Some(3), Some(2), 3000),
4163            vec![1000]
4164        );
4165        // No talents → no attack.mod events at all.
4166        assert_eq!(
4167            init_fight_attack_mod_deltas(true, true, None, None, 3000),
4168            Vec::<i64>::new()
4169        );
4170    }
4171
4172    // -----------------------------------------------------------------------
4173    // Effect Stone consumption sites (B2).
4174    //
4175    // A trigger fire arms these two attributes on the player; the fight
4176    // primitives are what spend them, because "the NEXT attack" is only
4177    // knowable here. Both sites read `&Entity` and consume through the sink, in
4178    // the same style as the shield pool.
4179    // -----------------------------------------------------------------------
4180
4181    /// Drives `attack` `swings` times against ONE caster snapshot and ONE sink,
4182    /// which is exactly the shape of a multi-target dispatch (`cone_strike`,
4183    /// Rewind, War Cry, Holy Nova, the projectile band all call `run_attack` in
4184    /// a loop over `band_targets` inside a single dispatch).
4185    fn aoe_dispatch(bonus_permyriad: i64, swings: usize) -> (Vec<i64>, Vec<OverlordEvent>) {
4186        use rand::SeedableRng;
4187
4188        let lookups = damaging_lookups();
4189        let mut caster = entity_at(EntityTeam::Ally, 1);
4190        caster.hp = 100;
4191        caster.max_hp = 100;
4192        caster.attributes.add("attack", 1_000);
4193        if bonus_permyriad != 0 {
4194            caster
4195                .attributes
4196                .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, bonus_permyriad);
4197            // v0.2: the bonus is charge-based (`EF-C05` arms one charge,
4198            // `EF-R06` three, `EF-E07` five), so arming a magnitude alone is
4199            // not an armed bonus any more.
4200            caster
4201                .attributes
4202                .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4203        }
4204
4205        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4206        let mut sink = NativeSink::default();
4207        let mut dealt = Vec::new();
4208        for i in 0..swings {
4209            let mut target = entity_at(EntityTeam::Enemy, 2 + i as i64);
4210            target.hp = 1_000_000;
4211            target.max_hp = 1_000_000;
4212            dealt.push(
4213                attack(
4214                    &mut sink,
4215                    &rng,
4216                    &lookups,
4217                    &mut NoopEffectCb,
4218                    caster.id,
4219                    &caster,
4220                    &target,
4221                    &AttackParams {
4222                        is_crit: Some(false),
4223                        ..AttackParams::default_power()
4224                    },
4225                    CombatSource::Other,
4226                )
4227                .unwrap()
4228                .expect("every swing must land"),
4229            );
4230        }
4231        (dealt, sink.events)
4232    }
4233
4234    /// The design is "следующая атака +20%" — ONE attack. A multi-target
4235    /// ability calls `attack` once per target against the same `&Entity`
4236    /// snapshot inside one dispatch, so without the per-dispatch claim the read
4237    /// would boost the whole cone and emit one consume per target, driving the
4238    /// attribute to −4000 and silently swallowing the next two arms.
4239    #[test]
4240    fn a_next_attack_bonus_boosts_only_the_first_swing_of_an_aoe_dispatch() {
4241        let (plain, _) = aoe_dispatch(0, 3);
4242        let (boosted, events) = aoe_dispatch(2_000, 3);
4243
4244        assert!(
4245            (boosted[0] as f64 - plain[0] as f64 * 1.2).abs() <= 1.0,
4246            "the first target takes the +20%: {} -> {}",
4247            plain[0],
4248            boosted[0]
4249        );
4250        assert_eq!(
4251            &boosted[1..],
4252            &plain[1..],
4253            "every later target of the same cast must be unboosted"
4254        );
4255
4256        let consumed: Vec<i64> = events
4257            .iter()
4258            .filter_map(|event| match event {
4259                OverlordEvent::EntityIncrAttribute {
4260                    attribute, delta, ..
4261                } if attribute == crate::mechanics::stones::NEXT_ATTACK_BONUS => Some(*delta),
4262                _ => None,
4263            })
4264            .collect();
4265        assert_eq!(
4266            consumed,
4267            vec![-2_000],
4268            "exactly one consume per dispatch — never one per target"
4269        );
4270    }
4271
4272    /// The claim belongs to the dispatch, not to the scope: a producer running
4273    /// inside one may not mint itself a second copy of the same one-shot.
4274    #[test]
4275    fn a_producer_scope_cannot_reclaim_the_dispatchs_one_shot() {
4276        let mut sink = NativeSink::default();
4277        assert_eq!(
4278            sink.claim_once(OnceSlot::DamageBonus, 2_000),
4279            2_000,
4280            "the first claim wins"
4281        );
4282        assert_eq!(
4283            OriginSink::proc(&mut sink).claim_once(OnceSlot::DamageBonus, 2_000),
4284            0,
4285            "a Proc scope inside the same dispatch must find it already spent"
4286        );
4287        assert_eq!(sink.claim_once(OnceSlot::DamageBonus, 2_000), 0);
4288
4289        // A fresh dispatch gets a fresh budget.
4290        let mut next = NativeSink::default();
4291        assert_eq!(next.claim_once(OnceSlot::DamageBonus, 2_000), 2_000);
4292    }
4293
4294    /// The budgets are independent. One shared latch meant a swing that claimed
4295    /// the crit chance handed the damage bonus a silent zero — a player with
4296    /// both `EF-C06` and `EF-C05` armed lost the one they armed second.
4297    #[test]
4298    fn the_one_shot_budgets_do_not_take_each_others_latch() {
4299        let mut sink = NativeSink::default();
4300        assert_eq!(sink.claim_once(OnceSlot::CritChance, 1_500), 1_500);
4301        assert_eq!(
4302            sink.claim_once(OnceSlot::DamageBonus, 2_000),
4303            2_000,
4304            "the crit claim must not have spent the damage budget"
4305        );
4306        assert_eq!(
4307            sink.claim_once(OnceSlot::LawArm, 3_000),
4308            3_000,
4309            "nor the law arm's"
4310        );
4311        assert_eq!(
4312            sink.claim_once(OnceSlot::LawThisArm, 4_000),
4313            4_000,
4314            "nor the this-strike arm's — `RL-01` and `RL-02` can be armed together"
4315        );
4316
4317        // Each is still one-shot in its own right.
4318        assert_eq!(sink.claim_once(OnceSlot::CritChance, 1_500), 0);
4319        assert_eq!(sink.claim_once(OnceSlot::DamageBonus, 2_000), 0);
4320        assert_eq!(sink.claim_once(OnceSlot::LawArm, 3_000), 0);
4321        assert_eq!(sink.claim_once(OnceSlot::LawThisArm, 4_000), 0);
4322    }
4323
4324    /// Lookups with a `received_damage` base, so `damage_entity` actually lands
4325    /// a hit instead of taking the loud missing-base path.
4326    fn damaging_lookups() -> ContentLookups {
4327        let rd_id = uuid::Uuid::new_v4();
4328        let mut lookups = ContentLookups::default();
4329        lookups
4330            .attribute_by_code
4331            .insert("received_damage".to_string(), rd_id);
4332        lookups.attribute_base_value.insert(rd_id, 10000.0);
4333        lookups
4334    }
4335
4336    /// One attack from a caster carrying `bonus_permyriad` of armed stone bonus.
4337    /// Returns the damage dealt plus every event the swing emitted.
4338    fn attack_with_armed_bonus(bonus_permyriad: i64) -> (Option<i64>, Vec<OverlordEvent>) {
4339        use rand::SeedableRng;
4340
4341        let lookups = damaging_lookups();
4342        let mut caster = entity_at(EntityTeam::Ally, 1);
4343        caster.hp = 100;
4344        caster.max_hp = 100;
4345        caster.attributes.add("attack", 1_000);
4346        if bonus_permyriad != 0 {
4347            caster
4348                .attributes
4349                .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, bonus_permyriad);
4350            // v0.2: the bonus is charge-based (`EF-C05` arms one charge,
4351            // `EF-R06` three, `EF-E07` five), so arming a magnitude alone is
4352            // not an armed bonus any more.
4353            caster
4354                .attributes
4355                .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4356        }
4357        let mut target = entity_at(EntityTeam::Enemy, 2);
4358        target.hp = 1_000_000;
4359        target.max_hp = 1_000_000;
4360
4361        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4362        let mut sink = NativeSink::default();
4363        let dealt = attack(
4364            &mut sink,
4365            &rng,
4366            &lookups,
4367            &mut NoopEffectCb,
4368            caster.id,
4369            &caster,
4370            &target,
4371            &AttackParams {
4372                // Crit forced off: the bonus must be measurable against a fixed
4373                // baseline, not against a coin flip.
4374                is_crit: Some(false),
4375                ..AttackParams::default_power()
4376            },
4377            CombatSource::Other,
4378        )
4379        .unwrap();
4380        (dealt, sink.events)
4381    }
4382
4383    /// The armed bonus scales the swing and is spent by it — exactly once.
4384    #[test]
4385    fn a_next_attack_bonus_scales_the_swing_and_is_consumed() {
4386        let (plain, plain_events) = attack_with_armed_bonus(0);
4387        let (boosted, boosted_events) = attack_with_armed_bonus(2_000);
4388
4389        let plain = plain.expect("the baseline swing must land");
4390        let boosted = boosted.expect("the boosted swing must land");
4391        assert!(
4392            (boosted as f64 - plain as f64 * 1.2).abs() <= 1.0,
4393            "+20% armed must deal ~20% more: {plain} -> {boosted}"
4394        );
4395
4396        let consumed: Vec<i64> = boosted_events
4397            .iter()
4398            .filter_map(|event| match event {
4399                OverlordEvent::EntityIncrAttribute {
4400                    attribute, delta, ..
4401                } if attribute == crate::mechanics::stones::NEXT_ATTACK_BONUS => Some(*delta),
4402                _ => None,
4403            })
4404            .collect();
4405        assert_eq!(
4406            consumed,
4407            vec![-2_000],
4408            "the armed bonus must be spent by the swing that used it"
4409        );
4410        assert!(
4411            !plain_events.iter().any(|event| matches!(
4412                event,
4413                OverlordEvent::EntityIncrAttribute { attribute, .. }
4414                    if attribute == crate::mechanics::stones::NEXT_ATTACK_BONUS
4415            )),
4416            "an unarmed swing must not touch the bonus at all"
4417        );
4418    }
4419
4420    /// All three one-shot damage budgets on ONE swing: the stone's charge-gated
4421    /// bonus, the law's family arm, and the law's this-strike arm.
4422    ///
4423    /// They are owned by two subsystems and each has its own latch and its own
4424    /// sink decrement, but they meet here and are summed into a single
4425    /// `dmg_mod`. Every other test arms exactly one of the three, which is how a
4426    /// shared `claim_once` latch — where the crit claim silently swallowed the
4427    /// damage claim — stayed invisible.
4428    #[test]
4429    fn the_three_armed_budgets_stack_on_one_swing_and_each_is_spent_once() {
4430        use rand::SeedableRng;
4431
4432        let swing = |arm_all: bool| {
4433            let lookups = damaging_lookups();
4434            let mut caster = entity_at(EntityTeam::Ally, 1);
4435            caster.hp = 100;
4436            caster.max_hp = 100;
4437            caster.attributes.add("attack", 1_000);
4438            if arm_all {
4439                // Stone side: magnitude + charge, +20%.
4440                caster
4441                    .attributes
4442                    .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, 2_000);
4443                caster
4444                    .attributes
4445                    .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4446                // Law side: the Basic-family arm (+30%) and the this-strike arm
4447                // (+50%). The cast kind is what selects the family arm.
4448                crate::logic::combat_facts::record_cast_kind(
4449                    &mut caster,
4450                    crate::logic::combat_facts::CastKind::Basic,
4451                );
4452                caster
4453                    .attributes
4454                    .add(crate::mechanics::cores::ARM_BASIC, 3_000);
4455                caster
4456                    .attributes
4457                    .add(crate::mechanics::cores::ARM_THIS, 5_000);
4458            }
4459            let mut target = entity_at(EntityTeam::Enemy, 2);
4460            target.hp = 1_000_000;
4461            target.max_hp = 1_000_000;
4462
4463            let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4464            let mut sink = NativeSink::default();
4465            let dealt = attack(
4466                &mut sink,
4467                &rng,
4468                &lookups,
4469                &mut NoopEffectCb,
4470                caster.id,
4471                &caster,
4472                &target,
4473                &AttackParams {
4474                    is_crit: Some(false),
4475                    ..AttackParams::default_power()
4476                },
4477                CombatSource::Other,
4478            )
4479            .unwrap();
4480            (dealt, sink.events)
4481        };
4482
4483        let (plain, _) = swing(false);
4484        let (boosted, events) = swing(true);
4485        let plain = plain.expect("the baseline swing must land");
4486        let boosted = boosted.expect("the boosted swing must land");
4487
4488        // 20% + 30% + 50% is one +100% multiplier, not three multiplications.
4489        assert!(
4490            (boosted as f64 - plain as f64 * 2.0).abs() <= 1.0,
4491            "the three arms must sum into one multiplier: {plain} -> {boosted}"
4492        );
4493
4494        let spent = |key: &str| -> Vec<i64> {
4495            events
4496                .iter()
4497                .filter_map(|event| match event {
4498                    OverlordEvent::EntityIncrAttribute {
4499                        attribute, delta, ..
4500                    } if attribute == key => Some(*delta),
4501                    _ => None,
4502                })
4503                .collect()
4504        };
4505        assert_eq!(
4506            spent(crate::mechanics::stones::NEXT_ATTACK_BONUS),
4507            vec![-2_000],
4508            "the stone bonus is spent once"
4509        );
4510        assert_eq!(
4511            spent(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES),
4512            vec![-1],
4513            "and gives up exactly one charge"
4514        );
4515        assert_eq!(
4516            spent(crate::mechanics::cores::ARM_BASIC),
4517            vec![-3_000],
4518            "the law's family arm is spent once"
4519        );
4520        assert_eq!(
4521            spent(crate::mechanics::cores::ARM_THIS),
4522            vec![-5_000],
4523            "and so is the this-strike arm"
4524        );
4525    }
4526
4527    /// The family arm the cast kind does NOT select stays untouched, even while
4528    /// the other two budgets are being spent around it.
4529    #[test]
4530    fn a_basic_swing_leaves_the_skill_arm_alone_while_spending_the_others() {
4531        use rand::SeedableRng;
4532
4533        let lookups = damaging_lookups();
4534        let mut caster = entity_at(EntityTeam::Ally, 1);
4535        caster.hp = 100;
4536        caster.max_hp = 100;
4537        caster.attributes.add("attack", 1_000);
4538        crate::logic::combat_facts::record_cast_kind(
4539            &mut caster,
4540            crate::logic::combat_facts::CastKind::Basic,
4541        );
4542        caster
4543            .attributes
4544            .add(crate::mechanics::cores::ARM_BASIC, 3_000);
4545        caster
4546            .attributes
4547            .add(crate::mechanics::cores::ARM_SKILL, 9_000);
4548
4549        let mut target = entity_at(EntityTeam::Enemy, 2);
4550        target.hp = 1_000_000;
4551        target.max_hp = 1_000_000;
4552
4553        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4554        let mut sink = NativeSink::default();
4555        attack(
4556            &mut sink,
4557            &rng,
4558            &lookups,
4559            &mut NoopEffectCb,
4560            caster.id,
4561            &caster,
4562            &target,
4563            &AttackParams {
4564                is_crit: Some(false),
4565                ..AttackParams::default_power()
4566            },
4567            CombatSource::Other,
4568        )
4569        .unwrap();
4570
4571        assert!(
4572            sink.events.iter().any(|event| matches!(
4573                event,
4574                OverlordEvent::EntityIncrAttribute { attribute, delta, .. }
4575                    if attribute == crate::mechanics::cores::ARM_BASIC && *delta == -3_000
4576            )),
4577            "the Basic arm belongs to this swing"
4578        );
4579        assert!(
4580            !sink.events.iter().any(|event| matches!(
4581                event,
4582                OverlordEvent::EntityIncrAttribute { attribute, .. }
4583                    if attribute == crate::mechanics::cores::ARM_SKILL
4584            )),
4585            "the Skill arm must be untouched by a Basic Attack"
4586        );
4587    }
4588
4589    /// A magnitude with no charge behind it is NOT an armed bonus.
4590    ///
4591    /// This is the gate every producer writing into the shared key has to
4592    /// satisfy — including `logic::laws`, which lends its own arm in here for
4593    /// the duration of one cast. Writing the magnitude alone leaves the swing
4594    /// unchanged and the value stranded, which is exactly how the law-side
4595    /// `NextAttackDamageBonus` went silently dead when the key became
4596    /// charge-gated.
4597    #[test]
4598    fn a_magnitude_without_a_charge_does_not_scale_the_swing() {
4599        use rand::SeedableRng;
4600
4601        let swing = |with_charge: bool| {
4602            let lookups = damaging_lookups();
4603            let mut caster = entity_at(EntityTeam::Ally, 1);
4604            caster.hp = 100;
4605            caster.max_hp = 100;
4606            caster.attributes.add("attack", 1_000);
4607            caster
4608                .attributes
4609                .add(crate::mechanics::stones::NEXT_ATTACK_BONUS, 5_000);
4610            if with_charge {
4611                caster
4612                    .attributes
4613                    .add(crate::mechanics::stones::NEXT_ATTACK_BONUS_CHARGES, 1);
4614            }
4615            let mut target = entity_at(EntityTeam::Enemy, 2);
4616            target.hp = 1_000_000;
4617            target.max_hp = 1_000_000;
4618
4619            let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(7));
4620            let mut sink = NativeSink::default();
4621            attack(
4622                &mut sink,
4623                &rng,
4624                &lookups,
4625                &mut NoopEffectCb,
4626                caster.id,
4627                &caster,
4628                &target,
4629                &AttackParams {
4630                    is_crit: Some(false),
4631                    ..AttackParams::default_power()
4632                },
4633                CombatSource::Other,
4634            )
4635            .unwrap()
4636            .expect("the swing must land")
4637        };
4638
4639        let (plain, _) = attack_with_armed_bonus(0);
4640        let plain = plain.expect("the baseline swing must land");
4641        assert_eq!(
4642            swing(false),
4643            plain,
4644            "a magnitude with no charge must leave the swing at its baseline"
4645        );
4646        assert!(
4647            (swing(true) as f64 - plain as f64 * 1.5).abs() <= 1.0,
4648            "the same magnitude WITH a charge must be worth +50%"
4649        );
4650    }
4651
4652    /// Extra swings from `NextAttackDoubleHit`.
4653    ///
4654    /// They are queued onto THIS cast rather than re-dispatched as another
4655    /// `StartCastAbility`: that path goes through `push_start_cast_replacing`'s
4656    /// `max(existing, now + cooldown)` and would push the entity's next natural
4657    /// swing back instead of adding one. And they are queued `Proc`, so the
4658    /// damage they land later cannot re-enter the trigger that granted them.
4659    #[test]
4660    fn an_armed_extra_swing_is_added_to_this_cast_as_proc_and_is_consumed() {
4661        use rand::SeedableRng;
4662
4663        let config = configs::tests_game_config::generate_game_config_for_tests();
4664        let lookups = ContentLookups::default();
4665        let rng = GameRng::new(rand::rngs::StdRng::seed_from_u64(0));
4666        let ability = Ability {
4667            template_id: hero_ability_id(),
4668            level: 1,
4669            shards_amount: 0,
4670        };
4671        let mut target = entity_at(EntityTeam::Enemy, 2);
4672        target.hp = 100;
4673
4674        let swing_origins = |caster: &Entity| -> (Vec<CombatEventOrigin>, Vec<i64>) {
4675            let mut sink = NativeSink::default();
4676            cast(
4677                &mut sink,
4678                &rng,
4679                &config,
4680                &lookups,
4681                caster,
4682                &ability,
4683                std::slice::from_ref(&target),
4684                1,
4685            )
4686            .unwrap();
4687            let consumed = sink
4688                .events
4689                .iter()
4690                .filter_map(|event| match event {
4691                    OverlordEvent::EntityIncrAttribute {
4692                        attribute, delta, ..
4693                    } if attribute == crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS => {
4694                        Some(*delta)
4695                    }
4696                    _ => None,
4697                })
4698                .collect();
4699            (sink.casts.iter().map(|c| c.origin).collect(), consumed)
4700        };
4701
4702        let mut plain = entity_at(EntityTeam::Ally, 1);
4703        plain.hp = 100;
4704        let (origins, consumed) = swing_origins(&plain);
4705        assert_eq!(
4706            origins,
4707            vec![CombatEventOrigin::Core],
4708            "an unarmed cast is one Core swing, exactly as before"
4709        );
4710        assert!(consumed.is_empty(), "nothing to consume");
4711
4712        let mut armed = plain.clone();
4713        armed
4714            .attributes
4715            .add(crate::mechanics::stones::NEXT_ATTACK_EXTRA_HITS, 1);
4716        let (origins, consumed) = swing_origins(&armed);
4717        assert_eq!(
4718            origins,
4719            vec![CombatEventOrigin::Core, CombatEventOrigin::Proc],
4720            "the natural swing stays Core and the granted one is Proc"
4721        );
4722        assert_eq!(
4723            consumed,
4724            vec![-1],
4725            "the arm is spent by the cast that used it"
4726        );
4727    }
4728
4729    /// `armor.mod` composes exactly like `hp.mod` / `attack.mod`: permyriad
4730    /// (`+1000 == +10%`), multiplicative over `(armor + armor.bonus)`, and
4731    /// floored at `MIN_STAT_MOD_MULT`. The point of the attribute is that a
4732    /// "+10% defense" grant SCALES with the wearer's armor rating instead of
4733    /// adding a fixed number of points — so the same `armor.mod` is worth ten
4734    /// times as much on a character with ten times the rating.
4735    #[test]
4736    fn armor_mod_composes_as_a_percentage_of_the_armor_rating() {
4737        let lookups = ContentLookups::default();
4738        let armored = |rating: i64, armor_mod: i64, bonus: i64| {
4739            let mut entity = Entity {
4740                id: uuid::Uuid::new_v4(),
4741                ..Default::default()
4742            };
4743            entity.attributes.add("armor", rating);
4744            entity.attributes.add("armor.bonus", bonus);
4745            entity.attributes.add("armor.mod", armor_mod);
4746            get_entity_stat(&lookups, &entity, "armor")
4747        };
4748
4749        // Stone Skin's shape: +1000 permyriad == +10% of the rating.
4750        assert!((armored(2_000, 1_000, 0) - 2_200.0).abs() < 1e-9);
4751        // Enhanced: +1600 == +16%.
4752        assert!((armored(2_000, 1_600, 0) - 2_320.0).abs() < 1e-9);
4753        // It SCALES: ten times the rating, ten times the gain (200 -> 2000),
4754        // which is precisely what a flat +1000 armor rating could not do.
4755        assert!((armored(20_000, 1_000, 0) - 22_000.0).abs() < 1e-9);
4756        // `.bonus` is additive first, then the multiplier — same order as hp.
4757        assert!((armored(2_000, 1_000, 500) - 2_750.0).abs() < 1e-9);
4758        // Floor: a −500% stack clamps to MIN_STAT_MOD_MULT rather than going ≤0.
4759        let floored = armored(2_000, -50_000, 0);
4760        assert!(
4761            (floored - 2_000.0 * balance::MIN_STAT_MOD_MULT).abs() < 1e-9,
4762            "armor.mod must floor at MIN_STAT_MOD_MULT (×0.05), got {floored}"
4763        );
4764        // No mod → unchanged.
4765        assert!((armored(2_000, 0, 0) - 2_000.0).abs() < 1e-9);
4766    }
4767}