overlord_event_system/logic/
laws.rs

1//! Laws v0.2 — the combat runtime of `condition → effect + Resonance`.
2//!
3//! A law is no longer a stat block that happens to be side-gated: it watches
4//! for one Core occurrence, runs one action, and drops its Resonance into its
5//! bridge. This module is the whole of that behaviour; the arithmetic (charge
6//! capacity, amplification, upgrade scaling) lives in
7//! [`crate::mechanics::cores`] and the shapes in [`essences::cores`].
8//!
9//! Three entry points, because the three condition families need three
10//! different moments:
11//!
12//! * [`OverlordLogic::apply_law_pre_cast`] runs from `handle_cast_ability`
13//!   BEFORE the cast resolves. `RL-01` reads "every 5th Basic Attack: THIS
14//!   strike deals +100%", which is only expressible if the law fires before its
15//!   own strike computes damage.
16//! * [`OverlordLogic::apply_law_reactions`] hangs off `apply_success_hooks`, so
17//!   it sees the world an outcome actually produced — crits, dodges, hits
18//!   taken, kills, HP thresholds, AoE target counts.
19//! * [`OverlordLogic::apply_flip_to_laws`] runs on a completed flip: it
20//!   delivers the bridge charge, re-folds attributes, and only THEN fires the
21//!   "phase started" laws (`RL-12`, `FL-11`). That order is acceptance #9 —
22//!   reversed, those two would be the only laws in the catalog a bridge can
23//!   never reach, since their single fire happens exactly at that moment.
24//!
25//! **Anti-loop.** Every condition is gated on
26//! [`essences::combat_origin::CombatEventOrigin::is_core`] and every combat
27//! outcome a law produces is emitted `Proc`. `FL-09` ("a Core action killed an
28//! enemy → 150% Attack to every enemy") therefore cannot chain on the kills its
29//! own blast makes: those arrive on `Proc` damage, which is not a condition at
30//! all. A genuine Basic Attack or original Skill stays Core even when a law
31//! changed its magnitude — a law arms an attribute, it never re-emits the
32//! action.
33//!
34//! **All per-fight law state lives in the entity's `attributes`,** the same
35//! idiom the stones runtime uses (`stone.*`). Keys are namespaced `law.*` and
36//! tagged by `side + slot index` rather than by law id, so they stay short and
37//! are stable for a whole fight. Mid-fight build edits update the durable Core
38//! state only; the entity-local snapshot continues driving this fight.
39//!
40//! **Known edge — the event that crosses the gauge belongs to the INCOMING
41//! phase.** A producer advances the entity's own `flip_state` while it resolves
42//! and emits `GlobalFlip` as a follow-up event, so by the time the law hook runs
43//! the outgoing side is already down: the flipping event is evaluated against
44//! the incoming side's laws, and against them at their PRE-delivery
45//! amplification. One action per flip is affected. It is not a loop and not a
46//! leak (the phase-start laws are still fired exactly once, by the flip handler,
47//! after delivery); moving it would mean deferring the side change out of the
48//! producers, which is flip machinery rather than law machinery.
49//!
50//! There is ONE producer:
51//!
52//! * `logic::stones::accumulate_trigger_gauge` — a Trigger Stone fire. This one
53//!   is structural rather than incidental: `apply_success_hooks` runs the stones
54//!   hook immediately before the laws hook, so a fire that crosses the gauge
55//!   always hands the rest of that event to the incoming side. Pinned by
56//!   `test_cores::a_stone_driven_flip_hands_the_same_event_to_the_incoming_sides_laws`.
57
58use std::collections::BTreeSet;
59
60use essences::abilities::{AbilityId, AbilityTag};
61use essences::combat_origin::CombatEventOrigin;
62use essences::cores::{LawCondition, LawEffect, LawTemplate, LawTemplateId};
63use essences::entity::{Entity, EntityAttributes, EntityId};
64use essences::fight_breakdown::CombatSource;
65use essences::flip::WorldSide;
66
67use crate::game_config_helpers::GameConfigLookup;
68use crate::logic::combat_facts::{self, CastKind, CombatFact};
69use crate::mechanics::{
70    artifacts as artifact_mech, balance, cores as mech, fight::get_entity_stat,
71    pet_facets as pet_mech,
72};
73use crate::{
74    event::OverlordEvent,
75    logic::{EventPluginized, handler::OverlordLogic},
76    state::OverlordState,
77};
78
79/// Damage bonus armed for the owner's next BASIC attack, permyriad. Defined in
80/// [`crate::mechanics::cores`], where `mechanics::fight::attack` reads it.
81pub use crate::mechanics::cores::{ARM_BASIC, ARM_SKILL, ARM_THIS};
82
83/// Splash armed for the owner's next Basic Attack, permyriad of Attack. Spent
84/// at that attack's pre-cast hook against every OTHER living enemy.
85pub const ARM_SPLASH: &str = "law.arm_splash";
86
87/// Echo armed for the owner's next original Skill, permyriad of Attack.
88pub const ARM_ECHO: &str = "law.arm_echo";
89
90/// Sequence number of the cast whose distinct targets are being counted.
91const CAST_SEQ: &str = "law.cast_seq";
92
93/// Tick that cast was dispatched on. Only hits on the same tick count.
94const CAST_TICK: &str = "law.cast_tick";
95
96/// Lowest HP the owner has been at this fight, in permyriad of max HP, so a
97/// threshold law fires on the CROSSING and not on every hit below it.
98const HP_WATERMARK: &str = "law.hp_watermark";
99
100const COUNTER_PREFIX: &str = "law.n.";
101const ONCE_PREFIX: &str = "law.once.";
102const TARGET_PREFIX: &str = "law.tgt.";
103const SKILL_SEEN_PREFIX: &str = "law.sk.";
104
105/// `VL-05 First Article`'s per-law, per-phase latch. Lives under
106/// [`ONCE_PREFIX`] so the flip's own sweep clears it — "once per phase" and not
107/// once per fight comes free.
108const FIRST_ARTICLE_PREFIX: &str = "fa.";
109
110/// `HL-04 Single Lesson`'s latch. One per phase for the whole stone, not per
111/// law: the stone "switches off until the Flip" once it has spent its wake.
112const SINGLE_LESSON_TAG: &str = "hl4";
113
114/// Per-law counter ("every N-th ..."), keyed by side + slot.
115fn counter_key(tag: &str) -> String {
116    format!("{COUNTER_PREFIX}{tag}")
117}
118
119/// Per-law "already fired this phase" latch, keyed by side + slot.
120fn once_key(tag: &str) -> String {
121    format!("{ONCE_PREFIX}{tag}")
122}
123
124/// Marks one victim of the cast identified by [`CAST_SEQ`].
125fn target_key(victim: EntityId) -> String {
126    format!("{TARGET_PREFIX}{victim}")
127}
128
129/// Tick at which `ability_id` last resolved as a Core original Skill.
130fn skill_seen_key(ability_id: AbilityId) -> String {
131    format!("{SKILL_SEEN_PREFIX}{ability_id}")
132}
133
134/// Short, stable per-law bookkeeping tag: `R2` = Real core, slot 2.
135fn slot_tag(side: WorldSide, slot_index: i64) -> String {
136    let side = match side {
137        WorldSide::Real => 'R',
138        WorldSide::Fantasy => 'F',
139    };
140    format!("{side}{slot_index}")
141}
142
143fn attr(attributes: &EntityAttributes, key: &str) -> i64 {
144    attributes.0.get(key).copied().unwrap_or(0)
145}
146
147/// One slotted, enabled law with everything the runtime needs about it,
148/// resolved once so the config borrow can end.
149struct LiveLaw {
150    template: LawTemplate,
151    level: i64,
152    tag: String,
153    /// Every multiplier that lands on the effect's NUMBERS, already combined:
154    /// bridge amplification, the artifact's Visible Law socket, and — for a law
155    /// woken while its side is down — the share the waking source offers.
156    ///
157    /// Resonance deliberately does not pass through here (acceptance #8), and
158    /// neither does any condition parameter.
159    amplification: f64,
160    /// `true` when this law is running while its side is DOWN. A hidden run
161    /// banks no Resonance, fills no bridge and feeds no gauge (design §3.4);
162    /// carrying the fact on the law is what keeps every fire site from having
163    /// to remember it.
164    hidden: bool,
165}
166
167impl LiveLaw {
168    fn id(&self) -> LawTemplateId {
169        self.template.id
170    }
171
172    /// The effect's number after the law's own upgrade scaling and every
173    /// multiplier in [`Self::amplification`] — the single place any of them is
174    /// applied to an effect.
175    fn effect_value(&self) -> i64 {
176        mech::law_effect_number(self.template.effect_value, self.level, self.amplification)
177    }
178
179    /// Duration is one of the effect's numbers for AMPLIFICATION — bridge
180    /// amplification stretches it ("усиливаются урон, лечение, длительность,
181    /// снижение кулдауна", §3) — but NOT for rank: law rank scales the
182    /// magnitude only, and duration never grows with level (BAL-029).
183    fn duration_ticks(&self) -> u64 {
184        ((self.template.effect_duration_ticks as f64 * self.amplification).round() as i64).max(0)
185            as u64
186    }
187}
188
189/// The Core occurrences a law can react to after the fact.
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191enum Reaction {
192    Crit,
193    Dodge,
194    HitTaken,
195    Kill,
196    HpDropped,
197    /// Distinct targets the owner's current cast has now damaged.
198    CastTargets(i64),
199}
200
201/// Everything an effect may need, read once before any mutation.
202struct LawCtx {
203    attack: f64,
204    max_hp: u64,
205    speed: i64,
206    /// Living opponents of the law owner, nearest first.
207    enemies: Vec<EntityId>,
208    /// Remaining skill cooldowns as `(ability_id, deadline_tick)`, class Basic
209    /// Attacks excluded — a law that "returns a Skill cooldown" must never hand
210    /// back the auto-attack.
211    skill_cooldowns: Vec<(AbilityId, u64)>,
212    now: u64,
213}
214
215/// One law that passed its condition, plus the counter writes its evaluation
216/// implies. Counters are written for EVERY law that watches the occurrence, not
217/// only for the ones that fired, or "every 5th" could never reach five.
218struct Evaluation {
219    fired: Vec<LiveLaw>,
220    counters: Vec<(String, i64)>,
221}
222
223impl OverlordLogic {
224    /// Whether `ability_id` is one of `entity`'s class Basic Attacks. This —
225    /// not `AbilityCastType`, which is presentation — is the server's notion of
226    /// "Basic Attack vs original Skill"; it is the same predicate
227    /// `StartCastAbilityResult::into_entity_action` uses to choose between
228    /// `CastAbility` and `CastBasicAbility`.
229    fn is_basic_ability(
230        game_config: &configs::game_config::GameConfig,
231        entity: &Entity,
232        ability_id: AbilityId,
233    ) -> bool {
234        combat_facts::cast_kind(game_config, entity, ability_id) == CastKind::Basic
235    }
236
237    /// The active-side, slotted, enabled laws of one combatant, with their
238    /// current bridge amplification.
239    ///
240    /// Empty for a mob, an arena filler bot and anyone below the cores unlock
241    /// chapter (all carry an empty `law_cores`), and it skips every law whose
242    /// side is currently down — which is what makes "a hidden law's condition
243    /// is not even evaluated" (acceptance #3) structural rather than a rule at
244    /// each call site.
245    fn live_laws(
246        game_config: &configs::game_config::GameConfig,
247        entity: &Entity,
248        mods: &artifact_mech::LawColumnMods,
249    ) -> Vec<LiveLaw> {
250        let Some(active_side) = entity.flip_state.map(|flip| flip.active_side) else {
251            return Vec::new();
252        };
253        Self::laws_of_side(game_config, entity, active_side, mods)
254    }
255
256    /// The slotted, enabled laws of ONE side, with every effect multiplier that
257    /// applies to them resolved.
258    ///
259    /// Called twice: with the active side, which is the ordinary law runtime,
260    /// and — only when the artifact offers a wake — with the hidden side, whose
261    /// laws are ordinarily not evaluated at all. The share a hidden law runs at
262    /// is applied by the caller, because it is decided per law by the
263    /// intersection rule.
264    fn laws_of_side(
265        game_config: &configs::game_config::GameConfig,
266        entity: &Entity,
267        side: WorldSide,
268        mods: &artifact_mech::LawColumnMods,
269    ) -> Vec<LiveLaw> {
270        if entity.law_cores.laws.is_empty() {
271            return Vec::new();
272        }
273        let hidden = entity
274            .flip_state
275            .is_some_and(|flip| flip.active_side != side);
276
277        let mut laws = Vec::new();
278        for owned in entity.law_cores.slotted_laws() {
279            let Some(template) = mech::law_template(game_config, owned.template_id) else {
280                continue;
281            };
282            if template.side != side || !template.is_active {
283                continue;
284            }
285            let Some(slot_index) = owned.slot_index else {
286                continue;
287            };
288            // Bridge amplification and the Visible Law socket are two separate
289            // multipliers on the same numbers, so they compose rather than
290            // override: a `VL-02` law on a full bridge is worth both.
291            let amplification = mech::law_power_multiplier(&entity.law_bridges, owned.template_id)
292                * mods.effect_multiplier(
293                    owned.template_id,
294                    &entity.law_bridges,
295                    entity.law_bridges.capacity_hundredths,
296                );
297            laws.push(LiveLaw {
298                template: template.clone(),
299                level: owned.level.max(1),
300                tag: slot_tag(template.side, slot_index),
301                amplification,
302                hidden,
303            });
304        }
305        laws
306    }
307
308    /// The artifact's right column for the OWNER of the laws being evaluated,
309    /// read once per hook — the same shape `live_aspect_in_socket` gives the
310    /// left column. Resolved per combatant, so a party ally's or a human PvP
311    /// opponent's own artifacts modify their laws and the local hero's never
312    /// leak onto someone else's build. Mods degrade to `NONE` for combatants
313    /// with no `CharacterState` (arena filler bots).
314    fn law_column_mods(
315        &self,
316        state: &OverlordState,
317        owner_id: EntityId,
318    ) -> artifact_mech::LawColumnMods {
319        let Some(build) = state.active_fight.as_ref().and_then(|fight| {
320            crate::entities::combatant_character_state(
321                &state.character_state,
322                &state.party,
323                &state.pvp_state,
324                fight,
325                owner_id,
326            )
327        }) else {
328            return artifact_mech::LawColumnMods::NONE;
329        };
330        artifact_mech::law_column_mods(&self.game_config.get(), &build.artifacts)
331    }
332
333    fn law_ctx(
334        &self,
335        game_config: &configs::game_config::GameConfig,
336        entities: &[Entity],
337        owner: &Entity,
338    ) -> LawCtx {
339        let mut enemies: Vec<&Entity> = entities
340            .iter()
341            .filter(|e| e.team != owner.team && e.hp > 0)
342            .collect();
343        enemies.sort_by_key(|e| {
344            (e.coordinates.x - owner.coordinates.x).abs()
345                + (e.coordinates.y - owner.coordinates.y).abs()
346        });
347
348        let skill_cooldowns = owner
349            .actions_queue
350            .start_cast_entries()
351            .into_iter()
352            .filter(|(ability_id, _)| !Self::is_basic_ability(game_config, owner, *ability_id))
353            .collect();
354
355        LawCtx {
356            attack: get_entity_stat(self.behaviors.lookups(), owner, "attack"),
357            max_hp: owner.max_hp,
358            speed: owner
359                .attributes
360                .speed_or_baseline(game_config.game_settings.baseline_speed),
361            enemies: enemies.into_iter().map(|e| e.id).collect(),
362            skill_cooldowns,
363            now: self.fight_clock.now(),
364        }
365    }
366
367    /// Laws whose condition is decided by the cast itself, evaluated BEFORE the
368    /// cast resolves so an effect can change the very action that triggered it.
369    ///
370    /// Returns the events the laws produced; the caller prepends them to the
371    /// cast's own output. Attribute arms are written straight onto the entity
372    /// instead of going through an event, because `mechanics::fight::attack`
373    /// reads them off the caster snapshot the ability script is about to be
374    /// handed — an event would land one action too late.
375    ///
376    /// A non-Core cast (a derived repeat, anything a modifier produced) returns
377    /// immediately: that is the anti-loop boundary for the pre-cast conditions.
378    pub fn apply_law_pre_cast(
379        &mut self,
380        state: &mut OverlordState,
381        caster_id: EntityId,
382        ability_id: AbilityId,
383        target_id: EntityId,
384    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
385        let mut events = Vec::new();
386        if !self.dispatch_origin().is_core() {
387            return events;
388        }
389        let game_config = self.game_config.get();
390
391        // Snapshot everything the evaluation needs, then drop the fight borrow:
392        // claiming a once-per-phase latch and writing counters both need `&mut
393        // state`, and an effect may dispatch work that re-enters this module.
394        let Some(fight) = state.active_fight.as_ref() else {
395            return events;
396        };
397        if fight.fight_ended || fight.fight_stopped {
398            return events;
399        }
400        let Some(owner) = fight.entities.iter().find(|e| e.id == caster_id) else {
401            return events;
402        };
403        if owner.law_cores.laws.is_empty() {
404            return events;
405        }
406        let is_basic = Self::is_basic_ability(&game_config, owner, ability_id);
407        let ctx = self.law_ctx(&game_config, &fight.entities, owner);
408        let mods = self.law_column_mods(state, caster_id);
409        let mut laws = Self::live_laws(&game_config, owner, &mods);
410        self.apply_wild_reading(state, owner, &mut laws);
411        let hidden_laws = self.hidden_laws(&game_config, owner, &mods);
412        let attributes = owner.attributes.clone();
413        let paid_mana_x100 = crate::logic::combat_facts::paid_mana_x100(owner);
414        let charges = owner.law_bridges.clone();
415        let ability_tags: Vec<AbilityTag> = game_config
416            .ability_template(ability_id)
417            .map(|template| template.tags.clone())
418            .unwrap_or_default();
419
420        // The distinct-skill window (`FL-12`) is measured from the per-ability
421        // record, which is written for every Core original Skill whether or not
422        // a law is watching — so a law slotted between fights never sees a
423        // half-filled window from the previous one.
424        let distinct_skills = if is_basic {
425            0
426        } else {
427            laws.iter()
428                .chain(hidden_laws.iter())
429                .filter(|law| law.template.condition == LawCondition::DistinctSkillsWithin)
430                .map(|law| law.template.condition_window_ticks.max(0) as u64)
431                .max()
432                .map_or(0, |window| {
433                    count_distinct_skills(&attributes, ctx.now, window, ability_id)
434                })
435        };
436
437        // "THIS attack" belongs to the cast about to resolve and to no other.
438        // The arm is only spent past the evasion gate, so a dodged trigger
439        // strike strands it; `run_law_effect` arms with `add`, so a later fire
440        // landing on a stranded value puts the sum of both on one swing.
441        // Clearing it here — before any law can arm it — bounds it to one cast.
442        self.clear_arm(state, caster_id, ARM_THIS);
443
444        let cast = PreCast {
445            is_basic,
446            ability_tags,
447            distinct_skills,
448            paid_mana_x100,
449        };
450        let mut evaluation = evaluate_pre_cast(laws, &attributes, &cast);
451        let hidden_evaluation = evaluate_pre_cast(hidden_laws, &attributes, &cast);
452        evaluation.counters.extend(hidden_evaluation.counters);
453
454        // Bookkeeping BEFORE the effects, exactly like the stones hook: an
455        // effect may dispatch work that comes back through here, and it must
456        // find the counters already advanced and the latches already taken.
457        self.write_counters(state, caster_id, &evaluation.counters);
458        if !is_basic {
459            self.open_cast_window(state, caster_id, ability_id, ctx.now);
460        }
461        let fired = self.claim_phase_latches(state, caster_id, evaluation.fired);
462        let woken = self.claim_hidden_wakes(state, caster_id, hidden_evaluation.fired, &mods);
463
464        // A basic attack spends what was armed for a basic attack; a skill
465        // spends the skill arm. `mechanics::fight::attack` picks between the
466        // two keys by the cast kind recorded on the caster before this cast
467        // resolves, so nothing has to be moved between subsystems and put back.
468        if is_basic {
469            events.extend(self.spend_arm_as_damage(
470                state,
471                caster_id,
472                ARM_SPLASH,
473                &ctx,
474                SplashShape::EveryOtherEnemy(target_id),
475            ));
476        } else {
477            events.extend(self.spend_arm_as_damage(
478                state,
479                caster_id,
480                ARM_ECHO,
481                &ctx,
482                SplashShape::SingleTarget(target_id),
483            ));
484        }
485
486        let fired = self.claim_first_article(state, caster_id, fired, &mods);
487        events.extend(self.fire_all(state, caster_id, fired, &ctx, &mods, &charges));
488        events.extend(self.fire_all(state, caster_id, woken, &ctx, &mods, &charges));
489        events
490    }
491
492    /// Runs a batch of laws that passed their conditions: the effect, the
493    /// Resonance it banks, and `HL-02 Linked Echo` where the artifact asks for
494    /// it.
495    ///
496    /// The Resonance step is skipped for a law running while its side is down —
497    /// design §3.4, the clause that keeps `ART-02` from doubling every bridge
498    /// and the flip tempo. It is read off [`LiveLaw::hidden`] rather than
499    /// re-derived here so there is exactly one place that decides it.
500    fn fire_all(
501        &mut self,
502        state: &mut OverlordState,
503        owner_id: EntityId,
504        fired: Vec<LiveLaw>,
505        ctx: &LawCtx,
506        mods: &artifact_mech::LawColumnMods,
507        charges: &essences::cores::LawBridgeCharges,
508    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
509        let mut events = Vec::new();
510        for law in &fired {
511            events.extend(self.run_law_effect(state, owner_id, law, ctx));
512            // BAL-029 `Three Spell Rule`: the combo window is fully consumed
513            // by its own proc — the next one needs three fresh distinct
514            // Skills, the proccing cast included.
515            if law.template.condition == LawCondition::DistinctSkillsWithin {
516                self.clear_skill_seen_marks(state, owner_id);
517            }
518            if !law.hidden {
519                self.bank_resonance(state, owner_id, law, mods);
520            }
521            // `HL-02` is the one wake that never checks the woken law's own
522            // condition: the chosen law firing IS the condition. It hangs off
523            // an ACTIVE fire, so it cannot chain off another hidden run.
524            if !law.hidden
525                && let Some((partner, share)) = mods.linked_echo(law.id(), charges)
526                && let Some(echo) = self.hidden_partner(state, owner_id, partner, share)
527            {
528                events.extend(self.run_law_effect(state, owner_id, &echo, ctx));
529            }
530        }
531        events
532    }
533
534    /// Builds the `LiveLaw` for a law woken by `HL-02`, at `share` of its
535    /// strength, or `None` when it is not slotted, not enabled, or its side is
536    /// currently UP (in which case it is running on its own already).
537    fn hidden_partner(
538        &self,
539        state: &OverlordState,
540        owner_id: EntityId,
541        partner: LawTemplateId,
542        share: f64,
543    ) -> Option<LiveLaw> {
544        if share <= 0.0 {
545            return None;
546        }
547        let game_config = self.game_config.get();
548        let owner = state
549            .active_fight
550            .as_ref()?
551            .entities
552            .iter()
553            .find(|e| e.id == owner_id)?;
554        let active_side = owner.flip_state?.active_side;
555        let owned = owner.law_cores.law(partner)?;
556        let slot_index = owned.slot_index?;
557        let template = mech::law_template(&game_config, partner)?;
558        if template.side == active_side || !template.is_active {
559            return None;
560        }
561        Some(LiveLaw {
562            template: template.clone(),
563            level: owned.level.max(1),
564            tag: slot_tag(template.side, slot_index),
565            amplification: mech::law_power_multiplier(&owner.law_bridges, partner) * share,
566            hidden: true,
567        })
568    }
569
570    /// The laws of the side that is currently DOWN — empty unless the artifact
571    /// offers a way to wake one.
572    ///
573    /// The gate matters beyond performance: evaluating hidden laws advances
574    /// their counters, so a player with no wake source would silently build up
575    /// "every 5th Basic Attack" progress on a side that cannot fire. Gating here
576    /// is what makes a player without the right column byte-identical to one
577    /// before this feature.
578    fn hidden_laws(
579        &self,
580        game_config: &configs::game_config::GameConfig,
581        owner: &Entity,
582        mods: &artifact_mech::LawColumnMods,
583    ) -> Vec<LiveLaw> {
584        if !mods.wakes_hidden_laws() {
585            return Vec::new();
586        }
587        let Some(active_side) = owner.flip_state.map(|flip| flip.active_side) else {
588            return Vec::new();
589        };
590        Self::laws_of_side(game_config, owner, active_side.flipped(), mods)
591    }
592
593    /// `VL-05 First Article`: the FIRST activation of every active-side law in a
594    /// phase applies its effect at the stone's share.
595    ///
596    /// A latch per law per phase, under the `law.once.` namespace so the flip's
597    /// own sweep clears it — that is the whole of "per phase, not per fight".
598    /// Resonance is deliberately left alone, which is what keeps the stone a
599    /// different shape from its socket neighbours rather than a bigger one; the
600    /// scaling therefore happens here, on the effect multiplier, and never
601    /// touches [`Self::bank_resonance`].
602    ///
603    /// Hidden runs are skipped: `VL-05` is a Visible Law stone and speaks for
604    /// the side that is up.
605    fn claim_first_article(
606        &self,
607        state: &mut OverlordState,
608        owner_id: EntityId,
609        candidates: Vec<LiveLaw>,
610        mods: &artifact_mech::LawColumnMods,
611    ) -> Vec<LiveLaw> {
612        let Some(share) = mods.first_activation_multiplier() else {
613            return candidates;
614        };
615        if candidates.is_empty() {
616            return candidates;
617        }
618        let Some(owner) = state
619            .active_fight
620            .as_mut()
621            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
622        else {
623            return Vec::new();
624        };
625        candidates
626            .into_iter()
627            .map(|mut law| {
628                if law.hidden {
629                    return law;
630                }
631                let key = once_key(&format!("{FIRST_ARTICLE_PREFIX}{}", law.tag));
632                if attr(&owner.attributes, &key) == 0 {
633                    owner.attributes.set(&key, 1);
634                    law.amplification *= share;
635                }
636                law
637            })
638            .collect()
639    }
640
641    /// Resolves the intersection rule (design §3) for every hidden law that
642    /// passed its condition, and scales the survivors by the share they were
643    /// woken at.
644    ///
645    /// Three of the four clauses live here:
646    ///
647    /// * §3.1 — each hidden law keeps its OWN counters and once-per-phase
648    ///   latches; they are keyed by side + slot, so they never collide with the
649    ///   active side's;
650    /// * §3.2 — one law takes at most ONE activation per Core event: the
651    ///   candidate list holds each law once and this pass emits each at most
652    ///   once;
653    /// * §3.3 — several sources may name one law and the LARGEST share wins,
654    ///   never the sum. `HL-04`'s once-per-phase share is only taken when it
655    ///   beats every free source AND its latch is still up; otherwise the law
656    ///   falls back to the free source rather than falling silent.
657    ///
658    /// The fourth clause (no Resonance, no charge, no gauge, not a Core event)
659    /// is enforced downstream — see [`Self::fire_all`] and `run_law_effect`'s
660    /// `Proc` provenance.
661    fn claim_hidden_wakes(
662        &self,
663        state: &mut OverlordState,
664        owner_id: EntityId,
665        candidates: Vec<LiveLaw>,
666        mods: &artifact_mech::LawColumnMods,
667    ) -> Vec<LiveLaw> {
668        if candidates.is_empty() {
669            return candidates;
670        }
671        let game_config = self.game_config.get();
672        let threshold = crate::entities::combatant_flip_threshold(state, owner_id, &game_config);
673        let Some(owner) = state
674            .active_fight
675            .as_mut()
676            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
677        else {
678            return Vec::new();
679        };
680        let progress = owner.flip_state.map_or(0.0, |flip| flip.progress);
681        let gauge_fill = artifact_mech::gauge_fill(progress, threshold);
682        let charges = owner.law_bridges.clone();
683        let capacity = charges.capacity_hundredths;
684
685        let mut woken = Vec::new();
686        for mut law in candidates {
687            let wake = mods.hidden_wake(law.id(), gauge_fill, &charges, capacity);
688            if wake.is_silent() {
689                continue;
690            }
691            let mut share = wake.free_share;
692            if wake.once_share > share {
693                let key = once_key(SINGLE_LESSON_TAG);
694                if attr(&owner.attributes, &key) == 0 {
695                    owner.attributes.set(&key, 1);
696                    share = wake.once_share;
697                }
698            }
699            if share <= 0.0 {
700                continue;
701            }
702            law.amplification *= share;
703            woken.push(law);
704        }
705        woken
706    }
707
708    /// Laws whose condition is an outcome. Runs from `apply_success_hooks`, so
709    /// the state it reads is the one the outcome actually produced.
710    pub fn apply_law_reactions(
711        &mut self,
712        state: &mut OverlordState,
713        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
714        event: &OverlordEvent,
715    ) {
716        // Cheap gates first: this hook runs on every successfully handled event
717        // of the session, most of which are not combat at all. A player with no
718        // laws leaves here having touched no state, which is what makes
719        // acceptance #14 true by construction rather than by measurement.
720        let Some(fight) = state.active_fight.as_ref() else {
721            return;
722        };
723        if fight.fight_ended || fight.fight_stopped {
724            return;
725        }
726        if fight.entities.iter().all(|e| e.law_cores.laws.is_empty()) {
727            return;
728        }
729
730        let game_config = self.game_config.get();
731        let classified = self.classify_reactions(&game_config, state, event);
732        for (owner_id, reactions, cast_mark) in classified {
733            if let Some((victim_id, seq)) = cast_mark {
734                self.mark_cast_target(state, owner_id, victim_id, seq);
735            }
736            let produced = self.run_reactions(state, owner_id, &reactions);
737            events.extend(produced);
738        }
739    }
740
741    /// Which combatants this event implicates, how, and (for the attacker) the
742    /// cast-target mark to write.
743    ///
744    /// The combat meaning of the event is not read here: it comes from
745    /// [`crate::logic::combat_facts`], the one place either system defines
746    /// "hit", "kill" or "dodge". What is left is the law-specific layer —
747    /// translating those facts into [`Reaction`]s and counting the distinct
748    /// targets of the owner's current cast, which needs the `law.*` attributes
749    /// and so cannot live in the shared classifier.
750    #[allow(clippy::type_complexity)]
751    fn classify_reactions(
752        &self,
753        game_config: &configs::game_config::GameConfig,
754        state: &OverlordState,
755        event: &OverlordEvent,
756    ) -> Vec<(EntityId, Vec<Reaction>, Option<(EntityId, i64)>)> {
757        let Some(fight) = state.active_fight.as_ref() else {
758            return Vec::new();
759        };
760
761        let mut out = Vec::new();
762        for (owner_id, facts) in combat_facts::classify(game_config, fight, event) {
763            let mut reactions = Vec::new();
764            let mut cast_mark = None;
765            for fact in facts {
766                match fact {
767                    CombatFact::HitLanded { crit, victim, .. } => {
768                        if crit {
769                            reactions.push(Reaction::Crit);
770                        }
771                        if let Some((count, seq)) = self.count_cast_target(fight, owner_id, victim)
772                        {
773                            reactions.push(Reaction::CastTargets(count));
774                            cast_mark = Some((victim, seq));
775                        }
776                    }
777                    CombatFact::Kill { .. } => reactions.push(Reaction::Kill),
778                    CombatFact::HitTaken { .. } => {
779                        reactions.push(Reaction::HitTaken);
780                        reactions.push(Reaction::HpDropped);
781                    }
782                    CombatFact::Dodged => reactions.push(Reaction::Dodge),
783                    // The cast conditions are decided before the cast resolves
784                    // (`apply_law_pre_cast`), not from this hook.
785                    CombatFact::Cast { .. } => {}
786                }
787            }
788            if !reactions.is_empty() || cast_mark.is_some() {
789                out.push((owner_id, reactions, cast_mark));
790            }
791        }
792        out
793    }
794
795    /// How many DISTINCT targets the caster's current cast has damaged once
796    /// this victim is counted, plus the cast's sequence number. `None` when the
797    /// hit does not belong to a cast being counted.
798    ///
799    /// Only the cast's own tick counts. Direct AoE (`cone_strike`, Holy Nova,
800    /// War Cry) resolves inside `handle_cast_ability`, so the count is exact
801    /// for it; a projectile-delivered AoE lands on a later tick and is not
802    /// counted, which errs towards not firing rather than towards firing on the
803    /// wrong cast.
804    fn count_cast_target(
805        &self,
806        fight: &essences::fighting::ActiveFight,
807        caster_id: EntityId,
808        victim_id: EntityId,
809    ) -> Option<(i64, i64)> {
810        let caster = fight.entities.iter().find(|e| e.id == caster_id)?;
811        let seq = attr(&caster.attributes, CAST_SEQ);
812        if seq == 0 || attr(&caster.attributes, CAST_TICK) != self.fight_clock.now() as i64 {
813            return None;
814        }
815        if attr(&caster.attributes, &target_key(victim_id)) == seq {
816            return None;
817        }
818        let already = caster
819            .attributes
820            .0
821            .iter()
822            .filter(|(key, value)| key.starts_with(TARGET_PREFIX) && **value == seq)
823            .count() as i64;
824        Some((already + 1, seq))
825    }
826
827    /// Evaluates one combatant's active laws against the reactions the event
828    /// produced and runs whatever fires.
829    fn run_reactions(
830        &mut self,
831        state: &mut OverlordState,
832        owner_id: EntityId,
833        reactions: &[Reaction],
834    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
835        let mut events = Vec::new();
836        let game_config = self.game_config.get();
837
838        let Some(fight) = state.active_fight.as_ref() else {
839            return events;
840        };
841        let Some(owner) = fight.entities.iter().find(|e| e.id == owner_id) else {
842            return events;
843        };
844        if owner.law_cores.laws.is_empty() {
845            return events;
846        }
847        let ctx = self.law_ctx(&game_config, &fight.entities, owner);
848        let mods = self.law_column_mods(state, owner_id);
849        let mut laws = Self::live_laws(&game_config, owner, &mods);
850        self.apply_wild_reading(state, owner, &mut laws);
851        let hidden_laws = self.hidden_laws(&game_config, owner, &mods);
852        let attributes = owner.attributes.clone();
853        let charges = owner.law_bridges.clone();
854        let hp_permyriad = if owner.max_hp == 0 {
855            10_000
856        } else {
857            (owner.hp as i128 * 10_000 / owner.max_hp as i128) as i64
858        };
859        let watermark = attr(&attributes, HP_WATERMARK);
860
861        let outcome = Outcome {
862            reactions,
863            hp_permyriad,
864            watermark,
865        };
866        let mut evaluation = evaluate_reactions(laws, &attributes, &outcome);
867        let hidden_evaluation = evaluate_reactions(hidden_laws, &attributes, &outcome);
868        evaluation.counters.extend(hidden_evaluation.counters);
869
870        self.write_counters(state, owner_id, &evaluation.counters);
871        if reactions.contains(&Reaction::HpDropped) {
872            self.lower_hp_watermark(state, owner_id, hp_permyriad);
873        }
874        let fired = self.claim_phase_latches(state, owner_id, evaluation.fired);
875        let woken = self.claim_hidden_wakes(state, owner_id, hidden_evaluation.fired, &mods);
876
877        // `PET-10 Dream Reader`: the next SUITABLE Core event — one whose own
878        // condition the chosen hidden law is watching for — wakes it once.
879        let dream = self.claim_dream_reader(state, owner_id, &outcome, &attributes);
880
881        let fired = self.claim_first_article(state, owner_id, fired, &mods);
882        events.extend(self.fire_all(state, owner_id, fired, &ctx, &mods, &charges));
883        events.extend(self.fire_all(state, owner_id, woken, &ctx, &mods, &charges));
884        events.extend(self.fire_all(state, owner_id, dream, &ctx, &mods, &charges));
885        events
886    }
887
888    /// The chosen hidden Law, if `PET-10 Dream Reader` is armed AND this event
889    /// satisfies that law's own condition.
890    ///
891    /// Marked `hidden`, which is what makes "no Resonance is created" structural:
892    /// [`Self::fire_all`] banks Resonance for non-hidden fires only. It also
893    /// fills no bridge and feeds no gauge, exactly like every other hidden run —
894    /// so the facet MODIFIES the hidden-law flow that already exists rather than
895    /// inventing a payout of its own.
896    fn claim_dream_reader(
897        &self,
898        state: &mut OverlordState,
899        owner_id: EntityId,
900        outcome: &Outcome<'_>,
901        attributes: &EntityAttributes,
902    ) -> Vec<LiveLaw> {
903        let Some(owner) = state
904            .active_fight
905            .as_ref()
906            .and_then(|fight| fight.entities.iter().find(|e| e.id == owner_id))
907        else {
908            return Vec::new();
909        };
910        let Some(share) = pet_mech::armed(
911            owner,
912            pet_mech::DREAM_READER,
913            pet_mech::DREAM_READER_CHARGES,
914        ) else {
915            return Vec::new();
916        };
917        let Some(chosen) = self.pet_facet_law(
918            state,
919            owner_id,
920            essences::pet_facets::PetFacetLawRole::DreamReader,
921        ) else {
922            return Vec::new();
923        };
924        let Some(candidate) = self.hidden_partner(state, owner_id, chosen, share as f64 / 10_000.0)
925        else {
926            return Vec::new();
927        };
928        // "Suitable" is the woken law's OWN condition being met by this event —
929        // the charge is not spent on an event the law would have ignored.
930        let evaluation = evaluate_reactions(vec![candidate], attributes, outcome);
931        if evaluation.fired.is_empty() {
932            return Vec::new();
933        }
934        if let Some(owner) = state
935            .active_fight
936            .as_mut()
937            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
938        {
939            pet_mech::spend_charge(
940                owner,
941                pet_mech::DREAM_READER,
942                pet_mech::DREAM_READER_CHARGES,
943            );
944        }
945        evaluation.fired
946    }
947
948    /// One completed flip, in the order plan §3 prescribes:
949    ///
950    /// 1. the charge is delivered to the receiving law and starts amplifying it;
951    /// 2. the charge is zeroed;
952    /// 3. the "phase started" laws (`RL-12`, `FL-11`) fire — already amplified.
953    ///
954    /// Entity-local: any combatant that flips gets its own laws re-folded, so a
955    /// PvP opponent's inactive half stops contributing on their flip exactly as
956    /// the hero's does.
957    pub fn apply_flip_to_laws(
958        &mut self,
959        state: &mut OverlordState,
960        entity_id: EntityId,
961        from_side: WorldSide,
962        to_side: WorldSide,
963    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
964        let mut events = Vec::new();
965        let game_config = self.game_config.get();
966
967        // Steps 1 and 2, plus the phase-local bookkeeping reset. Deliberately
968        // NOT gated on the entity having bridges: a slotted law with no bridge
969        // still has to stop contributing the moment its side goes down, which
970        // is the common case before a player owns two levelled cores.
971        {
972            let Some(entity) = state
973                .active_fight
974                .as_mut()
975                .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == entity_id))
976            else {
977                return events;
978            };
979            let previous = entity.law_bridges.clone();
980            entity.law_bridges.deliver();
981            entity
982                .attributes
983                .0
984                .retain(|key, _| !key.starts_with(ONCE_PREFIX) && !key.starts_with(COUNTER_PREFIX));
985            crate::entities::refresh_law_attributes(
986                entity,
987                from_side,
988                to_side,
989                &previous,
990                &game_config,
991            );
992        }
993
994        // Step 3.
995        let Some(fight) = state.active_fight.as_ref() else {
996            return events;
997        };
998        let Some(owner) = fight.entities.iter().find(|e| e.id == entity_id) else {
999            return events;
1000        };
1001        if owner.law_cores.laws.is_empty() {
1002            return events;
1003        }
1004        let ctx = self.law_ctx(&game_config, &fight.entities, owner);
1005        let mods = self.law_column_mods(state, entity_id);
1006        let charges = owner.law_bridges.clone();
1007        let mut opening: Vec<LiveLaw> = Self::live_laws(&game_config, owner, &mods)
1008            .into_iter()
1009            .filter(|law| law.template.condition == LawCondition::PhaseStarted)
1010            .collect();
1011        self.apply_wild_reading(state, owner, &mut opening);
1012        let fired = self.claim_phase_latches(state, entity_id, opening);
1013        let fired = self.claim_first_article(state, entity_id, fired, &mods);
1014
1015        events.extend(self.fire_all(state, entity_id, fired, &ctx, &mods, &charges));
1016        events
1017    }
1018
1019    /// Keeps the laws that may fire now, latching every `once_per_phase` one it
1020    /// keeps. The latch is cleared by the flip, so "once per phase" means once
1021    /// per phase and not once per fight.
1022    fn claim_phase_latches(
1023        &self,
1024        state: &mut OverlordState,
1025        owner_id: EntityId,
1026        candidates: Vec<LiveLaw>,
1027    ) -> Vec<LiveLaw> {
1028        if candidates.is_empty() {
1029            return candidates;
1030        }
1031        let Some(owner) = state
1032            .active_fight
1033            .as_mut()
1034            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1035        else {
1036            return Vec::new();
1037        };
1038        candidates
1039            .into_iter()
1040            .filter(|law| {
1041                if !law.template.once_per_phase {
1042                    return true;
1043                }
1044                let key = once_key(&law.tag);
1045                if attr(&owner.attributes, &key) != 0 {
1046                    return false;
1047                }
1048                owner.attributes.set(&key, 1);
1049                true
1050            })
1051            .collect()
1052    }
1053
1054    /// Drops the law's Resonance into its bridge.
1055    ///
1056    /// Never touched by bridge amplification — that is the half of acceptance #8
1057    /// which stops a law from feeding its own amplifier. It IS touched by the
1058    /// artifact's right column, which is a different multiplier from a different
1059    /// source: `VL-01` and `VL-03` buy Resonance with Effect, `VL-02` sells it,
1060    /// and `BL-05` pays both directions of one bridge.
1061    ///
1062    /// The product is banked in hundredths, so a `+25%` on a Resonance of 2
1063    /// really is 2.5 and not a rounding decision (post-merge plan §8).
1064    fn bank_resonance(
1065        &self,
1066        state: &mut OverlordState,
1067        owner_id: EntityId,
1068        law: &LiveLaw,
1069        mods: &artifact_mech::LawColumnMods,
1070    ) {
1071        // Read before the fight is borrowed mutably: the selection lives in
1072        // durable character state, the charge on the entity.
1073        let lead_reading = self.pet_facet_law(
1074            state,
1075            owner_id,
1076            essences::pet_facets::PetFacetLawRole::LeadReading,
1077        );
1078        let Some(owner) = state
1079            .active_fight
1080            .as_mut()
1081            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1082        else {
1083            return;
1084        };
1085        let multiplier = mods.resonance_multiplier(law.id(), &owner.law_bridges);
1086        // `PET-06 Lead Reading` MODIFIES this banking rather than opening a
1087        // second one — a Pet Facet may never CREATE Resonance. It pays only the
1088        // chosen law's own bridge, because that is the bridge this call feeds.
1089        let pet_bonus = if lead_reading == Some(law.id()) {
1090            let bonus = pet_mech::attr(owner, pet_mech::LEAD_READING_BONUS);
1091            if bonus != 0 && pet_mech::attr(owner, pet_mech::LEAD_READING_CHARGES) > 0 {
1092                pet_mech::spend_charge(
1093                    owner,
1094                    pet_mech::LEAD_READING_BONUS,
1095                    pet_mech::LEAD_READING_CHARGES,
1096                );
1097                1.0 + bonus as f64 / 10_000.0
1098            } else {
1099                1.0
1100            }
1101        } else {
1102            1.0
1103        };
1104        let amount = (law.template.resonance as f64
1105            * essences::cores::CHARGE_SCALE as f64
1106            * multiplier
1107            * pet_bonus)
1108            .round() as i64;
1109        owner.law_bridges.add_resonance(law.id(), amount);
1110    }
1111
1112    /// `PET-06 Wild Reading`: while its window is open, the chosen ACTIVE law's
1113    /// Effect is stronger.
1114    ///
1115    /// Applied to `amplification`, which is the effect multiplier only —
1116    /// [`Self::bank_resonance`] reads the template's own Resonance and the
1117    /// artifact mods, never this, so "its Resonance is NOT boosted" holds
1118    /// structurally rather than by a rule at the banking site.
1119    ///
1120    /// Hidden runs are skipped: the facet names an *active* Law.
1121    fn apply_wild_reading(&self, state: &OverlordState, owner: &Entity, laws: &mut [LiveLaw]) {
1122        let bonus = pet_mech::attr(owner, pet_mech::WILD_READING_BONUS);
1123        let until = pet_mech::attr(owner, pet_mech::WILD_READING_UNTIL);
1124        if bonus == 0 || until == 0 || self.fight_clock.now() as i64 >= until {
1125            return;
1126        }
1127        let Some(chosen) = self.pet_facet_law(
1128            state,
1129            owner.id,
1130            essences::pet_facets::PetFacetLawRole::WildReading,
1131        ) else {
1132            return;
1133        };
1134        let multiplier = 1.0 + bonus as f64 / 10_000.0;
1135        for law in laws.iter_mut() {
1136            if !law.hidden && law.id() == chosen {
1137                law.amplification *= multiplier;
1138            }
1139        }
1140    }
1141
1142    fn write_counters(
1143        &self,
1144        state: &mut OverlordState,
1145        owner_id: EntityId,
1146        counters: &[(String, i64)],
1147    ) {
1148        if counters.is_empty() {
1149            return;
1150        }
1151        let Some(owner) = state
1152            .active_fight
1153            .as_mut()
1154            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1155        else {
1156            return;
1157        };
1158        for (key, value) in counters {
1159            owner.attributes.set(key, *value);
1160        }
1161    }
1162
1163    fn lower_hp_watermark(&self, state: &mut OverlordState, owner_id: EntityId, permyriad: i64) {
1164        let Some(owner) = state
1165            .active_fight
1166            .as_mut()
1167            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1168        else {
1169            return;
1170        };
1171        let watermark = attr(&owner.attributes, HP_WATERMARK);
1172        if watermark == 0 || permyriad < watermark {
1173            // Floored at 1 so a dead combatant's watermark stays a written
1174            // value: `EntityAttributes::set` erases a zero.
1175            owner.attributes.set(HP_WATERMARK, permyriad.max(1));
1176        }
1177    }
1178
1179    /// Opens a new target-counting window for an original Skill and records the
1180    /// cast for the distinct-skill window. Forgetting the previous cast's
1181    /// victims keeps the `law.tgt.*` keys bounded by one cast's target count.
1182    fn open_cast_window(
1183        &self,
1184        state: &mut OverlordState,
1185        caster_id: EntityId,
1186        ability_id: AbilityId,
1187        now: u64,
1188    ) {
1189        let Some(caster) = state
1190            .active_fight
1191            .as_mut()
1192            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == caster_id))
1193        else {
1194            return;
1195        };
1196        caster
1197            .attributes
1198            .set(&skill_seen_key(ability_id), now as i64);
1199        let seq = attr(&caster.attributes, CAST_SEQ) + 1;
1200        caster
1201            .attributes
1202            .0
1203            .retain(|key, _| !key.starts_with(TARGET_PREFIX));
1204        caster.attributes.set(CAST_SEQ, seq);
1205        caster.attributes.set(CAST_TICK, now as i64);
1206    }
1207
1208    fn mark_cast_target(
1209        &self,
1210        state: &mut OverlordState,
1211        caster_id: EntityId,
1212        victim_id: EntityId,
1213        seq: i64,
1214    ) {
1215        if let Some(caster) = state
1216            .active_fight
1217            .as_mut()
1218            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == caster_id))
1219        {
1220            caster.attributes.set(&target_key(victim_id), seq);
1221        }
1222    }
1223
1224    /// Spends an armed derived-damage state (splash or echo) and returns the
1225    /// `Proc` damage it produces.
1226    fn spend_arm_as_damage(
1227        &self,
1228        state: &mut OverlordState,
1229        caster_id: EntityId,
1230        key: &str,
1231        ctx: &LawCtx,
1232        shape: SplashShape,
1233    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1234        let armed = {
1235            let Some(owner) = state
1236                .active_fight
1237                .as_mut()
1238                .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == caster_id))
1239            else {
1240                return Vec::new();
1241            };
1242            let armed = attr(&owner.attributes, key);
1243            if armed > 0 {
1244                owner.attributes.set(key, 0);
1245            }
1246            armed
1247        };
1248        if armed <= 0 {
1249            return Vec::new();
1250        }
1251        let damage = derived_damage(ctx.attack, armed);
1252        if damage == 0 {
1253            return Vec::new();
1254        }
1255        match shape {
1256            SplashShape::EveryOtherEnemy(primary) => ctx
1257                .enemies
1258                .iter()
1259                .filter(|id| **id != primary)
1260                .map(|id| armed_bonus_damage(caster_id, *id, damage))
1261                .collect(),
1262            SplashShape::SingleTarget(target) => {
1263                vec![armed_bonus_damage(caster_id, target, damage)]
1264            }
1265        }
1266    }
1267
1268    /// Runs ONE law effect. Everything that lands as a combat outcome is
1269    /// emitted `Proc`, so it can never satisfy a condition; everything that is
1270    /// bookkeeping (`EntityIncrAttribute`, `EntityAddAbilityCooldown`) carries
1271    /// no provenance and is not condition surface either way.
1272    fn run_law_effect(
1273        &mut self,
1274        state: &mut OverlordState,
1275        owner_id: EntityId,
1276        law: &LiveLaw,
1277        ctx: &LawCtx,
1278    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
1279        let mut events = Vec::new();
1280        let value = law.effect_value();
1281        let duration = law.duration_ticks();
1282        let fraction = value as f64 / 10_000.0;
1283
1284        match law.template.effect {
1285            // Armed on the entity rather than emitted: the strike that armed it
1286            // is about to compute its damage from this very snapshot.
1287            LawEffect::ThisAttackDamageBonus => self.arm(state, owner_id, ARM_THIS, value),
1288            LawEffect::NextAttackDamageBonus => self.arm(state, owner_id, ARM_BASIC, value),
1289            LawEffect::NextSkillPayloadBonus => self.arm(state, owner_id, ARM_SKILL, value),
1290            LawEffect::NextAttackSplash => self.arm(state, owner_id, ARM_SPLASH, value),
1291            LawEffect::NextSkillEcho => self.arm(state, owner_id, ARM_ECHO, value),
1292            LawEffect::HealPercentMaxHp => {
1293                let heal = (ctx.max_hp as f64 * fraction).round() as u64;
1294                if heal > 0 {
1295                    events.push(EventPluginized::now(OverlordEvent::Heal {
1296                        by_entity_id: Some(owner_id),
1297                        entity_id: owner_id,
1298                        heal,
1299                        origin: CombatEventOrigin::Proc,
1300                        source: CombatSource::LawProc {
1301                            law_template_id: law.template.id,
1302                        },
1303                    }));
1304                }
1305            }
1306            LawEffect::AoeDerivedDamage => {
1307                let damage = derived_damage(ctx.attack, value);
1308                if damage > 0 {
1309                    events.extend(
1310                        ctx.enemies
1311                            .iter()
1312                            .map(|id| proc_damage(owner_id, *id, damage, law.template.id)),
1313                    );
1314                }
1315            }
1316            LawEffect::ReduceLongestCooldown => {
1317                if let Some((ability_id, _)) = ctx
1318                    .skill_cooldowns
1319                    .iter()
1320                    .max_by_key(|(_, deadline)| deadline.saturating_sub(ctx.now))
1321                {
1322                    events.push(cooldown_delta(owner_id, *ability_id, -value));
1323                }
1324            }
1325            LawEffect::ReduceAllCooldowns => {
1326                events.extend(
1327                    ctx.skill_cooldowns
1328                        .iter()
1329                        .map(|(ability_id, _)| cooldown_delta(owner_id, *ability_id, -value)),
1330                );
1331            }
1332            LawEffect::ReadyShortestCooldown => {
1333                if let Some((ability_id, deadline)) = ctx
1334                    .skill_cooldowns
1335                    .iter()
1336                    .min_by_key(|(_, deadline)| deadline.saturating_sub(ctx.now))
1337                {
1338                    // `adjust_ability_cooldown` clamps at the current tick, so
1339                    // handing it the whole remaining wait is exactly "ready
1340                    // now" and can never rewind past it.
1341                    let remaining = deadline.saturating_sub(ctx.now) as i64;
1342                    events.push(cooldown_delta(owner_id, *ability_id, -remaining.max(1)));
1343                }
1344            }
1345            // BAL-029 `Open with Magic`: a scalable cut instead of a full
1346            // ready. The same clamp caps it at ready.
1347            LawEffect::ReduceShortestCooldown => {
1348                if let Some((ability_id, _)) = ctx
1349                    .skill_cooldowns
1350                    .iter()
1351                    .min_by_key(|(_, deadline)| deadline.saturating_sub(ctx.now))
1352                {
1353                    events.push(cooldown_delta(owner_id, *ability_id, -value));
1354                }
1355            }
1356            LawEffect::DamageBuff => {
1357                self.apply_timed_attribute(&mut events, owner_id, "attack.mod", value, duration);
1358            }
1359            LawEffect::DamageReduction => {
1360                self.apply_timed_attribute(
1361                    &mut events,
1362                    owner_id,
1363                    "received_damage.mod",
1364                    -value,
1365                    duration,
1366                );
1367            }
1368            LawEffect::AttackSpeedBuff => {
1369                let delta = (ctx.speed as f64 * fraction).round() as i64;
1370                self.apply_timed_attribute(&mut events, owner_id, "speed", delta, duration);
1371            }
1372        }
1373        events
1374    }
1375
1376    fn arm(&self, state: &mut OverlordState, owner_id: EntityId, key: &str, value: i64) {
1377        if value == 0 {
1378            return;
1379        }
1380        if let Some(owner) = state
1381            .active_fight
1382            .as_mut()
1383            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1384        {
1385            owner.attributes.add(key, value);
1386        }
1387    }
1388
1389    /// BAL-029 `Three Spell Rule`: forgets every recorded Skill sighting, so a
1390    /// proc fully consumes the combo window.
1391    fn clear_skill_seen_marks(&self, state: &mut OverlordState, owner_id: EntityId) {
1392        if let Some(owner) = state
1393            .active_fight
1394            .as_mut()
1395            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1396        {
1397            owner
1398                .attributes
1399                .0
1400                .retain(|key, _| !key.starts_with(SKILL_SEEN_PREFIX));
1401        }
1402    }
1403
1404    /// Drops whatever is sitting in a single-cast arm, so nothing survives the
1405    /// cast it was armed for.
1406    fn clear_arm(&self, state: &mut OverlordState, owner_id: EntityId, key: &str) {
1407        if let Some(owner) = state
1408            .active_fight
1409            .as_mut()
1410            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == owner_id))
1411            && attr(&owner.attributes, key) != 0
1412        {
1413            owner.attributes.set(key, 0);
1414        }
1415    }
1416}
1417
1418/// Everything the pre-cast conditions read about the cast about to resolve.
1419struct PreCast {
1420    is_basic: bool,
1421    ability_tags: Vec<AbilityTag>,
1422    distinct_skills: i64,
1423    /// Mana this cast paid at the gate, x100 fixed point — `None` for a cast
1424    /// that never went through the pool
1425    /// ([`crate::logic::combat_facts::paid_mana_x100`]).
1426    paid_mana_x100: Option<i64>,
1427}
1428
1429/// Everything the outcome conditions read about the event that just resolved.
1430struct Outcome<'a> {
1431    reactions: &'a [Reaction],
1432    hp_permyriad: i64,
1433    watermark: i64,
1434}
1435
1436/// Which of `laws` the cast satisfies, plus the counter writes the evaluation
1437/// implies.
1438///
1439/// A free function, and called once per SIDE: the active side's laws and — when
1440/// the artifact wakes them — the hidden side's run the exact same conditions.
1441/// Sharing the body is what makes "a hidden law checks its OWN Core conditions"
1442/// (design §3.1) true rather than a second, drifting copy of the ladder.
1443fn evaluate_pre_cast(
1444    laws: Vec<LiveLaw>,
1445    attributes: &EntityAttributes,
1446    cast: &PreCast,
1447) -> Evaluation {
1448    let mut evaluation = Evaluation {
1449        fired: Vec::new(),
1450        counters: Vec::new(),
1451    };
1452    for law in laws {
1453        let met = match law.template.condition {
1454            LawCondition::NthBasicAttack if cast.is_basic => {
1455                let count = attr(attributes, &counter_key(&law.tag)) + 1;
1456                let period = law.template.condition_value.max(1);
1457                let met = count >= period;
1458                evaluation
1459                    .counters
1460                    .push((counter_key(&law.tag), if met { 0 } else { count }));
1461                met
1462            }
1463            LawCondition::OriginalSkillCast => !cast.is_basic,
1464            LawCondition::SkillWithTag => {
1465                !cast.is_basic
1466                    && law
1467                        .template
1468                        .condition_tag
1469                        .is_some_and(|tag| cast.ability_tags.contains(&tag))
1470            }
1471            LawCondition::DistinctSkillsWithin => {
1472                !cast.is_basic && cast.distinct_skills >= law.template.condition_value.max(1)
1473            }
1474            // Priced off what the mana gate actually charged this cast
1475            // (`PreCast::paid_mana_x100`), so a stone discount or `Budget Plan`
1476            // changes the verdict exactly as it changed the bill. A cast that
1477            // never paid (no pool, a pet ult) satisfies neither bound.
1478            LawCondition::SkillManaAtMost => {
1479                !cast.is_basic
1480                    && cast.paid_mana_x100.is_some_and(|paid| {
1481                        paid <= law.template.condition_value.saturating_mul(100)
1482                    })
1483            }
1484            LawCondition::SkillManaAtLeast => {
1485                !cast.is_basic
1486                    && cast.paid_mana_x100.is_some_and(|paid| {
1487                        paid >= law.template.condition_value.saturating_mul(100)
1488                    })
1489            }
1490            _ => false,
1491        };
1492        if met {
1493            evaluation.fired.push(law);
1494        }
1495    }
1496    evaluation
1497}
1498
1499/// Which of `laws` the outcome satisfies. Same contract as
1500/// [`evaluate_pre_cast`], for the conditions decided after the fact.
1501fn evaluate_reactions(
1502    laws: Vec<LiveLaw>,
1503    attributes: &EntityAttributes,
1504    outcome: &Outcome<'_>,
1505) -> Evaluation {
1506    let mut evaluation = Evaluation {
1507        fired: Vec::new(),
1508        counters: Vec::new(),
1509    };
1510    for law in laws {
1511        let met = match law.template.condition {
1512            LawCondition::CriticalHit => outcome.reactions.contains(&Reaction::Crit),
1513            LawCondition::Dodge => outcome.reactions.contains(&Reaction::Dodge),
1514            LawCondition::KilledEnemy => outcome.reactions.contains(&Reaction::Kill),
1515            LawCondition::NthHitTaken if outcome.reactions.contains(&Reaction::HitTaken) => {
1516                let count = attr(attributes, &counter_key(&law.tag)) + 1;
1517                let met = count >= law.template.condition_value.max(1);
1518                evaluation
1519                    .counters
1520                    .push((counter_key(&law.tag), if met { 0 } else { count }));
1521                met
1522            }
1523            LawCondition::HpFellBelow => {
1524                let threshold = law.template.condition_value;
1525                outcome.reactions.contains(&Reaction::HpDropped)
1526                    && outcome.hp_permyriad < threshold
1527                    && (outcome.watermark == 0 || outcome.watermark >= threshold)
1528            }
1529            LawCondition::SkillMinTargets => outcome.reactions.iter().any(|reaction| {
1530                matches!(reaction, Reaction::CastTargets(count)
1531                    if *count >= law.template.condition_value.max(1))
1532            }),
1533            _ => false,
1534        };
1535        if met {
1536            evaluation.fired.push(law);
1537        }
1538    }
1539    evaluation
1540}
1541
1542/// Which enemies an armed derived-damage state hits when it is spent.
1543enum SplashShape {
1544    /// Every living enemy except the one the real attack already struck.
1545    EveryOtherEnemy(EntityId),
1546    /// The skill's own target.
1547    SingleTarget(EntityId),
1548}
1549
1550/// Same scaling as one real swing of `power = fraction`, so a 150% law effect
1551/// is worth 1.5 attacks and not a second damage currency.
1552fn derived_damage(attack: f64, permyriad: i64) -> u64 {
1553    let fraction = permyriad as f64 / 10_000.0;
1554    (attack * fraction * balance::DMG_K).floor().max(0.0) as u64
1555}
1556
1557/// A bonus armed by a law and spent by a later hit. The arming law is not
1558/// recorded on the entity — see [`CombatSource::ArmedBonus`].
1559fn armed_bonus_damage(
1560    by: EntityId,
1561    to: EntityId,
1562    damage: u64,
1563) -> EventPluginized<OverlordEvent, OverlordState> {
1564    EventPluginized::now(OverlordEvent::Damage {
1565        by_entity_id: Some(by),
1566        entity_id: to,
1567        damage,
1568        damage_data: Default::default(),
1569        origin: CombatEventOrigin::Proc,
1570        source: CombatSource::ArmedBonus,
1571    })
1572}
1573
1574fn proc_damage(
1575    by: EntityId,
1576    to: EntityId,
1577    damage: u64,
1578    law_template_id: LawTemplateId,
1579) -> EventPluginized<OverlordEvent, OverlordState> {
1580    EventPluginized::now(OverlordEvent::Damage {
1581        by_entity_id: Some(by),
1582        entity_id: to,
1583        damage,
1584        damage_data: Default::default(),
1585        origin: CombatEventOrigin::Proc,
1586        source: CombatSource::LawProc { law_template_id },
1587    })
1588}
1589
1590fn cooldown_delta(
1591    entity_id: EntityId,
1592    ability_id: AbilityId,
1593    delta_ticks: i64,
1594) -> EventPluginized<OverlordEvent, OverlordState> {
1595    EventPluginized::now(OverlordEvent::EntityAddAbilityCooldown {
1596        entity_id,
1597        ability_id,
1598        delta_ticks,
1599    })
1600}
1601
1602/// How many DIFFERENT original Skills, including the one being cast, resolved
1603/// inside the last `window` ticks.
1604fn count_distinct_skills(
1605    attributes: &EntityAttributes,
1606    now: u64,
1607    window: u64,
1608    ability_id: AbilityId,
1609) -> i64 {
1610    let floor = now.saturating_sub(window) as i64;
1611    let current = skill_seen_key(ability_id);
1612    let mut seen: BTreeSet<&str> = BTreeSet::new();
1613    for (key, value) in attributes.0.iter() {
1614        if key.starts_with(SKILL_SEEN_PREFIX) && *value >= floor && *key != current {
1615            seen.insert(key.as_str());
1616        }
1617    }
1618    seen.len() as i64 + 1
1619}