overlord_event_system/logic/
stones.rs

1//! Trigger/Effect Stones — inventory handlers and the combat runtime.
2//!
3//! The inventory half (insert, remove, upgrade, quick equip, upgrade all, the
4//! temporary drop grant) is a set of pure state transitions; the monolith
5//! persists whatever they produce.
6//!
7//! The combat half is [`OverlordLogic::apply_stone_triggers`], the single entry
8//! point of the whole mechanic. It hangs off `apply_success_hooks`, so it runs
9//! for every successfully handled event in both dispatch paths — the pure
10//! dispatch and the monolith's merged arms — instead of being wired per event
11//! arm, where a new arm would silently miss it.
12//!
13//! # The rules the design rests on
14//!
15//! * **One cooldown for the whole build.** A Core event checks every socketed
16//!   trigger at once, every match fires and adds its tier weight to the gauge,
17//!   then [`mech::GLOBAL_COOLDOWN`] is written once. Firing comes in bursts of
18//!   up to five rather than five independent trickles.
19//! * **A condition met on cooldown is lost, and its counter restarts.** No bank
20//!   and no queue: [`mech::reset_sequence_counters`] takes the sequence back to
21//!   zero, so "every 5th attack" starts counting from one again.
22//! * **Nothing an effect produces is Core.** A trigger reacts to
23//!   [`OverlordEvent::is_core_combat_event`] only, and every event this module
24//!   emits carries [`CombatEventOrigin::Proc`], which makes "effect grants a
25//!   crit → crit trigger → effect grants a crit" inexpressible. A real Basic
26//!   Attack stays Core even when an effect changed its magnitude, because
27//!   effects add derived damage beside the real hit instead of rewriting it.
28//! * **No effect shortens the cooldown.** It is written *before* the effect
29//!   loop dispatches anything, which holds under re-entrancy too.
30//!
31//! Its arithmetic and its per-fight bookkeeping keys live in
32//! [`crate::mechanics::stones`].
33
34use essences::abilities::AbilityId;
35use essences::combat_origin::CombatEventOrigin;
36use essences::entity::{Entity, EntityId};
37use essences::fight_breakdown::CombatSource;
38use essences::flip::FlipProgressSource;
39use essences::items::ItemType;
40use essences::stones::{
41    StoneError, StoneKind, StoneSocketKey, StoneSocketSlot, StoneTemplateId, StoneTier,
42    UpgradedStonesMap,
43};
44
45use configs::artifacts::ArtifactStoneRule;
46use configs::stones::{EffectStoneAction, TriggerCondition, TriggerStoneTemplate};
47use strum::IntoEnumIterator;
48
49use crate::game_config_helpers::GameConfigLookup;
50use crate::logic::combat_facts::{CastKind, CombatFact};
51use crate::mechanics::{
52    artifacts as art, balance, fight::get_entity_stat, pet_facets as pet_mech, stones as mech,
53};
54use crate::{
55    EventHandleResult,
56    event::OverlordEvent,
57    logic::{EventPluginized, handler::OverlordLogic},
58    state::OverlordState,
59};
60
61/// The Core facts a Trigger Stone may react to (design v0.2 §5).
62///
63/// Every variant is a Core combat outcome of the player's
64/// (`is_core_combat_event`). There is deliberately no heartbeat variant any
65/// more: v0.2's catalog has no interval trigger, and `FightProgress` is
66/// reachable from a Proc cascade, so admitting it would have been the one hole
67/// in the anti-loop boundary.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69enum CoreOutcome {
70    /// The player swung a real Basic Attack — the swing, not the damage it goes
71    /// on to deal. §4 makes Basic Attack the most frequent Core event in the
72    /// game, which is why no common trigger reacts to a single one of them.
73    BasicAttack,
74    /// The player cast an original Skill. `slot` is its index among the equipped
75    /// Skills; `None` for a cast that is not one of them (a pet ult, a summon's
76    /// kit) — such a cast still counts as a Skill Cast, but cannot take part in
77    /// the conditions that ask *which* Skill.
78    SkillCast { slot: Option<usize> },
79    /// A Core hit of the player's landed on an enemy. `damage` is what it took
80    /// and `target` who took it — the Effect Stones that derive from the hit
81    /// itself (`EF-E08`) need both.
82    HitLanded {
83        crit: bool,
84        damage: u64,
85        target: EntityId,
86    },
87    /// A Core hit landed on the player. `share` is what it took in permyriad of
88    /// Max HP, `survived` whether the hero was still alive afterwards.
89    HitTaken { share: i64, survived: bool },
90    /// The player dodged a Core hit.
91    Dodged,
92    /// A Core action killed an enemy.
93    Defeat,
94    /// BAL-027 `Battle Rhythm`: the interval trigger in one socket came due
95    /// while a living hostile exists. Carries the template so only that
96    /// trigger matches — the uniqueness rule guarantees one socket per
97    /// template.
98    IntervalElapsed { template: StoneTemplateId },
99}
100
101/// Facts that only the event just handled can establish — memory alone cannot,
102/// because each is about a transition rather than a total.
103#[derive(Clone, Copy, Debug, Default)]
104struct Facts {
105    /// A Core crit landed straight after a Core non-crit (`TR-R06`).
106    crit_after_non_crit: bool,
107    /// This Basic Attack is the first one since an original Skill (`TR-C06`).
108    first_attack_after_skill: bool,
109    /// This original Skill is not the one cast before it (`TR-R05`).
110    skill_differs: bool,
111    /// This cast was the last equipped Skill still unused this phase (`TR-L03`).
112    spellbook_completed: bool,
113    /// This cast closed a Skill → Basic Attack → other Skill run (`TR-L04`).
114    perfect_sequence: bool,
115    /// Targets this Skill has reached so far inside its own tick (`TR-R04`).
116    skill_targets: i64,
117    /// HP as permyriad of Max HP, before and after this event.
118    hp_before: i64,
119    hp_now: i64,
120}
121
122impl Facts {
123    /// Folds the facts of a later outcome of the SAME event into these.
124    ///
125    /// A killing blow is one event with two outcomes, and each is folded into
126    /// the memory in turn; a condition must see everything the event
127    /// established, not just whatever the last fold happened to report. The
128    /// booleans are claims about the event, so any of them holding is enough.
129    /// `hp_before` stays at the watermark from before the event and `hp_now`
130    /// tracks the latest reading.
131    fn absorb(&mut self, later: Facts) {
132        self.crit_after_non_crit |= later.crit_after_non_crit;
133        self.first_attack_after_skill |= later.first_attack_after_skill;
134        self.skill_differs |= later.skill_differs;
135        self.spellbook_completed |= later.spellbook_completed;
136        self.perfect_sequence |= later.perfect_sequence;
137        self.skill_targets = self.skill_targets.max(later.skill_targets);
138        self.hp_now = later.hp_now;
139    }
140}
141
142/// One extra Effect Stone an artifact rule runs alongside a trigger's own fire.
143///
144/// Everything an Aspect rule adds to a fire lands here — the paired hidden
145/// effect of `HA-01` / `HA-04` / `HA-05`, the next slot's hidden effect of
146/// `HA-03`, the repeat of `FA-03`, the departing effect of `FA-04`. One shape
147/// for all of them, so a rule can never invent a second way to run an effect and
148/// slip past the provenance and gauge guards that
149/// [`OverlordLogic::run_stone_effect`] enforces.
150#[derive(Clone, Copy)]
151struct ExtraFire {
152    template_id: StoneTemplateId,
153    level: i64,
154    /// Share of full strength, `0.3` for `30%`.
155    share: f64,
156}
157
158/// One trigger that passed its condition, collected before anything is spent so
159/// the whole build resolves against one consistent world.
160struct Fire {
161    /// Equipment slot whose Trigger socket held the fired stone — carried out
162    /// to the client as [`OverlordEvent::StoneTriggerFired`], since the combat
163    /// outcomes a fire produces are deliberately anonymous.
164    item_type: ItemType,
165    trigger_template: StoneTemplateId,
166    /// The effect socketed on the side that is active *at this fire*, plus its
167    /// upgrade level. `None` when that side's socket is empty — the trigger
168    /// still fires (and still feeds the gauge), it just has nothing to run.
169    effect: Option<(StoneTemplateId, i64)>,
170    /// Strength multiplier the artifact's Visible-Aspect stone applies to this
171    /// fire (`VA-01 Crescendo` / `VA-04 Opening Five`). `1.0` with no artifact —
172    /// which is what makes "a player without an artifact plays exactly as
173    /// before" an identity rather than a measurement.
174    strength: f64,
175    /// Extra effects the Hidden- and Flip-Aspect rules attach to this fire, in
176    /// the order they were decided. Empty with no artifact.
177    extras: Vec<ExtraFire>,
178    /// The paired hidden effect `HA-05 Catch-Up` runs *instead*, and only when
179    /// the active effect turns out to be wholly inapplicable. Kept apart from
180    /// [`Fire::extras`] because that verdict is only known after the active
181    /// effect has been attempted.
182    catch_up: Option<ExtraFire>,
183}
184
185/// Turns the shared reading of a dispatched event
186/// ([`crate::logic::combat_facts`]) into the trigger surface. Empty when the
187/// event established nothing a trigger may react to.
188///
189/// What "hit", "kill" and "dodge" mean is not decided here — the laws runtime
190/// reads the same facts, and the two definitions used to drift. What is left is
191/// the stone-specific presentation: a hit taken as a share of Max HP, and which
192/// equipped slot a Skill came from.
193///
194/// A killing blow establishes two outcomes at once — the hit landed AND the
195/// enemy died — so this returns a list. They arrive in the order they happened,
196/// and every socketed trigger is checked against all of them: the design's "a
197/// Core event checks every trigger at once", applied to an event that settled
198/// more than one thing.
199fn classify(
200    game_config: &configs::game_config::GameConfig,
201    event: &OverlordEvent,
202    player: &Entity,
203    fight: &essences::fighting::ActiveFight,
204    equipped_skills: &[AbilityId],
205) -> Vec<CoreOutcome> {
206    let classified = crate::logic::combat_facts::classify(game_config, fight, event);
207    crate::logic::combat_facts::facts_for(&classified, player.id)
208        .map(|fact| match fact {
209            CombatFact::Cast {
210                kind: CastKind::Basic,
211                ..
212            } => CoreOutcome::BasicAttack,
213            CombatFact::Cast {
214                kind: CastKind::Skill,
215                ability_id,
216            } => CoreOutcome::SkillCast {
217                slot: equipped_skills.iter().position(|id| *id == ability_id),
218            },
219            CombatFact::HitLanded {
220                crit,
221                damage,
222                victim,
223            } => CoreOutcome::HitLanded {
224                crit,
225                damage,
226                target: victim,
227            },
228            // The hook runs after the handler, so the hit is already off the
229            // hero's HP: `survived` is simply whether anything is left.
230            CombatFact::HitTaken { damage } => {
231                let max_hp = player.max_hp.max(1) as i128;
232                CoreOutcome::HitTaken {
233                    share: (damage as i128 * 10_000 / max_hp) as i64,
234                    survived: player.hp > 0,
235                }
236            }
237            CombatFact::Dodged => CoreOutcome::Dodged,
238            CombatFact::Kill { .. } => CoreOutcome::Defeat,
239        })
240        .collect()
241}
242
243/// Folds one Core outcome into the player's per-fight trigger memory and
244/// reports what that fold established.
245///
246/// Runs **before** any condition is evaluated and regardless of whether anything
247/// will fire, because the memory is the trigger's history, not a by-product of
248/// firing: a crit streak that only advanced on fires could never reach three,
249/// and a Basic Attack that fired nothing would still have to break the run.
250fn advance_memory(
251    player: &mut Entity,
252    outcome: CoreOutcome,
253    now: u64,
254    equipped_skills: usize,
255    perfect_sequence_window: u64,
256) -> Facts {
257    let mut facts = Facts {
258        hp_before: mech::hp_low_water(player),
259        hp_now: mech::hp_permyriad(player),
260        ..Default::default()
261    };
262
263    match outcome {
264        CoreOutcome::BasicAttack => {
265            player.attributes.add(mech::SEQ_BASIC_ATTACK, 1);
266            facts.first_attack_after_skill = mech::attr(player, mech::AFTER_SKILL) != 0;
267            player.attributes.set(mech::AFTER_SKILL, 0);
268            // Perfect Sequence, step two: a Skill opened the run, this is the
269            // Basic Attack in the middle of it.
270            if mech::attr(player, mech::SEQ_STAGE) == 1 {
271                player.attributes.set(mech::SEQ_STAGE, 2);
272            }
273        }
274        CoreOutcome::SkillCast { slot } => {
275            player.attributes.add(mech::SEQ_SKILL_CAST, 1);
276            player.attributes.add(mech::SKILLS_WITHOUT_DAMAGE, 1);
277            player.attributes.set(mech::AFTER_SKILL, 1);
278
279            let key = slot.map_or(0, |slot| slot as i64 + 1);
280            // "Differs from the PREVIOUS Skill" needs a previous one: the first
281            // cast of a fight changes nothing and must not read as a change.
282            let previous = mech::attr(player, mech::LAST_SKILL);
283            facts.skill_differs = key != 0 && previous != 0 && key != previous;
284
285            // Wide Cast counts the targets of ONE cast: a multi-target Skill
286            // arrives as one `CastAbility` per target inside the cast's own
287            // animation window, so the run is "same Skill, still inside it".
288            let opened_at = mech::attr(player, mech::CAST_RUN_TICK);
289            let same_run = opened_at != 0
290                && mech::attr(player, mech::CAST_RUN_SLOT) == key
291                && now as i64 - (opened_at - 1) <= mech::CAST_RUN_WINDOW_TICKS;
292            facts.skill_targets = if same_run {
293                mech::attr(player, mech::CAST_RUN_COUNT) + 1
294            } else {
295                player.attributes.set(mech::CAST_RUN_TICK, now as i64 + 1);
296                1
297            };
298            player.attributes.set(mech::CAST_RUN_SLOT, key);
299            player
300                .attributes
301                .set(mech::CAST_RUN_COUNT, facts.skill_targets);
302
303            if let Some(slot) = slot {
304                player
305                    .attributes
306                    .set(&mech::skill_last_cast_key(slot), now as i64 + 1);
307                // Full Spellbook is a latch: it fires on the cast that completed
308                // the book, not on every cast after it.
309                let cast_this_phase = (0..equipped_skills)
310                    .filter(|index| mech::attr(player, &mech::skill_last_cast_key(*index)) != 0)
311                    .count();
312                if equipped_skills > 0
313                    && cast_this_phase >= equipped_skills
314                    && mech::attr(player, mech::SPELLBOOK_DONE) == 0
315                {
316                    facts.spellbook_completed = true;
317                    player.attributes.set(mech::SPELLBOOK_DONE, 1);
318                }
319            }
320
321            // Perfect Sequence, step three: a *different* Skill closes the run,
322            // and only inside the window.
323            let stage = mech::attr(player, mech::SEQ_STAGE);
324            let opened_at = mech::attr(player, mech::SEQ_STAGE_TICK);
325            let opened_with = mech::attr(player, mech::SEQ_STAGE_SKILL);
326            let in_window = opened_at != 0
327                && now as i64 - (opened_at - 1) <= perfect_sequence_window.max(1) as i64;
328            if stage == 2 && in_window && key != 0 && key != opened_with {
329                facts.perfect_sequence = true;
330            }
331            // Either way this Skill opens a fresh run: it is the most recent
332            // "Skill" the sequence could be built from.
333            player.attributes.set(mech::SEQ_STAGE, 1);
334            player.attributes.set(mech::SEQ_STAGE_TICK, now as i64 + 1);
335            player.attributes.set(mech::SEQ_STAGE_SKILL, key);
336            player.attributes.set(mech::LAST_SKILL, key);
337        }
338        CoreOutcome::HitLanded { crit, .. } => {
339            facts.crit_after_non_crit = crit && mech::attr(player, mech::PREV_HIT_CRIT) == 0;
340            player.attributes.set(mech::PREV_HIT_CRIT, i64::from(crit));
341            let streak = if crit {
342                mech::attr(player, mech::CRIT_STREAK) + 1
343            } else {
344                0
345            };
346            player.attributes.set(mech::CRIT_STREAK, streak);
347        }
348        CoreOutcome::HitTaken { .. } => {
349            player.attributes.add(mech::SEQ_HIT_TAKEN, 1);
350            // Untouched Casting is about a run of Skills with nothing landing in
351            // between, so any Core hit ends it.
352            player.attributes.set(mech::SKILLS_WITHOUT_DAMAGE, 0);
353            if facts.hp_now < facts.hp_before {
354                player.attributes.set(mech::HP_LOW_WATER, facts.hp_now);
355            }
356        }
357        CoreOutcome::Dodged => {
358            for index in (1..mech::DODGE_RING).rev() {
359                let previous = mech::attr(player, &mech::dodge_tick_key(index - 1));
360                player
361                    .attributes
362                    .set(&mech::dodge_tick_key(index), previous);
363            }
364            player
365                .attributes
366                .set(&mech::dodge_tick_key(0), now as i64 + 1);
367        }
368        CoreOutcome::Defeat => {
369            player.attributes.add(mech::SEQ_DEFEAT, 1);
370        }
371        // BAL-027 `Battle Rhythm`: a clock tick, not a combat act — it leaves
372        // every sequence counter and streak untouched.
373        CoreOutcome::IntervalElapsed { .. } => {}
374    }
375
376    facts
377}
378
379/// Whether `template`'s condition is satisfied by this outcome.
380///
381/// Reads the memory [`advance_memory`] has already folded the event into, so
382/// "every 5th attack" sees the count *including* the attack being classified.
383///
384/// No artifact reaches this function. The v0.1 World Law «Отражение» used to —
385/// it made a dodge additionally read as a crit — but that rulebreaker is not in
386/// the v0.2 pool (plan §2), and the one law that ships (`ART-03 Halfway Bell`)
387/// moves the flip bar rather than reclassifying an outcome. A trigger condition
388/// is therefore a property of the Core event alone.
389fn condition_met(
390    template: &TriggerStoneTemplate,
391    outcome: CoreOutcome,
392    facts: &Facts,
393    player: &Entity,
394    now: u64,
395) -> bool {
396    // First, so no other clause can reach a stone the catalog switched off —
397    // `active: false` is how content whose mechanic has not shipped waits.
398    if !template.active {
399        return false;
400    }
401    let value = template.condition_value;
402    let window = template.condition_window_ticks;
403    let threshold = value.saturating_mul(100);
404
405    match (template.condition, outcome) {
406        (TriggerCondition::EveryNthBasicAttack, CoreOutcome::BasicAttack) => {
407            mech::counter_met(mech::attr(player, mech::SEQ_BASIC_ATTACK), value)
408        }
409        (TriggerCondition::EveryNthHitTaken, CoreOutcome::HitTaken { .. }) => {
410            mech::counter_met(mech::attr(player, mech::SEQ_HIT_TAKEN), value)
411        }
412        (TriggerCondition::EveryNthSkillCast, CoreOutcome::SkillCast { .. }) => {
413            mech::counter_met(mech::attr(player, mech::SEQ_SKILL_CAST), value)
414        }
415        (TriggerCondition::EveryNthDefeat, CoreOutcome::Defeat) => {
416            mech::counter_met(mech::attr(player, mech::SEQ_DEFEAT), value)
417        }
418        (TriggerCondition::FirstAttackAfterSkill, CoreOutcome::BasicAttack) => {
419            facts.first_attack_after_skill
420        }
421        (TriggerCondition::OnSkillCast, CoreOutcome::SkillCast { .. }) => true,
422        (TriggerCondition::OnCrit, CoreOutcome::HitLanded { crit, .. }) => crit,
423        (TriggerCondition::OnDefeat, CoreOutcome::Defeat) => true,
424        (TriggerCondition::SkillHitsAtLeastTargets, CoreOutcome::SkillCast { .. }) => {
425            facts.skill_targets >= value.max(1)
426        }
427        (TriggerCondition::SkillDiffersFromPrevious, CoreOutcome::SkillCast { .. }) => {
428            facts.skill_differs
429        }
430        (TriggerCondition::CritAfterNonCrit, CoreOutcome::HitLanded { crit, .. }) => {
431            crit && facts.crit_after_non_crit
432        }
433        (TriggerCondition::OnEvasion, CoreOutcome::Dodged) => true,
434        // Once per phase for free: the low-water mark only ever falls, so the
435        // line can only be crossed downwards once however much the hero heals.
436        (TriggerCondition::HpCrossedBelowPercent, CoreOutcome::HitTaken { .. }) => {
437            facts.hp_before >= threshold && facts.hp_now < threshold
438        }
439        (TriggerCondition::OnCritStreak, CoreOutcome::HitLanded { crit, .. }) => {
440            crit && mech::crit_streak_met(mech::attr(player, mech::CRIT_STREAK), value)
441        }
442        (TriggerCondition::DistinctSkillsWithin, CoreOutcome::SkillCast { .. }) => {
443            let live = (0..mech::SKILL_SLOTS)
444                .map(|index| mech::attr(player, &mech::skill_last_cast_key(index)))
445                .filter(|recorded| {
446                    *recorded != 0 && now as i64 - (recorded - 1) <= window.max(1) as i64
447                })
448                .count() as i64;
449            live >= value.max(1)
450        }
451        (TriggerCondition::SurvivedBigHitPercent, CoreOutcome::HitTaken { share, survived }) => {
452            survived && share >= threshold
453        }
454        (TriggerCondition::DodgesWithin, CoreOutcome::Dodged) => {
455            let needed = value.max(1) as usize;
456            if needed > mech::DODGE_RING {
457                return false;
458            }
459            let oldest = mech::attr(player, &mech::dodge_tick_key(needed - 1));
460            oldest != 0 && now as i64 - (oldest - 1) <= window.max(1) as i64
461        }
462        (TriggerCondition::AllEquippedSkillsCastThisPhase, CoreOutcome::SkillCast { .. }) => {
463            facts.spellbook_completed
464        }
465        (TriggerCondition::SkillAttackSkillWithin, CoreOutcome::SkillCast { .. }) => {
466            facts.perfect_sequence
467        }
468        (TriggerCondition::SkillsWithoutDamageTaken, CoreOutcome::SkillCast { .. }) => {
469            mech::counter_met(mech::attr(player, mech::SKILLS_WITHOUT_DAMAGE), value)
470        }
471        (TriggerCondition::SkillBelowHpPercent, CoreOutcome::SkillCast { .. }) => {
472            facts.hp_now > 0 && facts.hp_now < threshold
473        }
474        // `TR-C03` / `TR-E02`: priced off what the mana gate actually charged
475        // (`combat_facts::paid_mana_x100`, x100 like `threshold`), so a stone
476        // discount or `Budget Plan` changes the verdict exactly as it changed
477        // the bill. A cast that never paid satisfies neither bound.
478        (TriggerCondition::SkillManaAtMost, CoreOutcome::SkillCast { .. }) => {
479            crate::logic::combat_facts::paid_mana_x100(player).is_some_and(|paid| paid <= threshold)
480        }
481        (TriggerCondition::SkillManaAtLeast, CoreOutcome::SkillCast { .. }) => {
482            crate::logic::combat_facts::paid_mana_x100(player).is_some_and(|paid| paid >= threshold)
483        }
484        // BAL-027 `Battle Rhythm`: the emission already checked the clock and
485        // the living hostile; here only the identity matters.
486        (TriggerCondition::OnInterval, CoreOutcome::IntervalElapsed { template: fired }) => {
487            fired == template.id
488        }
489        _ => false,
490    }
491}
492
493/// The slot after `item_type` in the fixed two-sided slot order, wrapping at the
494/// end.
495///
496/// `HA-03 Cross Relay` is the only caller: "slot `N+1`" needs *an* order, and the
497/// one order the whole stone runtime already walks is `ItemType::iter()` filtered
498/// to the two-sided slots — the same sequence the flip volley and the shadow
499/// round use. A slot that has no sides at all answers with itself, so the rule
500/// degrades to «Фоновый голос» rather than panicking on a config that removes
501/// every socketed slot but one.
502fn next_two_sided_slot(item_type: ItemType) -> ItemType {
503    let slots: Vec<ItemType> = ItemType::iter()
504        .filter(|t| t.supports_world_side())
505        .collect();
506    let Some(position) = slots.iter().position(|slot| *slot == item_type) else {
507        return item_type;
508    };
509    slots[(position + 1) % slots.len()]
510}
511
512/// Nearest living opponent of `player`, for the effects that need a target.
513pub(crate) fn nearest_enemy(entities: &[Entity], player: &Entity) -> Option<EntityId> {
514    entities
515        .iter()
516        .filter(|e| e.team != player.team && e.hp > 0)
517        .min_by_key(|e| {
518            (e.coordinates.x - player.coordinates.x).abs()
519                + (e.coordinates.y - player.coordinates.y).abs()
520        })
521        .map(|e| e.id)
522}
523
524/// Every living opponent, for the effects that hit the whole line.
525pub(crate) fn living_enemies(entities: &[Entity], player: &Entity) -> Vec<EntityId> {
526    entities
527        .iter()
528        .filter(|e| e.team != player.team && e.hp > 0)
529        .map(|e| e.id)
530        .collect()
531}
532
533impl OverlordLogic {
534    pub fn handle_insert_stone(
535        &self,
536        template_id: StoneTemplateId,
537        item_type: ItemType,
538        socket: StoneSocketSlot,
539        mut state: OverlordState,
540    ) -> EventHandleResult<OverlordEvent, OverlordState> {
541        let Some(key) = StoneSocketKey::new(item_type, socket) else {
542            tracing::error!("InsertStone: {item_type:?} has no stone sockets");
543            return EventHandleResult::fail(state);
544        };
545
546        let game_config = self.game_config.get();
547        let unlocked = game_config.stones_settings.is_socket_unlocked(
548            item_type,
549            socket,
550            state.character_state.character.current_chapter_level,
551        );
552
553        // A template the player already wears elsewhere is refused here
554        // (`AlreadySocketed`): one instance per template means one socket.
555        if let Err(err) = state
556            .character_state
557            .stones
558            .insert(template_id, key, unlocked)
559        {
560            tracing::error!("InsertStone refused: {err}");
561            return EventHandleResult::fail(state);
562        }
563
564        EventHandleResult::ok(state)
565    }
566
567    pub fn handle_remove_stone(
568        &self,
569        template_id: StoneTemplateId,
570        mut state: OverlordState,
571    ) -> EventHandleResult<OverlordEvent, OverlordState> {
572        // The stone is never taken out of its collection — only its socket
573        // assignment is cleared — so there is nothing here that could lose it,
574        // and its banked copies come back with it.
575        if let Err(err) = state.character_state.stones.remove(template_id) {
576            tracing::error!("RemoveStone refused: {err}");
577            return EventHandleResult::fail(state);
578        }
579
580        EventHandleResult::ok(state)
581    }
582
583    /// Raises one stone by one level, paid for in raw copies.
584    ///
585    /// The client names the stone only; the cost and the spend are the
586    /// server's, so a UI cannot spend more than the rung asks. The cost comes
587    /// from the per-level ladder in `stones_settings.upgrade_ladder`, never a
588    /// constant. A level with no rung is refused rather than guessed —
589    /// `GameConfig::validate_stones` makes that unreachable in a deployed
590    /// config.
591    pub fn handle_upgrade_stone(
592        &self,
593        template_id: StoneTemplateId,
594        mut state: OverlordState,
595    ) -> EventHandleResult<OverlordEvent, OverlordState> {
596        let settings = &self.game_config.get().stones_settings;
597        let Some(stone) = state.character_state.stones.get(template_id) else {
598            tracing::error!(
599                "UpgradeStone refused: {}",
600                StoneError::UnknownStone(template_id)
601            );
602            return EventHandleResult::fail(state);
603        };
604
605        let next_level = stone.level + 1;
606        if stone.level >= settings.max_stone_level {
607            tracing::error!(
608                "UpgradeStone refused: {}",
609                StoneError::AlreadyMaxLevel(template_id, settings.max_stone_level)
610            );
611            return EventHandleResult::fail(state);
612        }
613        let Some(required) = settings.upgrade_copies_required(next_level) else {
614            tracing::error!(
615                "UpgradeStone refused: {}",
616                StoneError::NoUpgradeStep(next_level)
617            );
618            return EventHandleResult::fail(state);
619        };
620
621        if let Err(err) =
622            state
623                .character_state
624                .stones
625                .upgrade(template_id, required, settings.max_stone_level)
626        {
627            tracing::error!("UpgradeStone refused: {err}");
628            return EventHandleResult::fail(state);
629        }
630
631        EventHandleResult::ok(state)
632    }
633
634    /// BAL-024/BAL-025: the guaranteed milestone stones the character should
635    /// own at their chapter but does not. Granting the result converges — an
636    /// owned template is never listed — so the check is safe to run on every
637    /// chapter advance and on connect.
638    pub fn missing_milestone_stones(
639        game_config: &configs::game_config::GameConfig,
640        chapter: i64,
641        stones: &essences::stones::StoneInventory,
642    ) -> (Vec<StoneTemplateId>, Vec<StoneTemplateId>) {
643        let mut trigger_stones = Vec::new();
644        let mut effect_stones = Vec::new();
645        for grant in &game_config.stones_settings.milestone_grants {
646            if grant.chapter > chapter {
647                continue;
648            }
649            for id in &grant.trigger_stones {
650                if stones.get(*id).is_none() {
651                    trigger_stones.push(*id);
652                }
653            }
654            for id in &grant.effect_stones {
655                if stones.get(*id).is_none() {
656                    effect_stones.push(*id);
657                }
658            }
659        }
660        (trigger_stones, effect_stones)
661    }
662
663    /// Banks granted copies in their collections. Kept separate from the grant
664    /// roll so the sole writer of the inventory is one function.
665    ///
666    /// A copy of a template the player already owns raises that stone's
667    /// `copies` count; only the first copy of a template creates an instance.
668    /// The tier comes from the catalog, so a template missing from config is
669    /// skipped and logged rather than banked with a guessed tier.
670    pub fn handle_player_new_stones(
671        &self,
672        trigger_stones: &[StoneTemplateId],
673        effect_stones: &[StoneTemplateId],
674        mut state: OverlordState,
675    ) -> EventHandleResult<OverlordEvent, OverlordState> {
676        let game_config = self.game_config.get();
677        for (kind, template_id) in trigger_stones
678            .iter()
679            .map(|id| (StoneKind::Trigger, *id))
680            .chain(effect_stones.iter().map(|id| (StoneKind::Effect, *id)))
681        {
682            let tier = match kind {
683                StoneKind::Trigger => game_config
684                    .trigger_stone_template(template_id)
685                    .map(|t| t.tier),
686                StoneKind::Effect => game_config
687                    .effect_stone_template(template_id)
688                    .map(|t| t.tier),
689            };
690            let Some(tier) = tier else {
691                tracing::warn!("Granted {kind} stone {template_id} has no catalog entry — skipped");
692                continue;
693            };
694            state.character_state.stones.grant(kind, template_id, tier);
695        }
696        EventHandleResult::ok(state)
697    }
698
699    /// The stone faucet: `rolls` independent draws at
700    /// `stones_settings.kill_drop.chance × chance_scale`, split between the two
701    /// collections by `kill_drop.trigger_share`.
702    ///
703    /// It hangs off ENEMY KILLS ([`Self::roll_stone_kill_drop`]). It used to
704    /// hang off the item-chest open, which tied the whole faucet to a purchased
705    /// channel: a whale opening ~26k chests a day drew two orders of magnitude
706    /// more stones than a player who fought for them.
707    ///
708    /// A copy of a template the player already owns is banked as a copy by the
709    /// handler, not as a second instance. Draws from the caller's stream (like
710    /// every other handler) rather than the global RNG, so a test can seed the
711    /// drop.
712    pub fn roll_stone_drop(
713        &self,
714        rng: &mut rand::rngs::StdRng,
715        rolls: i64,
716        chance_scale: f64,
717    ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
718        let game_config = self.game_config.get();
719        let settings = &game_config.stones_settings;
720        let chance = settings.kill_drop.chance * chance_scale;
721        if chance <= 0.0 {
722            return None;
723        }
724
725        let mut trigger_stones = Vec::new();
726        let mut effect_stones = Vec::new();
727        for _ in 0..rolls.max(0) {
728            if rand::RngExt::random::<f64>(rng) >= chance {
729                continue;
730            }
731            let kind = settings.dropped_kind(rand::RngExt::random::<f64>(rng));
732            let Some(template_id) = pick_stone_template(rng, &game_config, kind) else {
733                continue;
734            };
735            match kind {
736                StoneKind::Trigger => trigger_stones.push(template_id),
737                StoneKind::Effect => effect_stones.push(template_id),
738            }
739        }
740
741        if trigger_stones.is_empty() && effect_stones.is_empty() {
742            return None;
743        }
744        Some(EventPluginized::now(OverlordEvent::PlayerNewStones {
745            trigger_stones,
746            effect_stones,
747        }))
748    }
749
750    /// One stone roll for one dead enemy, scaled by that mob's `wave_share`.
751    ///
752    /// The `wave_share` scaling is the same rule the cores currency and the law
753    /// copies already follow on this path: an inflated wave is more mobs of less
754    /// weight each, and without the scaling it would inflate every per-kill
755    /// faucet with it.
756    pub fn roll_stone_kill_drop(
757        &self,
758        rng: &mut rand::rngs::StdRng,
759        wave_share: f64,
760    ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
761        self.roll_stone_drop(rng, 1, wave_share)
762    }
763
764    /// Fills the open, empty sockets of one item with the BEST usable stones
765    /// the player is not already wearing.
766    ///
767    /// Modelled on `FastEquipAbilities` but kept pure: a stone inventory lives
768    /// in `character_state`, not a side table, so the whole decision is a state
769    /// transition and the same code runs in both dispatch paths.
770    ///
771    /// "Best" is `Stone::quality` — tier first, then level — with the canonical
772    /// catalog order breaking ties. Locked sockets are skipped, occupied sockets
773    /// are left alone, stones worn on other gear are never taken, and a socket
774    /// with nothing to put in it stays empty without an error.
775    pub fn handle_quick_equip_stones(
776        &self,
777        item_type: ItemType,
778        mut state: OverlordState,
779    ) -> EventHandleResult<OverlordEvent, OverlordState> {
780        if !item_type.supports_world_side() {
781            tracing::error!(
782                "QuickEquipStones refused: {}",
783                StoneError::SlotHasNoSockets { item_type }
784            );
785            return EventHandleResult::fail(state);
786        }
787
788        let game_config = self.game_config.get();
789        let chapter_level = state.character_state.character.current_chapter_level;
790        let settings = &game_config.stones_settings;
791        let filled = state.character_state.stones.quick_equip(
792            item_type,
793            &|socket| settings.is_socket_unlocked(item_type, socket, chapter_level),
794            &|kind| match kind {
795                StoneKind::Trigger => game_config
796                    .trigger_stones
797                    .iter()
798                    .filter(|t| t.active)
799                    .map(|t| t.id)
800                    .collect(),
801                StoneKind::Effect => game_config.effect_stones.iter().map(|t| t.id).collect(),
802            },
803        );
804
805        tracing::debug!(?item_type, sockets = filled.len(), "QuickEquipStones");
806        EventHandleResult::ok(state)
807    }
808
809    /// Raises every stone of one catalog that the player can currently afford to
810    /// raise, as far as its banked copies reach (design v0.2 §9).
811    ///
812    /// Modelled on `UpgradeAllAbilities` and named for it — the codebase says
813    /// `UpgradeAll*`, and §9 sanctions matching it. Like
814    /// [`Self::handle_upgrade_stone`] the cost is the server's business: the
815    /// client asks for "everything of this kind", the ladder decides what that
816    /// costs, and a stone that cannot pay for its next level is skipped rather
817    /// than failing the whole call.
818    ///
819    /// `kind` comes from which of the two catalogs the caller asked for — the
820    /// screen has a Trigger tab and an Effect tab and its button raises the open
821    /// one, so the two arrive as separate events.
822    pub fn handle_upgrade_all_stones(
823        &self,
824        kind: StoneKind,
825        mut state: OverlordState,
826    ) -> EventHandleResult<OverlordEvent, OverlordState> {
827        let game_config = self.game_config.get();
828        let settings = &game_config.stones_settings;
829        let upgraded =
830            state
831                .character_state
832                .stones
833                .upgrade_all(kind, settings.max_stone_level, &|level| {
834                    settings.upgrade_copies_required(level)
835                });
836
837        tracing::debug!(?kind, stones = upgraded.len(), "UpgradeAll*Stones");
838
839        // Nothing rose: stay silent rather than popping an empty result window.
840        if upgraded.is_empty() {
841            return EventHandleResult::ok(state);
842        }
843
844        let mut map = UpgradedStonesMap::default();
845        for (template_id, from, to) in upgraded {
846            map.insert(template_id, (from, to));
847        }
848
849        EventHandleResult::ok_events(
850            state,
851            vec![EventPluginized::now(OverlordEvent::UpgradedStones {
852                upgraded_stones: map,
853            })],
854        )
855    }
856
857    /// Fire every socketed trigger whose condition `event` satisfies: run the
858    /// globally active side's effect, feed the flip gauge by the trigger's tier,
859    /// then put the whole build on one cooldown.
860    ///
861    /// Runs from `apply_success_hooks`, after the event's own handler has
862    /// mutated the state, so a trigger reacts to the world the hit produced.
863    ///
864    /// The order inside is load-bearing:
865    ///
866    /// 1. classify the event — anything non-Core leaves here;
867    /// 2. fold it into the trigger memory, whether or not anything will fire;
868    /// 3. spend whatever effect state this event consumes — independent of the
869    ///    cooldown;
870    /// 4. collect the triggers whose condition matched;
871    /// 5. if the cooldown is still running, drop them and restart the sequence
872    ///    counters;
873    /// 6. otherwise write the global cooldown, **then** run the effects.
874    ///
875    /// Step 6's order makes the hook re-entrancy-safe: an effect can dispatch
876    /// work that comes back through here, and it finds the build already spent.
877    ///
878    /// A build with nothing socketed leaves through the first early return
879    /// having touched no state at all.
880    pub fn apply_stone_triggers(
881        &mut self,
882        state: &mut OverlordState,
883        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
884        event: &OverlordEvent,
885    ) {
886        // Cheap gates first: this hook runs on every successfully handled
887        // event of the session, most of which are not combat at all.
888        let Some(fight) = state.active_fight.as_ref() else {
889            return;
890        };
891        if fight.fight_ended || fight.fight_stopped {
892            return;
893        }
894        // Every character combatant fires their OWN socketed triggers: the
895        // local hero, the party ally, and a human PvP opponent. Mobs and arena
896        // filler bots carry no CharacterState and are skipped inside.
897        let mut combatants = vec![fight.player_id];
898        combatants.extend(fight.party_player_id);
899        if let Some(pvp) = &state.pvp_state {
900            let opponent_id = pvp.opponent_state.id();
901            if fight.entities.iter().any(|e| e.id == opponent_id) {
902                combatants.push(opponent_id);
903            }
904        }
905        for combatant_id in combatants {
906            self.apply_stone_triggers_for(state, events, event, combatant_id);
907        }
908    }
909
910    /// One combatant's pass of [`Self::apply_stone_triggers`].
911    fn apply_stone_triggers_for(
912        &mut self,
913        state: &mut OverlordState,
914        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
915        event: &OverlordEvent,
916        combatant_id: EntityId,
917    ) {
918        let Some(fight) = state.active_fight.as_ref() else {
919            return;
920        };
921        let Some(build) = crate::entities::combatant_character_state(
922            &state.character_state,
923            &state.party,
924            &state.pvp_state,
925            fight,
926            combatant_id,
927        ) else {
928            return;
929        };
930        if build.stones.all().all(|(_, stone)| !stone.is_socketed()) {
931            return;
932        }
933        // Clones, not borrows: the engine below interleaves mutable fight
934        // borrows, and the build may live in `state.party` / `state.pvp_state`.
935        // Both collections are a handful of stones.
936        let stones = build.stones.clone();
937        let artifacts = build.artifacts.clone();
938        let combatant_class = build.character.class;
939        let equipped_abilities = build.equipped_abilities.clone();
940
941        let player_id = combatant_id;
942        let Some(player) = fight.entities.iter().find(|e| e.id == player_id) else {
943            return;
944        };
945
946        let game_config = self.game_config.get();
947        let basic_abilities = game_config
948            .class(combatant_class)
949            .map(|class| class.basic_abilities.clone())
950            .unwrap_or_default();
951        let equipped_skills: Vec<AbilityId> = equipped_abilities
952            .slotted
953            .values()
954            .map(|ability| ability.template_id)
955            .filter(|id| !basic_abilities.contains(id))
956            .take(mech::SKILL_SLOTS)
957            .collect();
958
959        let mut outcomes = classify(&game_config, event, player, fight, &equipped_skills);
960
961        // BAL-027 `Battle Rhythm`: the interval condition is clock-driven, so
962        // it is checked on EVERY handled event (the per-tick `FightProgress`
963        // included) rather than classified from combat facts. The due tick per
964        // socket is entity state; a first sighting arms it without firing, and
965        // a due that elapses advances whether or not the match survives the
966        // global cooldown — a burned match is lost, not queued.
967        let now = self.fight_clock.now();
968        let mut interval_due_updates: Vec<(String, i64)> = Vec::new();
969        let hostile_alive = fight
970            .entities
971            .iter()
972            .any(|e| e.team != player.team && e.hp > 0);
973        for item_type in ItemType::iter().filter(|t| t.supports_world_side()) {
974            let Some(key) = StoneSocketKey::new(item_type, StoneSocketSlot::Trigger) else {
975                continue;
976            };
977            let Some(stone) = stones.socketed(key) else {
978                continue;
979            };
980            let Some(template) = game_config.trigger_stone_template(stone.template_id) else {
981                continue;
982            };
983            if template.condition != TriggerCondition::OnInterval || !template.active {
984                continue;
985            }
986            let interval = template.condition_value.max(1);
987            let due_key = mech::interval_due_key(item_type);
988            let due = player.attributes.0.get(&due_key).copied().unwrap_or(0);
989            if due == 0 {
990                interval_due_updates.push((due_key, now as i64 + interval));
991                continue;
992            }
993            if hostile_alive && now as i64 >= due {
994                outcomes.push(CoreOutcome::IntervalElapsed {
995                    template: template.id,
996                });
997                interval_due_updates.push((due_key, now as i64 + interval));
998            }
999        }
1000
1001        if outcomes.is_empty() {
1002            // The armed metronome must still be written, or it never starts.
1003            if !interval_due_updates.is_empty() {
1004                Self::with_player(state, player_id, |player| {
1005                    for (key, due) in &interval_due_updates {
1006                        player.attributes.set(key, *due);
1007                    }
1008                });
1009            }
1010            return;
1011        }
1012
1013        let settings = &game_config.stones_settings;
1014        // The three Aspect stones that can ride on a trigger fire. All three are
1015        // `None` for a player with no artifact, and every use below is guarded —
1016        // so the no-artifact path is byte-for-byte the old one.
1017        //
1018        // The Flip socket is read here as well as on `GlobalFlip`: two of its
1019        // five rules (`FA-03 Warm Start`, `FA-04 Afterimage`) describe what
1020        // happens on the ordinary procs *after* a flip, not on the flip tick.
1021        let visible_aspect = crate::logic::artifacts::live_aspect_in_socket(
1022            &game_config,
1023            &artifacts,
1024            essences::artifacts::ArtifactSocketSlot::VisibleAspect,
1025        );
1026        let hidden_aspect = crate::logic::artifacts::live_aspect_in_socket(
1027            &game_config,
1028            &artifacts,
1029            essences::artifacts::ArtifactSocketSlot::HiddenAspect,
1030        );
1031        let flip_aspect = crate::logic::artifacts::live_aspect_in_socket(
1032            &game_config,
1033            &artifacts,
1034            essences::artifacts::ArtifactSocketSlot::FlipAspect,
1035        );
1036
1037        // Everything the effects need, read before any mutation so the immutable
1038        // borrow of the fight ends here.
1039        let player_attack = get_entity_stat(self.behaviors.lookups(), player, "attack");
1040        let player_max_hp = player.max_hp;
1041        let baseline_speed = game_config.game_settings.baseline_speed;
1042        let player_speed = player.attributes.speed_or_baseline(baseline_speed);
1043        let target_id = nearest_enemy(&fight.entities, player);
1044        let enemy_ids = living_enemies(&fight.entities, player);
1045        // The side is read once, so every slot firing on THIS event resolves
1046        // against one consistent world: if one fire's gauge contribution happens
1047        // to cross the threshold, the slots that fired alongside it still run the
1048        // side that was active when the event happened. The next Core event sees
1049        // the new side.
1050        // A combatant whose flip is still locked carries no fight-local
1051        // FlipState; the default (Fantasy, empty gauge) is exactly their
1052        // durable state in that case.
1053        let active_side = player
1054            .flip_state
1055            .map_or_else(essences::flip::WorldSide::default, |flip| flip.active_side);
1056        // Gauge fill and phase identity, for the rules that read them.
1057        // `VA-01 Crescendo` and `HA-04 Second Half` ramp/gate on the fill;
1058        // `VA-04 Opening Five` and `FA-03 Warm Start` key their once-per-phase
1059        // markers off the revision, which is what "phase" means — the stretch
1060        // between two flips.
1061        let (flip_progress, flip_revision) = player
1062            .flip_state
1063            .map_or((0.0, 0), |flip| (flip.progress, flip.revision));
1064        // The bar this combatant plays against: `ART-03 Halfway Bell` lowers it
1065        // to a share of the configured threshold, and every reader of "how full
1066        // is the gauge" has to agree with the accumulator about where full is.
1067        let flip_threshold = art::flip_threshold(&game_config, &artifacts);
1068        let gauge_fill = art::gauge_fill(flip_progress, flip_threshold);
1069        // `FA-04 Afterimage` counts procs within one phase; the marker keeps a
1070        // stale count from a previous phase out.
1071        let afterimage_seen =
1072            if mech::attr(player, art::AFTERIMAGE_REVISION) == flip_revision as i64 + 1 {
1073                mech::attr(player, art::AFTERIMAGE_COUNT)
1074            } else {
1075                0
1076            };
1077
1078        // The Perfect Sequence window is a property of the trigger that uses it;
1079        // the memory needs it one step earlier, so take the widest one socketed.
1080        let sequence_window = ItemType::iter()
1081            .filter(|t| t.supports_world_side())
1082            .filter_map(|item_type| StoneSocketKey::new(item_type, StoneSocketSlot::Trigger))
1083            .filter_map(|key| stones.socketed(key))
1084            .filter_map(|stone| game_config.trigger_stone_template(stone.template_id))
1085            .filter(|template| template.condition == TriggerCondition::SkillAttackSkillWithin)
1086            .map(|template| template.condition_window_ticks)
1087            .max()
1088            .unwrap_or(0);
1089
1090        let Some(player) = state
1091            .active_fight
1092            .as_mut()
1093            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == player_id))
1094        else {
1095            return;
1096        };
1097        let mut facts: Option<Facts> = None;
1098        let mut spent = Vec::new();
1099        for outcome in &outcomes {
1100            let folded = advance_memory(
1101                player,
1102                *outcome,
1103                now,
1104                equipped_skills.len(),
1105                sequence_window,
1106            );
1107            match &mut facts {
1108                Some(facts) => facts.absorb(folded),
1109                None => facts = Some(folded),
1110            }
1111            // Effect state this event spends. Deliberately outside the cooldown
1112            // gate: a charge armed by an earlier fire belongs to the player, not
1113            // to the trigger's rate limit.
1114            spent.extend(spend_effect_state(
1115                player,
1116                *outcome,
1117                player_attack,
1118                &enemy_ids,
1119                target_id,
1120            ));
1121        }
1122        let facts = facts.unwrap_or_default();
1123        let cooldown_ready = now >= mech::attr(player, mech::GLOBAL_COOLDOWN).max(0) as u64;
1124
1125        let mut fires = Vec::new();
1126        let mut matched = false;
1127        let mut opening_five_arms: Vec<(ItemType, i64)> = Vec::new();
1128        let mut warm_start_arms: Vec<(ItemType, i64)> = Vec::new();
1129        let mut afterimage_used = 0_i64;
1130        // Pet Facet bookkeeping, collected here and written back in the same
1131        // block as the artifact markers — i.e. BEFORE the effect loop, so a
1132        // re-entrant fire finds the charges already spent.
1133        let mut pet_prepared_arms: Vec<(String, i64)> = Vec::new();
1134        let mut pet_rising_gate_fires = 0_i64;
1135        let mut pet_rising_gate_mult = 1.0_f64;
1136        let mut pet_first_spell_used = 0_i64;
1137        // Up to five slots resolve in ONE batch and none of their charges is
1138        // written back until after the loop, so every charge a facet spends
1139        // per slot needs a local counter — re-reading the entity would hand the
1140        // same charge to every firing slot.
1141        let mut pet_first_spell_left = pet_mech::attr(player, pet_mech::FIRST_SPELL_CHARGES);
1142        let mut pet_souvenir_used = 0_i64;
1143        let mut pet_souvenir_left = pet_mech::attr(player, pet_mech::SOUVENIR_CHARGES);
1144        // Slot-order memory for `VA-02` / `VA-03`, carried in from the previous
1145        // event and advanced once per slot that actually resolves. Local until
1146        // the bookkeeping block writes it back, so the whole batch reads one
1147        // consistent "previous slot" rather than each slot seeing the last
1148        // write.
1149        let mut last_slot = mech::attr(player, art::LAST_RESOLVED_SLOT);
1150        let mut repeat_streak = mech::attr(player, art::SLOT_REPEAT_STREAK);
1151        for item_type in ItemType::iter().filter(|t| t.supports_world_side()) {
1152            let Some(key) = StoneSocketKey::new(item_type, StoneSocketSlot::Trigger) else {
1153                continue;
1154            };
1155            let Some(stone) = stones.socketed(key) else {
1156                continue;
1157            };
1158            // A template deleted from config while players own copies leaves a
1159            // dangling id: `character_stones.template_id` is registered with
1160            // `collection: none`, so nothing validates it at deploy or at load.
1161            // Skip the stone and keep the session alive.
1162            let Some(template) = game_config.trigger_stone_template(stone.template_id) else {
1163                tracing::warn!(
1164                    template_id = %stone.template_id,
1165                    "Skipping socketed Trigger Stone with no catalog entry"
1166                );
1167                continue;
1168            };
1169
1170            let player = state
1171                .active_fight
1172                .as_ref()
1173                .and_then(|fight| fight.entities.iter().find(|e| e.id == player_id));
1174            let Some(player) = player else { return };
1175            // Any outcome of this event may satisfy the condition: a killing
1176            // blow is both a hit and a defeat.
1177            if !outcomes
1178                .iter()
1179                .any(|outcome| condition_met(template, *outcome, &facts, player, now))
1180            {
1181                continue;
1182            }
1183            matched = true;
1184            // Met while the build is on cooldown: lost, not queued.
1185            if !cooldown_ready {
1186                continue;
1187            }
1188
1189            let effect_key =
1190                StoneSocketKey::new(item_type, StoneSocketSlot::effect_for(active_side))
1191                    .and_then(|key| stones.socketed(key))
1192                    .map(|stone| (stone.template_id, stone.level));
1193            // The effect socketed on the side that is NOT active right now. Four
1194            // rules speak through it, and none of them can reach the active side
1195            // — that is what keeps "hidden" meaningful.
1196            let paired_hidden = |slot: ItemType| {
1197                StoneSocketKey::new(slot, StoneSocketSlot::effect_for(active_side.flipped()))
1198                    .and_then(|key| stones.socketed(key))
1199                    .map(|stone| (stone.template_id, stone.level))
1200            };
1201
1202            // This slot has resolved: advance the shared slot-order memory before
1203            // any rule reads it, so `VA-02` and `VA-03` agree on what "the
1204            // previous slot" was and on how long the streak is.
1205            let this_slot = art::slot_order_marker(item_type);
1206            let is_repeat = last_slot == this_slot;
1207            repeat_streak = if is_repeat { repeat_streak + 1 } else { 0 };
1208            last_slot = this_slot;
1209
1210            // Visible Aspect: one of five readings of "how do active effects
1211            // behave". Every one of them only scales the number the effect
1212            // already produces — none changes what it does.
1213            let mut strength = 1.0;
1214            if let Some(aspect) = visible_aspect {
1215                match aspect.rule {
1216                    ArtifactStoneRule::Crescendo => {
1217                        strength *= art::crescendo_multiplier(
1218                            aspect.magnitude,
1219                            aspect.secondary_magnitude,
1220                            flip_progress,
1221                            flip_threshold,
1222                        );
1223                    }
1224                    ArtifactStoneRule::OpeningFive => {
1225                        // One bonus per slot per phase. The marker stores
1226                        // `revision + 1`, so an untouched attribute (`0`) reads
1227                        // as "not yet this phase" even at revision 0.
1228                        let marker = mech::attr(player, &art::opening_five_key(item_type));
1229                        let this_phase = flip_revision as i64 + 1;
1230                        if marker != this_phase {
1231                            strength *= art::total_multiplier(aspect.magnitude);
1232                            opening_five_arms.push((item_type, this_phase));
1233                        }
1234                    }
1235                    // `VA-02`: alternating slots is the point, so a fresh slot
1236                    // pays and a repeat is taxed. Both numbers are absolute
1237                    // shares of the effect, not deltas.
1238                    ArtifactStoneRule::Alternator => {
1239                        strength *= art::total_multiplier(if is_repeat {
1240                            aspect.secondary_magnitude
1241                        } else {
1242                            aspect.magnitude
1243                        });
1244                    }
1245                    // `VA-03`: the mirror of `VA-02` — staying on one slot is
1246                    // what pays, and the streak caps at `secondary_magnitude`.
1247                    // A fresh slot has a streak of zero and so runs plain.
1248                    ArtifactStoneRule::FocusedEngine => {
1249                        let bonus = (repeat_streak as f64 * aspect.magnitude)
1250                            .min(aspect.secondary_magnitude);
1251                        strength *= art::total_multiplier(100.0 + bonus);
1252                    }
1253                    // `VA-05`: the share comes from the tier of the TRIGGER that
1254                    // lit the effect, not of the effect itself — the rule pays
1255                    // builds that fire cheap triggers often.
1256                    ArtifactStoneRule::SmallGears => {
1257                        strength *= aspect.tier_share(template.tier);
1258                    }
1259                    _ => {}
1260                }
1261            }
1262
1263            let mut extras: Vec<ExtraFire> = Vec::new();
1264            let mut catch_up = None;
1265
1266            // Hidden Aspect: five readings of "what do the hidden effects do".
1267            // All of them speak through the paired hidden side and never through
1268            // the active one.
1269            if let Some(aspect) = hidden_aspect {
1270                let share = art::share_multiplier(aspect.magnitude);
1271                match aspect.rule {
1272                    // `HA-01`: every trigger runs the paired hidden effect too.
1273                    ArtifactStoneRule::BackgroundVoice => {
1274                        extras.extend(paired_hidden(item_type).map(|(template_id, level)| {
1275                            ExtraFire {
1276                                template_id,
1277                                level,
1278                                share,
1279                            }
1280                        }));
1281                    }
1282                    // `HA-03`: the *next* slot's hidden effect answers instead of
1283                    // this slot's, so the rule relays across the build rather
1284                    // than doubling one slot.
1285                    ArtifactStoneRule::CrossRelay => {
1286                        extras.extend(paired_hidden(next_two_sided_slot(item_type)).map(
1287                            |(template_id, level)| ExtraFire {
1288                                template_id,
1289                                level,
1290                                share,
1291                            },
1292                        ));
1293                    }
1294                    // `HA-04`: the same fire as `HA-01`, but only once the gauge
1295                    // is past its own share of the bar — a late-phase rule rather
1296                    // than an always-on one.
1297                    ArtifactStoneRule::SecondHalf
1298                        if gauge_fill >= (aspect.secondary_magnitude / 100.0) =>
1299                    {
1300                        extras.extend(paired_hidden(item_type).map(|(template_id, level)| {
1301                            ExtraFire {
1302                                template_id,
1303                                level,
1304                                share,
1305                            }
1306                        }));
1307                    }
1308                    // `HA-05`: decided after the active effect has been tried —
1309                    // "wholly inapplicable" is a fact about the run, not about
1310                    // the socket.
1311                    ArtifactStoneRule::CatchUp => {
1312                        catch_up = paired_hidden(item_type).map(|(template_id, level)| ExtraFire {
1313                            template_id,
1314                            level,
1315                            share,
1316                        });
1317                    }
1318                    _ => {}
1319                }
1320            }
1321
1322            // Flip Aspect: three of its five rules land on the flip tick
1323            // (`crate::logic::artifacts`); these two describe the ordinary procs
1324            // that follow one.
1325            if let Some(aspect) = flip_aspect {
1326                let share = art::share_multiplier(aspect.magnitude);
1327                match aspect.rule {
1328                    // `FA-03`: the first ordinary fire of each newly active
1329                    // effect is echoed once, at a share — a warm-up, so the same
1330                    // marker shape as `VA-04` and a key of its own.
1331                    ArtifactStoneRule::WarmStart => {
1332                        let marker = mech::attr(player, &art::warm_start_key(item_type));
1333                        let this_phase = flip_revision as i64 + 1;
1334                        if marker != this_phase
1335                            && let Some((template_id, level)) = effect_key
1336                        {
1337                            extras.push(ExtraFire {
1338                                template_id,
1339                                level,
1340                                share,
1341                            });
1342                            warm_start_arms.push((item_type, this_phase));
1343                        }
1344                    }
1345                    // `FA-04`: the departing side keeps speaking for the first
1346                    // few procs of the new phase. The count is per phase and
1347                    // shared across slots — the catalog says "the first five
1348                    // trigger procs", not "five per slot".
1349                    ArtifactStoneRule::Afterimage => {
1350                        let allowance = aspect.rule_param.max(0);
1351                        if afterimage_seen + afterimage_used < allowance
1352                            && let Some((template_id, level)) = paired_hidden(item_type)
1353                        {
1354                            extras.push(ExtraFire {
1355                                template_id,
1356                                level,
1357                                share,
1358                            });
1359                            afterimage_used += 1;
1360                        }
1361                    }
1362                    _ => {}
1363                }
1364            }
1365
1366            // Pet Facets that ride on an ORDINARY gear proc. All three read
1367            // state armed by the Team Die and are inert for a player who has
1368            // not rolled them, so the no-facet path is the old one exactly.
1369            //
1370            // Everything they add runs through the same `ExtraFire` shape the
1371            // artifact rules use, which is what keeps them inside the existing
1372            // provenance and gauge guards: an extra fire is `Proc` and its gauge
1373            // contribution is dropped.
1374            {
1375                // `PET-09 Prepared Slots`: the first ordinary proc of EACH gear
1376                // slot in the phase runs stronger. Same marker shape as
1377                // `VA-04 Opening Five` — `revision + 1`, so an untouched
1378                // attribute reads as "not yet this phase" at revision 0.
1379                let prepared = pet_mech::attr(player, pet_mech::PREPARED_SLOTS);
1380                if prepared > 0 {
1381                    let key = pet_mech::prepared_slot_key(item_type);
1382                    let this_phase = flip_revision as i64 + 1;
1383                    if pet_mech::attr(player, &key) != this_phase {
1384                        strength *= prepared as f64 / 10_000.0;
1385                        pet_prepared_arms.push((key, this_phase));
1386                    }
1387                }
1388                // `PET-09 First Spell`: the FIRST ordinary gear Effect of the
1389                // phase executes a second time. ONE repeat for the whole phase,
1390                // not one per slot — `pet_first_spell_left` is what makes that
1391                // true when several slots fire on the same event.
1392                if pet_first_spell_left > 0
1393                    && let Some(share) = pet_mech::armed(
1394                        player,
1395                        pet_mech::FIRST_SPELL,
1396                        pet_mech::FIRST_SPELL_CHARGES,
1397                    )
1398                    && let Some((template_id, level)) = effect_key
1399                {
1400                    extras.push(ExtraFire {
1401                        template_id,
1402                        level,
1403                        share: share as f64 / 10_000.0,
1404                    });
1405                    pet_first_spell_used += 1;
1406                    pet_first_spell_left -= 1;
1407                }
1408                // `PET-10 Souvenir`: the next N Trigger procs are each
1409                // accompanied by the paired HIDDEN effect at a share — the same
1410                // reading of the hidden side as `HA-01 Background Voice`.
1411                if pet_souvenir_left > 0
1412                    && let Some(share) = pet_mech::armed(
1413                        player,
1414                        pet_mech::SOUVENIR_SHARE,
1415                        pet_mech::SOUVENIR_CHARGES,
1416                    )
1417                    && let Some((template_id, level)) = paired_hidden(item_type)
1418                {
1419                    extras.push(ExtraFire {
1420                        template_id,
1421                        level,
1422                        share: share as f64 / 10_000.0,
1423                    });
1424                    pet_souvenir_used += 1;
1425                    pet_souvenir_left -= 1;
1426                }
1427            }
1428
1429            fires.push(Fire {
1430                item_type,
1431                trigger_template: template.id,
1432                effect: effect_key,
1433                strength,
1434                extras,
1435                catch_up,
1436            });
1437        }
1438
1439        // Bookkeeping, in one borrow and — critically — BEFORE the effect loop.
1440        {
1441            let Some(player) = state
1442                .active_fight
1443                .as_mut()
1444                .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == player_id))
1445            else {
1446                return;
1447            };
1448            // Same reason as the cooldown: the markers are spent before the
1449            // effects run, so a re-entrant fire in the same phase cannot take a
1450            // once-per-phase bonus twice.
1451            for (item_type, phase) in opening_five_arms {
1452                player
1453                    .attributes
1454                    .set(&art::opening_five_key(item_type), phase);
1455            }
1456            for (item_type, phase) in warm_start_arms {
1457                player
1458                    .attributes
1459                    .set(&art::warm_start_key(item_type), phase);
1460            }
1461            if afterimage_used > 0 {
1462                player
1463                    .attributes
1464                    .set(art::AFTERIMAGE_REVISION, flip_revision as i64 + 1);
1465                player
1466                    .attributes
1467                    .set(art::AFTERIMAGE_COUNT, afterimage_seen + afterimage_used);
1468            }
1469            // The slot-order memory is written whether or not an Aspect stone is
1470            // worn: a rule socketed mid-fight must read a real history, not one
1471            // that starts the moment it is inserted.
1472            player.attributes.set(art::LAST_RESOLVED_SLOT, last_slot);
1473            player
1474                .attributes
1475                .set(art::SLOT_REPEAT_STREAK, repeat_streak);
1476            if matched && !cooldown_ready {
1477                // Acceptance criterion #5: the activation is lost AND the
1478                // sequence behind it starts over, so "every 5th attack" does not
1479                // fire on the very next attack the moment the cooldown lapses.
1480                mech::reset_sequence_counters(player);
1481            }
1482            if !fires.is_empty() {
1483                player.attributes.set(
1484                    mech::GLOBAL_COOLDOWN,
1485                    (now + settings.global_trigger_cooldown_ticks) as i64,
1486                );
1487            }
1488            // BAL-027: the metronome advances whether its match fired or
1489            // burned on the global cooldown.
1490            for (key, due) in &interval_due_updates {
1491                player.attributes.set(key, *due);
1492            }
1493
1494            // Pet Facet charges, spent here for the same reason as the artifact
1495            // markers: before the effect loop dispatches anything.
1496            for (key, phase) in pet_prepared_arms {
1497                player.attributes.set(&key, phase);
1498            }
1499            for _ in 0..pet_first_spell_used {
1500                pet_mech::spend_charge(
1501                    player,
1502                    pet_mech::FIRST_SPELL,
1503                    pet_mech::FIRST_SPELL_CHARGES,
1504                );
1505            }
1506            for _ in 0..pet_souvenir_used {
1507                pet_mech::spend_charge(
1508                    player,
1509                    pet_mech::SOUVENIR_SHARE,
1510                    pet_mech::SOUVENIR_CHARGES,
1511                );
1512            }
1513            // `PET-08 Rising Gate` MODIFIES the gauge an ordinary Trigger proc
1514            // was already going to feed — one of the two Clock Crow facets the
1515            // design doc exempts from "a facet creates no Gauge". The charges
1516            // are claimed here; each claimed fire below has its own award
1517            // MULTIPLIED (BAL-014), never added to.
1518            if !fires.is_empty() {
1519                let mult = pet_mech::attr(player, pet_mech::RISING_GATE_MULT);
1520                let left = pet_mech::attr(player, pet_mech::RISING_GATE_PROCS);
1521                if mult != 0 && left > 0 {
1522                    pet_rising_gate_fires = (fires.len() as i64).min(left);
1523                    pet_rising_gate_mult = mult as f64 / 100.0;
1524                    player
1525                        .attributes
1526                        .set(pet_mech::RISING_GATE_PROCS, left - pet_rising_gate_fires);
1527                    if left - pet_rising_gate_fires <= 0 {
1528                        player.attributes.set(pet_mech::RISING_GATE_MULT, 0);
1529                    }
1530                }
1531            }
1532        }
1533
1534        for event in spent {
1535            events.push(EventPluginized::now(event));
1536        }
1537
1538        // BAL-027/BAL-018: every match of one event sums into ONE aggregate
1539        // batch gain, applied once after the loop — so the whole batch can
1540        // cross the bar at most once and the overflow burns together.
1541        let mut batch_gauge = 0.0_f64;
1542        for fire in fires {
1543            let Some(trigger) = game_config.trigger_stone_template(fire.trigger_template) else {
1544                continue;
1545            };
1546            // The fire's identity, before its outcomes: the outcomes are
1547            // emitted `Proc` with no source, so this event is the only place
1548            // the client learns WHICH slot's trigger fired.
1549            events.push(EventPluginized::now(OverlordEvent::StoneTriggerFired {
1550                entity_id: player_id,
1551                item_type: fire.item_type,
1552                trigger_stone_id: fire.trigger_template,
1553            }));
1554            // BAL-027: the trigger's own authored award — tier grants nothing.
1555            let mut gauge_gain = trigger.gauge_gain;
1556            if pet_rising_gate_fires > 0 {
1557                gauge_gain *= pet_rising_gate_mult;
1558                pet_rising_gate_fires -= 1;
1559            }
1560
1561            let ctx = StoneEffectCtx {
1562                // Replaced per fire below: the ctx is built before the effect
1563                // is resolved, and every run stamps the stone it actually ran.
1564                source: CombatSource::Other,
1565                target_id,
1566                enemy_ids: &enemy_ids,
1567                player_attack,
1568                player_max_hp,
1569                player_speed,
1570                now,
1571            };
1572
1573            // Whether the active side had anything to say at all. An empty
1574            // socket, a template pulled from under the player, and an action that
1575            // produced nothing all read the same way — which is what `HA-05
1576            // Catch-Up` means by "wholly inapplicable".
1577            let mut active_applied = false;
1578            if let Some((effect_template_id, effect_level)) = fire.effect {
1579                match game_config.effect_stone_template(effect_template_id) {
1580                    Some(effect) => {
1581                        let magnitude =
1582                            mech::effect_magnitude(settings, trigger, effect, effect_level)
1583                                * fire.strength;
1584                        let outcome = self.run_stone_effect(
1585                            state,
1586                            events,
1587                            player_id,
1588                            effect,
1589                            magnitude,
1590                            StoneEffectCtx {
1591                                source: CombatSource::StoneProc {
1592                                    stone_template_id: effect_template_id,
1593                                },
1594                                ..ctx
1595                            },
1596                        );
1597                        gauge_gain += outcome.gauge;
1598                        active_applied = outcome.applied;
1599                    }
1600                    None => tracing::warn!(
1601                        template_id = %effect_template_id,
1602                        "Skipping socketed Effect Stone with no catalog entry"
1603                    ),
1604                }
1605            }
1606
1607            // Everything the Aspect rules attached to this fire. Their gauge
1608            // contribution is deliberately **dropped** — an artifact rule that
1609            // rebroadcasts an effect must not also feed the gauge, or a build
1610            // could flip itself faster the more it rebroadcasts. See the same
1611            // rule in `crate::logic::artifacts`.
1612            let catch_up = fire.catch_up.filter(|_| !active_applied);
1613            for extra in fire.extras.iter().copied().chain(catch_up) {
1614                if extra.share <= 0.0 {
1615                    continue;
1616                }
1617                match game_config.effect_stone_template(extra.template_id) {
1618                    Some(effect) => {
1619                        let magnitude =
1620                            mech::effect_magnitude(settings, trigger, effect, extra.level)
1621                                * extra.share;
1622                        let _ = self.run_stone_effect(
1623                            state,
1624                            events,
1625                            player_id,
1626                            effect,
1627                            magnitude,
1628                            StoneEffectCtx {
1629                                source: CombatSource::StoneProc {
1630                                    stone_template_id: extra.template_id,
1631                                },
1632                                ..ctx
1633                            },
1634                        );
1635                    }
1636                    None => tracing::warn!(
1637                        template_id = %extra.template_id,
1638                        "Skipping artifact-attached Effect Stone with no catalog entry"
1639                    ),
1640                }
1641            }
1642
1643            batch_gauge += gauge_gain;
1644        }
1645
1646        if let Some(flip) = accumulate_flip_gauge(
1647            state,
1648            player_id,
1649            batch_gauge,
1650            FlipProgressSource::TriggerFired,
1651            &game_config,
1652        ) {
1653            events.push(EventPluginized::now(flip));
1654        }
1655    }
1656
1657    /// Runs one Effect Stone action. Returns whatever it contributes to the flip
1658    /// gauge on top of the trigger's own tier weight (only `GaugeFill` does).
1659    ///
1660    /// Everything that lands as a combat outcome is emitted `Proc` — derived
1661    /// copies, splash, retaliation, scheduled repeats — so it can never
1662    /// re-enter a trigger. Bookkeeping is written straight onto the player
1663    /// entity and is not trigger surface either way.
1664    ///
1665    /// Timed actions apply their delta now and schedule the exact inverse on the
1666    /// fight clock. Deltas compose, so re-applying a buff before the first one
1667    /// expires is additive in both directions and cannot strand a permanent
1668    /// bonus; the clock is cleared with the fight.
1669    ///
1670    /// Known and accepted: the attack-speed actions are a rate-limited
1671    /// amplification loop. They raise `speed`, which shortens ability cooldowns,
1672    /// which raises the Core swing rate, which fires more triggers. Not a
1673    /// provenance leak — an ability's cooldown re-arm is deliberately Core
1674    /// (`push_start_cast_replacing`), since marking it would switch triggers off
1675    /// for the rest of the fight. The loop is bounded by
1676    /// `global_trigger_cooldown_ticks`.
1677    ///
1678    /// Always runs **now**. `FA-05 Staggered Entrance` is the one rule that
1679    /// spaces its effects out, and it defers the whole run through
1680    /// [`OverlordEvent::FireArtifactEffect`] rather than delaying the events a
1681    /// run produces: arming is a direct write under v0.2, not an event, so there
1682    /// is nothing to put on the fight clock. Deferring the run instead leaves
1683    /// both semantics untouched — a spaced-out fire differs from an immediate one
1684    /// only in when it happens.
1685    pub(crate) fn run_stone_effect(
1686        &mut self,
1687        state: &mut OverlordState,
1688        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
1689        player_id: EntityId,
1690        effect: &configs::stones::EffectStoneTemplate,
1691        magnitude: f64,
1692        ctx: StoneEffectCtx<'_>,
1693    ) -> StoneEffectOutcome {
1694        // Percentages arrive as whole percent (20.0 = +20%); entity attributes
1695        // that express a fraction are permyriad.
1696        let permyriad = (magnitude * 100.0).round() as i64;
1697        let fraction = magnitude / 100.0;
1698        let charges = effect.charges.max(0);
1699        let duration_ticks = effect.duration_ticks;
1700        let derived = |ratio: f64| {
1701            (ctx.player_attack * ratio * balance::DMG_K)
1702                .floor()
1703                .max(0.0) as u64
1704        };
1705
1706        // Arming is written straight onto the player entity rather than emitted
1707        // as an increment: a magnitude is a *value*, and re-arming an effect must
1708        // overwrite it rather than stack it into nonsense.
1709        //
1710        // Answers whether anything was written — a fight that ended under the
1711        // effect has no player entity left to arm.
1712        let arm = |state: &mut OverlordState, pairs: &[(&str, i64)]| {
1713            let Some(player) = state
1714                .active_fight
1715                .as_mut()
1716                .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == player_id))
1717            else {
1718                return false;
1719            };
1720            for (key, value) in pairs {
1721                player.attributes.set(key, *value);
1722            }
1723            true
1724        };
1725
1726        // Whether the action had anywhere to land. This is what `HA-05 Catch-Up`
1727        // reads: an effect that emitted nothing — no living target, a magnitude
1728        // that floors to zero, a duration-less timed action — is "wholly
1729        // inapplicable", and the paired hidden effect answers instead. An effect
1730        // that landed for very little still counts as applied.
1731        let applied = match effect.action {
1732            EffectStoneAction::GaugeFill => {
1733                return StoneEffectOutcome {
1734                    gauge: magnitude.max(0.0),
1735                    applied: magnitude > 0.0,
1736                };
1737            }
1738            EffectStoneAction::NextAttackDamageBonus => {
1739                permyriad != 0
1740                    && arm(
1741                        state,
1742                        &[
1743                            (mech::NEXT_ATTACK_BONUS, permyriad),
1744                            (mech::NEXT_ATTACK_BONUS_CHARGES, charges.max(1)),
1745                        ],
1746                    )
1747            }
1748            EffectStoneAction::NextAttackCritChance => {
1749                permyriad != 0
1750                    && arm(
1751                        state,
1752                        &[
1753                            (mech::NEXT_ATTACK_CRIT, permyriad),
1754                            (mech::NEXT_ATTACK_CRIT_CHARGES, charges.max(1)),
1755                        ],
1756                    )
1757            }
1758            EffectStoneAction::NextAttackDoubleHit => {
1759                // `magnitude` is the total number of hits the next attack makes,
1760                // so the stone grants one fewer extra swing than that.
1761                let extra = (magnitude.round() as i64 - 1).max(0);
1762                if extra > 0 {
1763                    events.push(EventPluginized::now(OverlordEvent::EntityIncrAttribute {
1764                        entity_id: player_id,
1765                        attribute: mech::NEXT_ATTACK_EXTRA_HITS.to_string(),
1766                        delta: extra,
1767                    }));
1768                }
1769                extra > 0
1770            }
1771            EffectStoneAction::NextAttackDerivedHits => {
1772                permyriad != 0
1773                    && arm(
1774                        state,
1775                        &[
1776                            (mech::NEXT_ATTACK_DERIVED, permyriad),
1777                            (mech::NEXT_ATTACK_DERIVED_CHARGES, charges.max(1)),
1778                        ],
1779                    )
1780            }
1781            EffectStoneAction::NextAttacksSplash => {
1782                permyriad != 0
1783                    && arm(
1784                        state,
1785                        &[
1786                            (mech::NEXT_ATTACK_SPLASH, permyriad),
1787                            (mech::NEXT_ATTACK_SPLASH_CHARGES, charges.max(1)),
1788                        ],
1789                    )
1790            }
1791            EffectStoneAction::NextSkillSplash => {
1792                permyriad != 0
1793                    && arm(
1794                        state,
1795                        &[
1796                            (mech::NEXT_SKILL_SPLASH, permyriad),
1797                            (mech::NEXT_SKILL_SPLASH_CHARGES, charges.max(1)),
1798                        ],
1799                    )
1800            }
1801            EffectStoneAction::NextSkillSplitCopies => {
1802                permyriad != 0
1803                    && arm(
1804                        state,
1805                        &[
1806                            (mech::NEXT_SKILL_SPLIT, permyriad),
1807                            // `charges` is the §6 cap — "at most 3 additional
1808                            // targets" — not a number of Skills. One armed Skill,
1809                            // N extra targets.
1810                            (mech::NEXT_SKILL_SPLIT_TARGETS, charges.max(1)),
1811                        ],
1812                    )
1813            }
1814            EffectStoneAction::NextSkillPayloadBonus => {
1815                permyriad != 0
1816                    && arm(
1817                        state,
1818                        &[
1819                            (mech::NEXT_SKILL_BONUS, permyriad),
1820                            (mech::NEXT_SKILL_BONUS_CHARGES, charges.max(1)),
1821                        ],
1822                    )
1823            }
1824            EffectStoneAction::NextSkillEchoCopies => {
1825                permyriad != 0
1826                    && arm(
1827                        state,
1828                        &[
1829                            (mech::NEXT_SKILL_ECHO, permyriad),
1830                            (
1831                                mech::NEXT_SKILL_ECHO_WEAK,
1832                                (effect.secondary_magnitude * 100.0).round() as i64,
1833                            ),
1834                            (mech::NEXT_SKILL_ECHO_CHARGES, charges.max(1)),
1835                        ],
1836                    )
1837            }
1838            EffectStoneAction::NextHitTakenReduction => {
1839                let reduction = permyriad.clamp(0, 10_000);
1840                reduction != 0
1841                    && arm(
1842                        state,
1843                        &[
1844                            (mech::HIT_REDUCTION, reduction),
1845                            (mech::HIT_REDUCTION_CHARGES, charges.max(1)),
1846                        ],
1847                    )
1848            }
1849            EffectStoneAction::NextHitBlockedRetaliation => {
1850                let retaliation = derived(fraction) as i64;
1851                retaliation != 0
1852                    && arm(
1853                        state,
1854                        &[
1855                            (mech::GUARD_RETALIATION, retaliation),
1856                            (mech::GUARD_CHARGES, charges.max(1)),
1857                        ],
1858                    )
1859            }
1860            EffectStoneAction::InstantDamage => {
1861                // No living target, or a magnitude that floors to nothing: the
1862                // effect had nowhere to land. This is the case `HA-05 Catch-Up`
1863                // exists for.
1864                let Some(target_id) = ctx.target_id else {
1865                    return StoneEffectOutcome::inapplicable();
1866                };
1867                push_derived_damage(events, player_id, target_id, derived(fraction), ctx.source)
1868            }
1869            EffectStoneAction::InstantDamageAll => {
1870                let damage = derived(fraction);
1871                let mut landed = false;
1872                for target_id in ctx.enemy_ids {
1873                    landed |=
1874                        push_derived_damage(events, player_id, *target_id, damage, ctx.source);
1875                }
1876                landed
1877            }
1878            EffectStoneAction::HealMaxHpPercent => {
1879                let heal = (ctx.player_max_hp as f64 * fraction).round() as u64;
1880                if heal > 0 {
1881                    events.push(EventPluginized::now(OverlordEvent::Heal {
1882                        by_entity_id: Some(player_id),
1883                        entity_id: player_id,
1884                        heal,
1885                        origin: CombatEventOrigin::Proc,
1886                        source: ctx.source,
1887                    }));
1888                }
1889                heal > 0
1890            }
1891            EffectStoneAction::OrbitingProjectiles => {
1892                // Spread over the window rather than fired at once: the design
1893                // calls them orbiting blades, and a burst would also let one
1894                // effect land its whole payload inside a single tick.
1895                let damage = derived(fraction);
1896                let count = charges.max(1);
1897                let step = (duration_ticks / count.max(1) as u64).max(1);
1898                let mut launched = false;
1899                for index in 0..count {
1900                    let Some(target_id) = ctx.target_id else {
1901                        break;
1902                    };
1903                    if damage == 0 {
1904                        break;
1905                    }
1906                    self.fight_clock.schedule(
1907                        OverlordEvent::Damage {
1908                            by_entity_id: Some(player_id),
1909                            entity_id: target_id,
1910                            damage,
1911                            damage_data: Default::default(),
1912                            origin: CombatEventOrigin::Proc,
1913                            source: ctx.source,
1914                        },
1915                        step * (index as u64 + 1),
1916                    );
1917                    launched = true;
1918                }
1919                launched
1920            }
1921            EffectStoneAction::DelayedRepeat => {
1922                // The repeat is a plain scheduled hit, not a re-run of the
1923                // effect, which is exactly why "the repeat does not create a new
1924                // Twin" holds without a guard.
1925                let Some(target_id) = ctx.target_id else {
1926                    return StoneEffectOutcome::inapplicable();
1927                };
1928                let damage = derived(fraction);
1929                if damage == 0 {
1930                    return StoneEffectOutcome::inapplicable();
1931                }
1932                self.fight_clock.schedule(
1933                    OverlordEvent::Damage {
1934                        by_entity_id: Some(player_id),
1935                        entity_id: target_id,
1936                        damage,
1937                        damage_data: Default::default(),
1938                        origin: CombatEventOrigin::Proc,
1939                        source: ctx.source,
1940                    },
1941                    duration_ticks.max(1),
1942                );
1943                true
1944            }
1945            EffectStoneAction::SkillCooldownReduction => {
1946                // `magnitude` is in ticks here, not percent — the design says
1947                // "the longest cooldown loses 0.8 s".
1948                let by_ticks = magnitude.round().max(0.0) as u64;
1949                by_ticks > 0
1950                    && Self::with_player(state, player_id, |player| {
1951                        player.actions_queue.shorten_longest_cooldowns(
1952                            ctx.now,
1953                            charges.max(1) as usize,
1954                            by_ticks,
1955                        );
1956                    })
1957            }
1958            EffectStoneAction::ResetSkillCooldowns => {
1959                Self::with_player(state, player_id, |player| {
1960                    player.actions_queue.clear_ability_cooldowns(ctx.now);
1961                })
1962            }
1963            EffectStoneAction::CooldownRecoveryBuff => {
1964                // "+X% recovery for D ticks" integrates to exactly `D * X%` of
1965                // extra recovery, so it is applied once, up front, instead of
1966                // carrying a rate the cooldown queue has no way to express.
1967                let by_ticks = (duration_ticks as f64 * fraction).round().max(0.0) as u64;
1968                by_ticks > 0
1969                    && Self::with_player(state, player_id, |player| {
1970                        player.actions_queue.shorten_longest_cooldowns(
1971                            ctx.now,
1972                            mech::SKILL_SLOTS,
1973                            by_ticks,
1974                        );
1975                    })
1976            }
1977            EffectStoneAction::CritChanceBuff => self.apply_timed_attribute(
1978                events,
1979                player_id,
1980                "crit_chance",
1981                permyriad,
1982                duration_ticks,
1983            ),
1984            EffectStoneAction::Shield => {
1985                let shield = (ctx.player_max_hp as f64 * fraction).round() as i64;
1986                self.apply_timed_attribute(events, player_id, "shield", shield, duration_ticks)
1987            }
1988            EffectStoneAction::AttackSpeedBuff => {
1989                let delta = (ctx.player_speed as f64 * fraction).round() as i64;
1990                self.apply_timed_attribute(events, player_id, "speed", delta, duration_ticks)
1991            }
1992            EffectStoneAction::AttackSpeedMultiplier => {
1993                // `magnitude` is the multiplier (2.0 = double), so the delta is
1994                // what it adds on top of the current speed.
1995                let delta = (ctx.player_speed as f64 * (magnitude - 1.0)).round() as i64;
1996                self.apply_timed_attribute(events, player_id, "speed", delta, duration_ticks)
1997            }
1998            EffectStoneAction::IncomingDamageReduction => self.apply_timed_attribute(
1999                events,
2000                player_id,
2001                mech::INCOMING_REDUCTION,
2002                permyriad.clamp(0, 10_000),
2003                duration_ticks,
2004            ),
2005            EffectStoneAction::LifestealBuff => self.apply_timed_attribute(
2006                events,
2007                player_id,
2008                mech::LIFESTEAL,
2009                permyriad,
2010                duration_ticks,
2011            ),
2012            EffectStoneAction::DamageDealtBuff => self.apply_timed_attribute(
2013                events,
2014                player_id,
2015                mech::DAMAGE_BUFF,
2016                permyriad,
2017                duration_ticks,
2018            ),
2019            EffectStoneAction::SkillPayloadBuff => self.apply_timed_attribute(
2020                events,
2021                player_id,
2022                mech::SKILL_BUFF,
2023                permyriad,
2024                duration_ticks,
2025            ),
2026            EffectStoneAction::DamageFloorGuard => {
2027                self.apply_timed_attribute(events, player_id, mech::DAMAGE_FLOOR, 1, duration_ticks)
2028            }
2029        };
2030        StoneEffectOutcome {
2031            gauge: 0.0,
2032            applied,
2033        }
2034    }
2035
2036    /// Runs `edit` against the player's fight entity. Answers whether the fight
2037    /// still had one — a caller that reports `applied` needs to tell "did the
2038    /// work" from "there was nobody left to do it to".
2039    pub(crate) fn with_player(
2040        state: &mut OverlordState,
2041        player_id: EntityId,
2042        edit: impl FnOnce(&mut Entity),
2043    ) -> bool {
2044        if let Some(player) = state
2045            .active_fight
2046            .as_mut()
2047            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == player_id))
2048        {
2049            edit(player);
2050            return true;
2051        }
2052        false
2053    }
2054
2055    /// Applies `delta` to `attribute` now and schedules the exact inverse
2056    /// `duration_ticks` later. Returns whether anything was actually applied.
2057    ///
2058    /// A zero duration is refused rather than applied: a timed buff with no
2059    /// expiry would last the whole fight, which is a content error the runtime
2060    /// must not turn into a permanent stat.
2061    pub(crate) fn apply_timed_attribute(
2062        &mut self,
2063        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
2064        player_id: EntityId,
2065        attribute: &str,
2066        delta: i64,
2067        duration_ticks: u64,
2068    ) -> bool {
2069        if delta == 0 {
2070            return false;
2071        }
2072        if duration_ticks == 0 {
2073            tracing::warn!(
2074                attribute,
2075                "Skipping timed Effect Stone action with duration_ticks = 0 — it would never \
2076                 expire"
2077            );
2078            return false;
2079        }
2080        events.push(EventPluginized::now(OverlordEvent::EntityIncrAttribute {
2081            entity_id: player_id,
2082            attribute: attribute.to_string(),
2083            delta,
2084        }));
2085        self.fight_clock.schedule(
2086            OverlordEvent::EntityIncrAttribute {
2087                entity_id: player_id,
2088                attribute: attribute.to_string(),
2089                delta: -delta,
2090            },
2091            duration_ticks,
2092        );
2093        true
2094    }
2095}
2096
2097/// What running one Effect Stone action produced.
2098///
2099/// `applied` is what `HA-05 Catch-Up` reads: an action that emitted nothing —
2100/// instant damage with no living target or a magnitude that floors to zero, a
2101/// buff whose delta rounds to zero, a duration-less timed action — is "wholly
2102/// inapplicable", and the rule lets the paired hidden effect answer instead.
2103/// Everything else, including an effect that landed for very little, counts as
2104/// applied.
2105#[derive(Clone, Copy, Debug, PartialEq)]
2106pub(crate) struct StoneEffectOutcome {
2107    /// Contribution to the flip gauge on top of the trigger's tier weight (only
2108    /// `GaugeFill` produces one).
2109    pub(crate) gauge: f64,
2110    pub(crate) applied: bool,
2111}
2112
2113impl StoneEffectOutcome {
2114    pub(crate) const fn inapplicable() -> Self {
2115        Self {
2116            gauge: 0.0,
2117            applied: false,
2118        }
2119    }
2120}
2121
2122/// Combat readings one Effect Stone action may need, taken once before any
2123/// mutation.
2124///
2125/// `Copy`, because an artifact rebroadcast hands the same readings to five
2126/// effects in a row — which is why `enemy_ids` is borrowed rather than owned.
2127#[derive(Clone, Copy)]
2128pub(crate) struct StoneEffectCtx<'a> {
2129    /// Breakdown attribution of everything this run emits: the fired Effect
2130    /// Stone, or the Aspect stone when an artifact is what rebroadcast it.
2131    pub(crate) source: CombatSource,
2132    pub(crate) target_id: Option<EntityId>,
2133    pub(crate) enemy_ids: &'a [EntityId],
2134    pub(crate) player_attack: f64,
2135    pub(crate) player_max_hp: u64,
2136    pub(crate) player_speed: i64,
2137    pub(crate) now: u64,
2138}
2139
2140/// One derived hit: `Proc`, so it feeds no trigger and starts no cascade.
2141/// Answers whether anything was emitted.
2142fn push_derived_damage(
2143    events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
2144    player_id: EntityId,
2145    target_id: EntityId,
2146    damage: u64,
2147    source: CombatSource,
2148) -> bool {
2149    if damage == 0 {
2150        return false;
2151    }
2152    events.push(EventPluginized::now(OverlordEvent::Damage {
2153        by_entity_id: Some(player_id),
2154        entity_id: target_id,
2155        damage,
2156        damage_data: Default::default(),
2157        origin: CombatEventOrigin::Proc,
2158        source,
2159    }));
2160    true
2161}
2162
2163/// Spends the armed effect state this Core outcome consumes, and returns the
2164/// derived events it produced.
2165///
2166/// The second half of the Effect Stones: a trigger arms a state, this spends it
2167/// on the real action it was armed for. Kept out of the fight primitives on
2168/// purpose — an outgoing bonus lands as a derived hit *beside* the real one
2169/// rather than rewriting it, which is what keeps the real Basic Attack or
2170/// original Skill Core even when an effect changed its total damage.
2171///
2172/// Timing, uniform across every `NEXT_*` state spent here: a cast arrives as
2173/// `CastAbility` first and its landed `Damage` after, and this hook runs on
2174/// both, so a state armed on the cast is spent by *that cast's own* payload
2175/// rather than the following one. One dispatch early against the literal
2176/// wording of the design, and deliberate — the alternative is a "which cast
2177/// armed me" stamp on every key, and the effect lands on exactly one Skill
2178/// either way. The states consumed inside the fight primitives
2179/// (`NEXT_ATTACK_BONUS`, `NEXT_ATTACK_CRIT`) do not have this property: the
2180/// swing they modify is already resolved when the trigger arms them.
2181fn spend_effect_state(
2182    player: &mut Entity,
2183    outcome: CoreOutcome,
2184    player_attack: f64,
2185    enemy_ids: &[EntityId],
2186    target_id: Option<EntityId>,
2187) -> Vec<OverlordEvent> {
2188    let mut produced = Vec::new();
2189    let CoreOutcome::HitLanded {
2190        damage: landed,
2191        target: struck,
2192        ..
2193    } = outcome
2194    else {
2195        return produced;
2196    };
2197    let player_id = player.id;
2198    let derived = |permyriad: i64| {
2199        (player_attack * (permyriad as f64 / 10_000.0) * balance::DMG_K)
2200            .floor()
2201            .max(0.0) as u64
2202    };
2203    let mut hit = |target: EntityId, damage: u64| {
2204        if damage > 0 {
2205            produced.push(OverlordEvent::Damage {
2206                by_entity_id: Some(player_id),
2207                entity_id: target,
2208                damage,
2209                damage_data: Default::default(),
2210                origin: CombatEventOrigin::Proc,
2211                source: CombatSource::ArmedBonus,
2212            });
2213        }
2214    };
2215
2216    let is_skill = mech::attr(player, mech::CAST_KIND) == mech::CAST_KIND_SKILL;
2217    let mut spend = Vec::new();
2218
2219    // Timed outgoing bonuses: all damage, plus original Skills only.
2220    let mut bonus = mech::attr(player, mech::DAMAGE_BUFF);
2221    if is_skill {
2222        bonus += mech::attr(player, mech::SKILL_BUFF);
2223    }
2224
2225    if is_skill {
2226        if let Some(one_shot) = mech::armed_magnitude(
2227            player,
2228            mech::NEXT_SKILL_BONUS,
2229            mech::NEXT_SKILL_BONUS_CHARGES,
2230        ) {
2231            bonus += one_shot;
2232            spend.push((mech::NEXT_SKILL_BONUS, mech::NEXT_SKILL_BONUS_CHARGES));
2233        }
2234        if let Some(echo) =
2235            mech::armed_magnitude(player, mech::NEXT_SKILL_ECHO, mech::NEXT_SKILL_ECHO_CHARGES)
2236        {
2237            if let Some(target) = target_id {
2238                hit(target, derived(echo));
2239                let weak = mech::attr(player, mech::NEXT_SKILL_ECHO_WEAK);
2240                if mech::attr(player, mech::NEXT_SKILL_ECHO_CHARGES) > 1 && weak > 0 {
2241                    hit(target, derived(weak));
2242                }
2243            }
2244            spend.push((mech::NEXT_SKILL_ECHO, mech::NEXT_SKILL_ECHO_CHARGES));
2245        }
2246        // `EF-E08` Prismatic Cast — §6: derived copies of the Skill worth
2247        // `magnitude`% of what it just landed, onto **at most N additional**
2248        // targets. The cap is the mechanic: it is what separates this Epic from
2249        // the Rare splash (a flat share of Attack against the whole line), and
2250        // the deployed waves stream 5-6 mobs, so "every enemy" would be twice
2251        // the reach the design asks for.
2252        if let Some(share) = mech::armed_magnitude(
2253            player,
2254            mech::NEXT_SKILL_SPLIT,
2255            mech::NEXT_SKILL_SPLIT_TARGETS,
2256        ) {
2257            let copy = (landed as f64 * (share as f64 / 10_000.0)).floor().max(0.0) as u64;
2258            let cap = mech::attr(player, mech::NEXT_SKILL_SPLIT_TARGETS).max(0) as usize;
2259            for target in enemy_ids
2260                .iter()
2261                .filter(|id| **id != struck)
2262                .take(cap)
2263                .copied()
2264                .collect::<Vec<_>>()
2265            {
2266                hit(target, copy);
2267            }
2268            // Spent whole: the count is a target cap for ONE Skill, not a
2269            // number of Skills, so both keys clear together.
2270            player.attributes.set(mech::NEXT_SKILL_SPLIT, 0);
2271            player.attributes.set(mech::NEXT_SKILL_SPLIT_TARGETS, 0);
2272        }
2273        if let Some(splash) = mech::armed_magnitude(
2274            player,
2275            mech::NEXT_SKILL_SPLASH,
2276            mech::NEXT_SKILL_SPLASH_CHARGES,
2277        ) {
2278            let damage = derived(splash);
2279            for target in enemy_ids {
2280                hit(*target, damage);
2281            }
2282            spend.push((mech::NEXT_SKILL_SPLASH, mech::NEXT_SKILL_SPLASH_CHARGES));
2283        }
2284    } else {
2285        if let Some(extra) = mech::armed_magnitude(
2286            player,
2287            mech::NEXT_ATTACK_DERIVED,
2288            mech::NEXT_ATTACK_DERIVED_CHARGES,
2289        ) {
2290            let damage = derived(extra);
2291            let hits = mech::attr(player, mech::NEXT_ATTACK_DERIVED_CHARGES).max(0);
2292            for _ in 0..hits {
2293                if let Some(target) = target_id {
2294                    hit(target, damage);
2295                }
2296            }
2297            // Spent whole: the charges ARE the hits, not a number of attacks.
2298            player.attributes.set(mech::NEXT_ATTACK_DERIVED, 0);
2299            player.attributes.set(mech::NEXT_ATTACK_DERIVED_CHARGES, 0);
2300        }
2301        if let Some(splash) = mech::armed_magnitude(
2302            player,
2303            mech::NEXT_ATTACK_SPLASH,
2304            mech::NEXT_ATTACK_SPLASH_CHARGES,
2305        ) {
2306            let damage = derived(splash);
2307            for target in enemy_ids {
2308                hit(*target, damage);
2309            }
2310            spend.push((mech::NEXT_ATTACK_SPLASH, mech::NEXT_ATTACK_SPLASH_CHARGES));
2311        }
2312    }
2313
2314    if bonus > 0
2315        && let Some(target) = target_id
2316    {
2317        hit(target, derived(bonus));
2318    }
2319
2320    let lifesteal = mech::attr(player, mech::LIFESTEAL);
2321    if lifesteal > 0 {
2322        let heal = (player_attack * (lifesteal as f64 / 10_000.0) * balance::DMG_K).round() as u64;
2323        if heal > 0 {
2324            produced.push(OverlordEvent::Heal {
2325                by_entity_id: Some(player_id),
2326                entity_id: player_id,
2327                heal,
2328                origin: CombatEventOrigin::Proc,
2329                source: CombatSource::ArmedBonus,
2330            });
2331        }
2332    }
2333
2334    for (magnitude_key, charges_key) in spend {
2335        let left = mech::attr(player, charges_key) - 1;
2336        player.attributes.set(charges_key, left.max(0));
2337        if left <= 0 {
2338            player.attributes.set(magnitude_key, 0);
2339        }
2340    }
2341
2342    produced
2343}
2344
2345/// Adds one fire's contribution to the shared gauge and returns the `GlobalFlip`
2346/// it crossed, if any.
2347///
2348/// The gauge rules are unchanged and not re-implemented here: one gauge for the
2349/// whole build, the flip is global, overflow above the threshold burns — all of
2350/// that is [`essences::flip::FlipState::accumulate`], the same call the damage
2351/// source makes. A player whose flip is still locked carries no
2352/// `Entity::flip_state`, so the trigger simply adds nothing.
2353pub(crate) fn accumulate_flip_gauge(
2354    state: &mut OverlordState,
2355    player_id: EntityId,
2356    gain: f64,
2357    source: FlipProgressSource,
2358    game_config: &configs::game_config::GameConfig,
2359) -> Option<OverlordEvent> {
2360    if gain <= 0.0 {
2361        return None;
2362    }
2363    // The bar, not the raw setting: `ART-03 Halfway Bell` lowers it to a share
2364    // of the configured threshold — read from the GAUGE OWNER's own artifacts
2365    // through the same helper the ramp uses, so one combatant never has two
2366    // ideas of where full is. Read before the fight is borrowed mutably.
2367    let threshold = crate::entities::combatant_flip_threshold(state, player_id, game_config);
2368    let fight = state.active_fight.as_mut()?;
2369    let is_local_player = fight.player_id == player_id;
2370    let is_party_ally = fight.party_player_id == Some(player_id);
2371    let player = fight.entities.iter_mut().find(|e| e.id == player_id)?;
2372    let flip_state = player.flip_state.as_mut()?;
2373
2374    // The three shared gauge recordings, with this producer's `source` label —
2375    // without them a producer is invisible on the source-labelled flip
2376    // dashboard even while it drives every flip.
2377    let attrs = [opentelemetry::KeyValue::new("source", source.to_string())];
2378    let metrics = crate::logic::fighting::flip_progress_metrics();
2379    metrics.gains.add(1, &attrs);
2380    metrics.amount.record(gain, &attrs);
2381
2382    let transition = flip_state.accumulate(source, gain, threshold);
2383    match transition {
2384        Ok(outcome) => {
2385            // Mirror the fight-local gauge into the matching durable copy: the
2386            // session's own for the hero, the party/PvP snapshots for the
2387            // others (those never persist past the viewer's fight).
2388            let snapshot = *flip_state;
2389            if is_local_player {
2390                state.flip_state = snapshot;
2391            } else if is_party_ally {
2392                state.party.party_flip_state = Some(snapshot);
2393            } else if let Some(pvp) = state.pvp_state.as_mut()
2394                && pvp.opponent_state.id() == player_id
2395            {
2396                pvp.opponent_flip_state = snapshot;
2397            }
2398            let transition = outcome?;
2399            metrics.flips.add(1, &attrs);
2400            Some(OverlordEvent::GlobalFlip {
2401                entity_id: player_id,
2402                source: transition.source,
2403                from_side: transition.from_side,
2404                to_side: transition.to_side,
2405                revision: transition.revision,
2406            })
2407        }
2408        Err(err) => {
2409            tracing::warn!(gain, %source, "Skipping invalid flip gauge gain: {err}");
2410            None
2411        }
2412    }
2413}
2414
2415/// The signed drop order is `50/50` kind → `55/25/15/5` rarity → uniform inside
2416/// the drawn tier (BAL-012, released with BAL-031's roll order).
2417///
2418/// If the drawn tier holds no catalog entry the roll falls back to the whole
2419/// `kind` catalog rather than dropping nothing: an unpopulated tier is a
2420/// content gap, and swallowing the drop would silently cut the faucet that
2421/// BAL-031's rates were fitted against.
2422fn pick_stone_template(
2423    rng: &mut rand::rngs::StdRng,
2424    game_config: &configs::game_config::GameConfig,
2425    kind: StoneKind,
2426) -> Option<StoneTemplateId> {
2427    let catalog: Vec<(StoneTemplateId, StoneTier)> = match kind {
2428        StoneKind::Trigger => game_config
2429            .trigger_stones
2430            .iter()
2431            .map(|t| (t.id, t.tier))
2432            .collect(),
2433        StoneKind::Effect => game_config
2434            .effect_stones
2435            .iter()
2436            .map(|t| (t.id, t.tier))
2437            .collect(),
2438    };
2439    if catalog.is_empty() {
2440        return None;
2441    }
2442
2443    let tier = game_config
2444        .stones_settings
2445        .dropped_tier(rand::RngExt::random::<f64>(rng));
2446    let in_tier: Vec<StoneTemplateId> = catalog
2447        .iter()
2448        .filter(|(_, t)| *t == tier)
2449        .map(|(id, _)| *id)
2450        .collect();
2451
2452    let pool: Vec<StoneTemplateId> = if in_tier.is_empty() {
2453        catalog.iter().map(|(id, _)| *id).collect()
2454    } else {
2455        in_tier
2456    };
2457    let index = rand::RngExt::random_range(rng, 0..pool.len());
2458    pool.get(index).copied()
2459}