overlord_event_system/logic/
handler.rs

1use crate::{
2    BehaviorRegistry, attributes, event::OverlordEvent, game_config_helpers::GameConfigLookup,
3    state::OverlordState,
4};
5
6use std::sync::Arc;
7
8use configs::game_config::GameConfig;
9
10use analytics::constants::METRICS_TARGET;
11
12use essences::{
13    ad_usage::{AdPlacement, AdUsageData},
14    character_state::CharacterState,
15    combat_origin::CombatEventOrigin,
16    currency::{
17        CurrencyConsumer, CurrencySource, CurrencyUnit, check_can_decrease_currencies,
18        decrease_currencies, increase_currencies,
19    },
20    entity::EntityState,
21    fighting::ActiveFight,
22    mail::Mail,
23    offers::OfferTemplate,
24    quest::{QuestGroupType, QuestInstance},
25    stones::StoneKind,
26};
27
28pub use event_system::{
29    event::{EventPluginized, EventStruct},
30    plugin::{cron::CronMark, delayed::DelayedMark},
31    system::EventHandleResult,
32};
33
34/// A fight's own random stream: one seed per match, one fight-local draw
35/// counter. Both drivers bump it on exactly the same events, so the fight the
36/// player watches rolls identically to the fight that was persisted.
37#[derive(Debug)]
38struct FightRng {
39    seed: event_system::random::Seed,
40    draw: u64,
41}
42
43#[derive(Debug)]
44pub struct OverlordLogic {
45    pub(super) game_config: configs::SharedGameConfig,
46    pub(super) behaviors: Arc<BehaviorRegistry>,
47    pub(super) frontend: bool,
48    pub(super) start_fight_tick: u64,
49    /// Combat scheduler: delayed combat events + the FightProgress heartbeat.
50    /// Drained via `collect_due_scheduled` by whichever loop drives this
51    /// handler — the live `System` or the `FightEngine`.
52    pub(crate) fight_clock: crate::fight::FightClock,
53    /// Seed handed over for the PvP fight that is about to be prepared. A PvP
54    /// outcome is decided by a precalculation and persisted before the player
55    /// sees anything; the live fight must therefore replay that exact fight
56    /// rather than roll a second one. One-shot: armed by the caller that ran
57    /// the precalculation, consumed by the next `PrepareFight{PVPFight}`.
58    ///
59    /// Deliberately not in `OverlordState`: state is JSON-patched to the
60    /// client, and a client holding the seed could precompute the outcome.
61    pending_fight_seed: Option<event_system::random::Seed>,
62    /// Provenance of the event being dispatched right now: `Core` for genuine
63    /// combat, non-Core while a modifier's work is resolving. Armed by
64    /// `pre_event`, consumed by `post_event`.
65    dispatch_origin: CombatEventOrigin,
66    /// The armed fight's stream, live for the duration of the fight.
67    fight_rng: Option<FightRng>,
68    /// Stream key drawn for the event currently being dispatched, assigned by
69    /// `pre_event` and read by `event_rng`. `None` when the event is not part
70    /// of a seeded fight, in which case the driver's own RNG is used.
71    current_fight_draw: Option<u64>,
72    /// Id of the last fight whose `EndFight` was processed. Producers can race
73    /// two `EndFight` events for one fight (max-duration timeout + an
74    /// in-flight projectile death); the end-of-fight pipeline (PvP rating,
75    /// vassal, reward bundles, chapter advance) must run exactly once.
76    pub(super) ended_fight_id: Option<uuid::Uuid>,
77    /// Per-fight damage/heal accumulator, keyed by fight INSTANCE id.
78    ///
79    /// Deliberately not in `OverlordState`: `active_fight` is the dominant
80    /// per-tick patch cost and this changes on every hit, while the summary is
81    /// needed exactly once — at `EndFight`, where it is written to
82    /// `OverlordState::last_fight_breakdown`. Because `OverlordLogic` drives
83    /// both the live path and `FightEngine`, live fights, the PvP
84    /// precalculation and the bot sim are all covered by this one field.
85    pub(super) fight_breakdown_acc: Option<crate::fight::BreakdownAccumulator>,
86    /// Game time of the last party-ally DB refresh (`RefreshPartyMemberState`
87    /// emission). Gates the refresh to at most once per
88    /// `PARTY_REFRESH_INTERVAL_SEC`. Game time, not `FightClock` ticks: the
89    /// tick counter is driven by `std::time::Instant`, which the simulation's
90    /// fake clock does not scale, so ticks and game seconds diverge there.
91    pub(super) last_party_refresh_at: Option<chrono::DateTime<chrono::Utc>>,
92    pub(super) compute_fields_duration: opentelemetry::metrics::Histogram<f64>,
93    /// Tracks the source/consumer for currency change metrics logging.
94    pub(super) last_currency_source: Option<String>,
95}
96
97impl OverlordLogic {
98    // No analytics span here: the unified `OverlordEventHandler::handle_event`
99    // already opens the per-event METRICS_TARGET span with these exact fields,
100    // and a second nested one would double span volume on the 10Hz fight path.
101    /// Per-event pre-amble: stamps the combat clock so relative `schedule`
102    /// calls made anywhere in the dispatch resolve against the current tick,
103    /// and records the currency source label for the next `compute_fields`
104    /// diff. Called by `handle_event` and by the monolith's merged
105    /// (logic + persistence) arms, which bypass this dispatch.
106    pub fn pre_event(&mut self, event: &OverlordEvent, current_tick: u64) {
107        self.fight_clock.set_now(current_tick);
108
109        match event {
110            // A PvP fight replays the precalculation that already decided it —
111            // but only if a seed was armed for it. A `PrepareFight` with no
112            // precalculation behind it (PvE, or a client-sent event) finds no
113            // seed and keeps the driver's own stream, exactly as before.
114            OverlordEvent::PrepareFight { prepare_fight_type } => {
115                self.fight_rng = match prepare_fight_type {
116                    crate::event::PrepareFightType::PVPFight { .. } => self
117                        .pending_fight_seed
118                        .take()
119                        .map(|seed| FightRng { seed, draw: 0 }),
120                    _ => None,
121                };
122            }
123            OverlordEvent::EndFight { .. } => self.fight_rng = None,
124            _ => {}
125        }
126
127        self.current_fight_draw = match &mut self.fight_rng {
128            Some(fight) if Self::is_fight_scoped(event) => {
129                let key = fight.seed.with_fight_draw(fight.draw);
130                fight.draw += 1;
131                Some(key)
132            }
133            _ => None,
134        };
135
136        self.last_currency_source = match event {
137            OverlordEvent::CurrencyIncrease {
138                currency_source, ..
139            } => Some(format!("{currency_source:?}")),
140            OverlordEvent::CurrencyDecrease {
141                currency_consumer, ..
142            } => Some(format!("{currency_consumer:?}")),
143            other => Some(other.to_string()),
144        };
145
146        // Provenance pre-amble. Everything this dispatch produces inherits the
147        // dispatching event's mark: the result events are restamped in
148        // `post_event`, and the delayed ones — which handlers push straight
149        // onto the clock instead of returning — are stamped by the clock while
150        // it stays armed.
151        self.dispatch_origin = event.carried_origin().unwrap_or(CombatEventOrigin::Core);
152        self.fight_clock.set_dispatch_origin(self.dispatch_origin);
153    }
154
155    /// Provenance of the dispatch in progress. Anything this dispatch queues on
156    /// an entity's action queue must be stamped with it, because the queue
157    /// drains on a later tick, long after `post_event` disarmed the scope.
158    ///
159    /// Two kinds of queue entry deliberately do *not* take this mark and stay
160    /// Core: an ability's cooldown re-arm and a charge-driven pet ult. Both are
161    /// the entity's own cadence — the same metronome ticks with or without a
162    /// modifier — so marking them would turn every later swing into a
163    /// modifier's product and switch triggers off for the rest of the fight.
164    /// See [`essences::entity::EntityActionsQueue::push_start_cast_replacing`].
165    pub fn dispatch_origin(&self) -> CombatEventOrigin {
166        self.dispatch_origin
167    }
168
169    /// Closing half of the provenance pre-amble: disarms the clock and
170    /// restamps everything this dispatch is handing back.
171    ///
172    /// Must run at the end of every dispatch that called
173    /// [`OverlordLogic::pre_event`] — this one included, plus the monolith's
174    /// merged (logic + persistence) arms, which replicate the wrapper without
175    /// going through `handle_event`.
176    ///
177    /// The restamp only ever *upgrades* to a non-Core mark, so an event a
178    /// modifier already marked cannot be reset by a later Core dispatch. Events
179    /// that carry no provenance (`SpawnEntity`, `FightProgress`, the flip) are
180    /// left alone by `set_origin` — see
181    /// [`crate::event::OverlordEvent::carried_origin`] for why those are
182    /// boundaries rather than gaps, and
183    /// [`OverlordLogic::dispatch_origin`] for the delayed hop through the
184    /// entity action queue.
185    pub fn post_event(&mut self, result: &mut EventHandleResult<OverlordEvent, OverlordState>) {
186        let origin = std::mem::replace(&mut self.dispatch_origin, CombatEventOrigin::Core);
187        self.fight_clock
188            .set_dispatch_origin(CombatEventOrigin::Core);
189        if origin.is_core() {
190            return;
191        }
192        for out in result.events_mut() {
193            out.event_mut().set_origin(origin);
194        }
195    }
196
197    /// Add one applied damage/heal amount to the fight's breakdown.
198    ///
199    /// Keyed on the fight INSTANCE: a new fight replaces the accumulator, and a
200    /// delayed hit that outlives its fight cannot land in the next one. Pure
201    /// bookkeeping — it reads nothing from state and writes nothing back, which
202    /// is what keeps the PvP replay identical to its precalculation.
203    #[allow(clippy::too_many_arguments)]
204    pub(super) fn record_fight_breakdown(
205        &mut self,
206        fight_instance_id: uuid::Uuid,
207        fight_id: essences::fighting::FightTemplateId,
208        actor: crate::fight::BreakdownActor,
209        source: essences::fight_breakdown::CombatSource,
210        damage: u64,
211        heal: u64,
212        crit: bool,
213    ) {
214        let acc = match &mut self.fight_breakdown_acc {
215            Some(acc) if acc.fight_instance_id() == fight_instance_id => acc,
216            _ => self
217                .fight_breakdown_acc
218                .insert(crate::fight::BreakdownAccumulator::new(
219                    fight_instance_id,
220                    fight_id,
221                )),
222        };
223        acc.record(actor, source, damage, heal, crit);
224    }
225
226    /// The breakdown accumulated for the fight in progress, as a summary.
227    ///
228    /// The live path does not need this — `handle_end_fight` drains the
229    /// accumulator into `OverlordState::last_fight_breakdown`. It exists for
230    /// [`crate::fight::FightEngine`], which deliberately does NOT process
231    /// `EndFight` (progression side effects must not run inside a simulation)
232    /// and so would otherwise have no way to read what its fight produced.
233    pub fn fight_breakdown(
234        &self,
235        is_win: bool,
236        duration_ticks: u64,
237    ) -> Option<essences::fight_breakdown::FightBreakdown> {
238        self.fight_breakdown_acc
239            .as_ref()
240            .map(|acc| acc.finish(is_win, duration_ticks))
241    }
242
243    /// Hand the next `PrepareFight{PVPFight}` the seed of the precalculation
244    /// whose outcome was persisted, so the live fight reproduces it.
245    pub fn arm_fight_seed(&mut self, seed: event_system::random::Seed) {
246        self.pending_fight_seed = Some(seed);
247    }
248
249    /// RNG for the event `pre_event` was just called with: the fight's own
250    /// stream for fight-scoped events of a seeded fight, otherwise the driver's.
251    ///
252    /// Must be called after `pre_event` and exactly once per dispatched event —
253    /// `pre_event` is what advances the fight's draw counter.
254    pub fn event_rng(&self, driver_rng: rand::rngs::StdRng) -> rand::rngs::StdRng {
255        use rand::SeedableRng as _;
256
257        match self.current_fight_draw {
258            Some(key) => rand::rngs::StdRng::seed_from_u64(key),
259            None => driver_rng,
260        }
261    }
262
263    /// Whether an event belongs to the fight's own random stream.
264    ///
265    /// The membership rule is an invariant, not a taste call: an event belongs
266    /// here **iff only fight machinery can produce it**. Admitting an event
267    /// that a non-fight cascade can also emit desyncs the counter — the live
268    /// loop would bump on an interleaved tap that the precalculation never saw,
269    /// and every roll after it diverges. Omitting an event the fight does emit
270    /// desyncs it the other way.
271    ///
272    /// `EndFight` is deliberately absent: `FightEngine` takes the outcome from
273    /// it without dispatching it (`fight::FightEngine::try_finish`), so bumping
274    /// on it would count in the live loop only.
275    pub fn is_fight_scoped(event: &OverlordEvent) -> bool {
276        matches!(
277            event,
278            OverlordEvent::PrepareFight { .. }
279                | OverlordEvent::StartFight { .. }
280                | OverlordEvent::FightProgress {}
281                | OverlordEvent::WaveCleared { .. }
282                | OverlordEvent::SpawnEntity { .. }
283                | OverlordEvent::StartMove { .. }
284                | OverlordEvent::EndMove { .. }
285                | OverlordEvent::MoveProgress { .. }
286                | OverlordEvent::StartCastAbility { .. }
287                | OverlordEvent::StartedCastAbility { .. }
288                | OverlordEvent::CastAbility { .. }
289                | OverlordEvent::StartCastProjectile { .. }
290                | OverlordEvent::StartedCastProjectile { .. }
291                | OverlordEvent::DerivedAbilityStrike { .. }
292                | OverlordEvent::EntityIncrAttributeDelayed { .. }
293                | OverlordEvent::CastProjectile { .. }
294                | OverlordEvent::Damage { .. }
295                | OverlordEvent::GlobalFlip { .. }
296                | OverlordEvent::StoneTriggerFired { .. }
297                | OverlordEvent::Heal { .. }
298                | OverlordEvent::CounterAttack { .. }
299                | OverlordEvent::Multicast { .. }
300                | OverlordEvent::Evasion { .. }
301                | OverlordEvent::PlayerDeath {}
302                | OverlordEvent::EntityDeath { .. }
303                | OverlordEvent::EntityIncrAttribute { .. }
304                | OverlordEvent::EntityAddAbilityCooldown { .. }
305                | OverlordEvent::EntityCancelCastWithCooldown { .. }
306                | OverlordEvent::EntityStun { .. }
307                | OverlordEvent::EntityApplyEffect { .. }
308                | OverlordEvent::CastEffect { .. }
309                | OverlordEvent::CastEffectFromEvent { .. }
310                | OverlordEvent::FightCustomEvent { .. }
311                | OverlordEvent::FightVisualEvent { .. }
312        )
313    }
314
315    /// Effect `events_subscribe` scan: when a fight is active, effects applied
316    /// to entities can react to any event by casting. The returned events are
317    /// appended to the handling result's events (after the success hooks).
318    pub fn effect_subscription_events(
319        &self,
320        event: &OverlordEvent,
321        state: &OverlordState,
322    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
323        // Effects react to combat and nothing else. `events_subscribe` is
324        // GameConfig data, so without this gate a subscription to, say,
325        // `OpenItemCase` would let a chest opened mid-fight cast into the
326        // fight — in the live session only, since the precalculation never
327        // sees the player's taps.
328        if !Self::is_fight_scoped(event) {
329            return Vec::new();
330        }
331
332        let game_config = self.game_config.get();
333        let mut events: Vec<EventPluginized<OverlordEvent, OverlordState>> = Vec::new();
334        if let Some(fight) = &state.active_fight {
335            for entity in &fight.entities {
336                for effect_id in &entity.effect_ids {
337                    // Config drift: an effect can be removed from GameConfig
338                    // while still applied to an entity in an active fight.
339                    // Skip the stale effect instead of failing every event for
340                    // the session before dispatch.
341                    let Some(effect) = game_config.effect(*effect_id) else {
342                        tracing::warn!(
343                            "Skipping unknown effect with id = {effect_id} on fight entity {}",
344                            entity.id
345                        );
346                        continue;
347                    };
348                    if let Some(events_subscribe) = &effect.events_subscribe
349                        && events_subscribe.contains(&event.to_string())
350                    {
351                        events.push(EventPluginized::now(OverlordEvent::CastEffectFromEvent {
352                            entity_id: entity.id,
353                            effect_id: effect.id,
354                            caller_event: Box::new(event.clone()),
355                        }));
356                    }
357                }
358            }
359        }
360        events
361    }
362
363    /// Cross-cutting success hooks: quest progress, offer triggers, Trigger
364    /// Stone fires, law reactions and the artifact's flip/heartbeat Aspect
365    /// rules. Runs for every successfully handled event, both here and in the
366    /// monolith's merged arms.
367    ///
368    /// Stone triggers hang off this hook rather than off the combat arms on
369    /// purpose: the surface they react to
370    /// ([`crate::event::OverlordEvent::is_core_combat_event`]) is a property of
371    /// the event, not of which arm handled it, and a merged arm added later for
372    /// one of those events would otherwise silently switch the mechanic off.
373    /// `&mut self` for the fight clock the timed effects schedule their expiry
374    /// on.
375    pub fn apply_success_hooks(
376        &mut self,
377        state: &mut OverlordState,
378        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
379        event: &OverlordEvent,
380    ) {
381        self.update_quests_progress(state, events, event);
382        self.try_give_new_offers(state, events, event);
383        self.apply_stone_triggers(state, events, event);
384        // Laws stay immediately after the stones: a stone fire moves the flip
385        // gauge and switches the side synchronously, and the law hook is meant
386        // to see that. Artifact Aspects run last so they observe the settled
387        // side rather than the one that is about to change.
388        self.apply_law_reactions(state, events, event);
389        self.apply_artifact_aspects(state, events, event);
390        // Pet Facets spend last: they are Derived payoffs that hang off the
391        // same Core outcome the stones and laws just reacted to, and nothing
392        // they emit is trigger surface, so running them after settles the order
393        // without changing anyone's reading of the event.
394        self.apply_pet_facets(state, events, event);
395    }
396
397    pub fn handle_event(
398        &mut self,
399        event: &OverlordEvent,
400        state: OverlordState,
401        rand_gen: rand::rngs::StdRng,
402        current_tick: u64,
403    ) -> EventHandleResult<OverlordEvent, OverlordState> {
404        self.pre_event(event, current_tick);
405        let rand_gen = self.event_rng(rand_gen);
406        let mut events = self.effect_subscription_events(event, &state);
407
408        let mut result = match &event {
409            // Items
410            OverlordEvent::OpenItemCase { batch_size } => {
411                self.handle_open_item_case(*batch_size, rand_gen, state)
412            }
413            OverlordEvent::AutoChestOpenItemCase { batch_size } => {
414                self.handle_auto_chest_open_item_case(*batch_size, rand_gen, state)
415            }
416            OverlordEvent::PlinkoBallDropped { item_id, path } => {
417                self.handle_plinko_ball_dropped(*item_id, path, state)
418            }
419            OverlordEvent::PlayerEquipItem { item_id } => {
420                self.handle_player_equip_item(*item_id, state)
421            }
422            OverlordEvent::SellItem { item_id } => self.handle_sell_item(*item_id, state),
423            OverlordEvent::ItemSold { .. } => self.handle_noop(state),
424            OverlordEvent::ItemsExpired { .. } => self.handle_noop(state),
425            OverlordEvent::PlayerNewItems { items } => self.handle_player_new_items(items, state),
426            OverlordEvent::UpgradeItemCase {} => self.handle_noop(state),
427            OverlordEvent::ItemCaseUpgraded {} => self.handle_noop(state),
428            OverlordEvent::SpeedupUpgradeItemCase {} => self.handle_noop(state),
429            OverlordEvent::SkipUpgradeItemCase {} => self.handle_noop(state),
430            OverlordEvent::ClaimUpgradeItemCase {} => self.handle_noop(state),
431
432            OverlordEvent::EnableAutoSell {} => self.handle_enable_auto_sell(state),
433            OverlordEvent::DisableAutoSell {} => self.handle_disable_auto_sell(state),
434
435            OverlordEvent::SetGearOverrideEnabled { item_type, enabled } => {
436                self.handle_set_gear_override_enabled(*item_type, *enabled, state)
437            }
438
439            OverlordEvent::EnableCaseUpgradePopUp {} => {
440                self.handle_enable_case_upgrade_pop_up(state)
441            }
442            OverlordEvent::DisableCaseUpgradePopUp {} => {
443                self.handle_disable_case_upgrade_pop_up(state)
444            }
445
446            // Abilities
447            OverlordEvent::OpenAbilityCase { .. } => self.handle_noop(state),
448            OverlordEvent::SetAbilityGachaWishlist { .. } => self.handle_noop(state),
449            OverlordEvent::UpgradeAbilitySlot { .. } => self.handle_noop(state),
450            OverlordEvent::AbilityCaseOpened { .. } => self.handle_noop(state),
451            OverlordEvent::NewAbilities { .. } => self.handle_noop(state),
452            OverlordEvent::UpgradeAbilityCase {} => self.handle_noop(state),
453
454            // Ability stones: validation, state mutation and persistence all
455            // live in the monolith handler (it needs the DB), so the pure logic
456            // layer has nothing to do here.
457            OverlordEvent::SocketAbilityStone { .. } => self.handle_noop(state),
458            OverlordEvent::UnsocketAbilityStone { .. } => self.handle_noop(state),
459            OverlordEvent::UpgradeAbilityStone { .. } => self.handle_noop(state),
460            OverlordEvent::QuickEquipAbilityStones { .. } => self.handle_noop(state),
461            OverlordEvent::UpgradeAllAbilityStones {} => self.handle_noop(state),
462            OverlordEvent::UpgradedAbilityStones { .. } => self.handle_noop(state),
463            OverlordEvent::NewAbilityStones { .. } => self.handle_noop(state),
464            OverlordEvent::FastEquipAbilities {} => self.handle_noop(state),
465            OverlordEvent::EquipAbility {
466                slot_id,
467                ability_id,
468            } => self.handle_equip_ability(*slot_id, *ability_id, current_tick, state),
469            OverlordEvent::UnequipAbility { slot_id } => {
470                self.handle_unequip_ability(*slot_id, state)
471            }
472            OverlordEvent::EquipAbilities { equipped_abilities } => {
473                self.handle_equip_abilities(equipped_abilities.clone(), current_tick, state)
474            }
475            OverlordEvent::UpgradeAbility { .. } => self.handle_noop(state),
476            OverlordEvent::UpgradeAllAbilities {} => self.handle_noop(state),
477            OverlordEvent::UpgradedAbilities { .. } => self.handle_noop(state),
478            // Report-only, exactly like UpgradedAbilities: the levels are
479            // already applied by UpgradeAllStones, this only carries the result
480            // out to the client and the quest board.
481            OverlordEvent::UpgradedStones { .. } => self.handle_noop(state),
482
483            // Fight management
484            OverlordEvent::StartGame {} => self.handle_noop(state),
485            OverlordEvent::PrepareFight { prepare_fight_type } => {
486                self.handle_prepare_fight(prepare_fight_type.clone(), rand_gen, state)
487            }
488            OverlordEvent::StartFight { fight_id } => {
489                self.handle_start_fight(event.clone(), *fight_id, rand_gen, current_tick, state)
490            }
491            OverlordEvent::EndFight {
492                fight_id,
493                is_win,
494                pvp_state,
495            } => self.handle_end_fight(*fight_id, *is_win, pvp_state.as_deref(), rand_gen, state),
496            OverlordEvent::StageCleared {} => self.handle_noop(state),
497            OverlordEvent::RaidDungeon { .. } => self.handle_noop(state),
498
499            // Moving
500            OverlordEvent::StartMove {
501                entity_id,
502                to,
503                duration_ticks,
504            } => self.handle_start_move(*entity_id, to.clone(), *duration_ticks, state),
505            OverlordEvent::EndMove { entity_id } => self.handle_end_move(*entity_id, state),
506            OverlordEvent::MoveProgress { entity_id, to } => {
507                self.handle_move_progress(*entity_id, to.clone(), state)
508            }
509
510            // Fighting
511            OverlordEvent::SpawnEntity {
512                id,
513                entity_template_id,
514                position,
515                entity_team,
516                has_big_hp_bar,
517                entity_attributes,
518            } => self.handle_spawn_entity(
519                *id,
520                *entity_template_id,
521                position.clone(),
522                entity_team.clone(),
523                *has_big_hp_bar,
524                entity_attributes.clone(),
525                current_tick,
526                state,
527            ),
528            OverlordEvent::FightProgress {} => self.handle_fight_progress(current_tick, state),
529            OverlordEvent::StartCastAbility {
530                by_entity_id,
531                ability_id,
532                ..
533            } => self.handle_start_cast_ability(
534                event.clone(),
535                *by_entity_id,
536                *ability_id,
537                rand_gen,
538                current_tick,
539                state,
540            ),
541            OverlordEvent::StartedCastAbility { .. } => self.handle_noop(state),
542            OverlordEvent::CastAbility {
543                by_entity_id,
544                to_entity_id,
545                ability_id,
546                ..
547            } => self.handle_cast_ability(
548                event.clone(),
549                *by_entity_id,
550                *to_entity_id,
551                *ability_id,
552                rand_gen,
553                state,
554            ),
555            OverlordEvent::StartCastProjectile {
556                by_entity_id,
557                to_entity_id,
558                projectile_id,
559                level,
560                delay: _,
561                // Carried, not consumed: `pre_event` already armed the dispatch
562                // with this event's provenance.
563                origin: _,
564                source,
565            } => self.handle_start_cast_projectile(
566                event.clone(),
567                *by_entity_id,
568                *to_entity_id,
569                *projectile_id,
570                *level,
571                *source,
572                current_tick,
573                state,
574            ),
575            OverlordEvent::StartedCastProjectile { .. } => self.handle_noop(state),
576            OverlordEvent::DerivedAbilityStrike {
577                by_entity_id,
578                to_entity_id,
579                ability_id,
580                level,
581                payload_permille,
582                source_paid_mana_x100,
583                ..
584            } => self.handle_derived_ability_strike(
585                *by_entity_id,
586                *to_entity_id,
587                *ability_id,
588                *level,
589                *payload_permille,
590                *source_paid_mana_x100,
591                rand_gen,
592                state,
593            ),
594            // Only ever produced with a delay and routed onto the fight clock,
595            // which re-emits it as a plain `EntityIncrAttribute`.
596            OverlordEvent::EntityIncrAttributeDelayed {
597                entity_id,
598                attribute,
599                delta,
600                ..
601            } => self.handle_entity_incr_attribute(
602                *entity_id,
603                attribute,
604                *delta,
605                current_tick,
606                state,
607            ),
608            OverlordEvent::CastProjectile {
609                by_entity_id,
610                to_entity_id,
611                projectile_id,
612                level,
613                projectile_data,
614                origin: _,
615                source,
616            } => self.handle_cast_projectile(
617                event.clone(),
618                *by_entity_id,
619                *to_entity_id,
620                *projectile_id,
621                *level,
622                projectile_data,
623                *source,
624                rand_gen,
625                current_tick,
626                state,
627            ),
628            OverlordEvent::Damage {
629                by_entity_id,
630                entity_id,
631                damage,
632                damage_data,
633                // Provenance is carried, not consumed: no trigger system reads
634                // it yet, and `handle_damage` behaves identically either way.
635                origin: _,
636                source,
637            } => self.handle_damage(
638                *by_entity_id,
639                *entity_id,
640                *damage,
641                damage_data,
642                *source,
643                rand_gen,
644                state,
645            ),
646            // The Team Die rolls INSIDE this arm, before the law layer runs —
647            // see `logic::pet_facets` for why that ordering is the acceptance
648            // criterion rather than a preference.
649            OverlordEvent::GlobalFlip {
650                entity_id,
651                from_side,
652                to_side,
653                revision,
654                ..
655            } => {
656                let mut rng = rand_gen;
657                self.handle_global_flip(
658                    *entity_id, *from_side, *to_side, *revision, &mut rng, state,
659                )
660            }
661            OverlordEvent::Heal {
662                by_entity_id,
663                entity_id,
664                heal,
665                source,
666                ..
667            } => self.handle_heal(*by_entity_id, *entity_id, *heal, *source, state),
668            OverlordEvent::CounterAttack { .. } => self.handle_noop(state),
669            OverlordEvent::WaveCleared {} => self.handle_noop(state),
670            OverlordEvent::Multicast { .. } => self.handle_noop(state),
671            // Pure client-facing marker — see the variant's doc.
672            OverlordEvent::StoneTriggerFired { .. } => self.handle_noop(state),
673            OverlordEvent::Evasion { .. } => self.handle_noop(state),
674            OverlordEvent::PlayerDeath {} => self.handle_player_death(state),
675            OverlordEvent::EntityDeath {
676                entity_id, reward, ..
677            } => self.handle_entity_death(*entity_id, reward.clone(), rand_gen, state),
678            OverlordEvent::EntityIncrAttribute {
679                entity_id,
680                attribute,
681                delta,
682            } => self.handle_entity_incr_attribute(
683                *entity_id,
684                attribute,
685                *delta,
686                current_tick,
687                state,
688            ),
689            OverlordEvent::EntityAddAbilityCooldown {
690                entity_id,
691                ability_id,
692                delta_ticks,
693            } => self.handle_entity_add_ability_cooldown(
694                *entity_id,
695                *ability_id,
696                *delta_ticks,
697                current_tick,
698                state,
699            ),
700            OverlordEvent::EntityCancelCastWithCooldown {
701                entity_id,
702                ability_id,
703            } => self.handle_entity_cancel_cast_with_cooldown(
704                *entity_id,
705                *ability_id,
706                current_tick,
707                state,
708            ),
709            OverlordEvent::EntityStun {
710                entity_id,
711                duration_ticks,
712            } => self.handle_entity_stun(*entity_id, *duration_ticks, current_tick, state),
713            OverlordEvent::EntityApplyEffect {
714                entity_id,
715                effect_id,
716                ..
717            } => self.handle_entity_apply_effect(*entity_id, *effect_id, current_tick, state),
718            OverlordEvent::CastEffect {
719                entity_id,
720                effect_id,
721                ..
722            } => {
723                self.handle_cast_effect(*entity_id, *effect_id, None, rand_gen, current_tick, state)
724            }
725            OverlordEvent::CastEffectFromEvent {
726                entity_id,
727                effect_id,
728                caller_event,
729            } => self.handle_cast_effect(
730                *entity_id,
731                *effect_id,
732                Some(caller_event.to_owned()),
733                rand_gen,
734                current_tick,
735                state,
736            ),
737            OverlordEvent::FightCustomEvent { .. } => self.handle_noop(state),
738            OverlordEvent::FightVisualEvent { .. } => self.handle_noop(state),
739            OverlordEvent::NewCharacterLevel { level } => {
740                self.handle_new_character_level(*level, state)
741            }
742
743            // Vassal links
744            OverlordEvent::ClaimSuzerainReward {} => self.handle_noop(state),
745            OverlordEvent::ClaimVassalReward { .. } => self.handle_noop(state),
746            OverlordEvent::NewSuzerain { new_suzerain } => {
747                self.handle_new_suzerain(new_suzerain.as_deref().cloned(), state)
748            }
749            OverlordEvent::RemoveVassal { vassal_id } => {
750                self.handle_remove_vassal(*vassal_id, state)
751            }
752
753            // Quests
754            OverlordEvent::ClaimQuest { quest_id } => {
755                self.handle_claim_quest(*quest_id, rand_gen, state)
756            }
757            OverlordEvent::NewQuests { quest_ids } => {
758                self.handle_new_quests(quest_ids.clone(), state)
759            }
760            OverlordEvent::UpdateActiveLoopTaskId { quest_id } => {
761                self.handle_update_active_loop_task_id(*quest_id, state)
762            }
763
764            OverlordEvent::ClaimQuestProgressionReward { quest_group_type } => {
765                self.handle_claim_quest_progression_reward(quest_group_type.to_owned(), state)
766            }
767
768            OverlordEvent::ResetRepeatingQuests { quest_ids } => {
769                self.handle_reset_repeating_quests(quest_ids.clone(), state)
770            }
771
772            // Vassal Tasks
773            OverlordEvent::GiveTask { .. } => self.handle_noop(state),
774            OverlordEvent::NewTask { new_task } => {
775                self.handle_new_task((**new_task).clone(), state)
776            }
777            OverlordEvent::AcceptTask { task_id, is_good } => {
778                self.handle_accept_task(*task_id, *is_good, state)
779            }
780            OverlordEvent::TaskAccepted {
781                task_id,
782                started_good,
783                started_at,
784                finish_at,
785            } => self.handle_task_accepted(*task_id, *started_good, *started_at, *finish_at, state),
786            OverlordEvent::HitHands { task_id } => self.handle_hit_hands(*task_id, state),
787            OverlordEvent::HandsHitted { task_id } => self.handle_hands_hitted(*task_id, state),
788            OverlordEvent::ClaimTaskReward { task_id } => {
789                self.handle_claim_task_reward(*task_id, state)
790            }
791            OverlordEvent::TaskFinished { task_id } => self.handle_finish_task(*task_id, state),
792
793            // Resist Task
794            OverlordEvent::GiveResistTask { new_resist_task } => {
795                self.handle_give_resist_task((**new_resist_task).clone(), state)
796            }
797            OverlordEvent::AcceptResistTask {} => self.handle_noop(state),
798            OverlordEvent::ResistTaskAccepted { new_resist_task } => {
799                self.handle_resist_task_accepted((**new_resist_task).clone(), state)
800            }
801            OverlordEvent::CatchResistTask { task_id } => {
802                self.handle_catch_resist_task(*task_id, state)
803            }
804            OverlordEvent::ResistTaskCatched { task_id } => {
805                self.handle_resist_task_catched(*task_id, state)
806            }
807            OverlordEvent::ResistTaskFinished { task_id } => {
808                self.handle_resist_task_finished(*task_id, state)
809            }
810            OverlordEvent::ClaimResistTaskReward { task_id } => {
811                self.handle_claim_resist_task_reward(*task_id, state)
812            }
813
814            // Gifts
815            OverlordEvent::SendGift {
816                receiver_id,
817                config_gift_id,
818            } => self.handle_send_gift(*receiver_id, *config_gift_id, state),
819            OverlordEvent::NewGift { new_gift } => {
820                self.handle_new_gift((**new_gift).clone(), state)
821            }
822            OverlordEvent::AcceptGift { gift_id } => self.handle_accept_gift(*gift_id, state),
823
824            // PVP
825            OverlordEvent::StartVassalPVPSync { .. } => self.handle_noop(state),
826            OverlordEvent::StartArenaPVPSync { .. } => self.handle_noop(state),
827            OverlordEvent::StartArenaRematchSync { .. } => self.handle_noop(state),
828            OverlordEvent::RefreshArenaMatchmaking {} => self.handle_noop(state),
829            OverlordEvent::BuyArenaTicket {} => self.handle_noop(state),
830            OverlordEvent::SetArenaWorldSide { side } => {
831                self.handle_set_arena_world_side(*side, state)
832            }
833
834            // Referral Rewards
835            OverlordEvent::ClaimReferralLvlUpReward { level } => {
836                self.handle_claim_referral_lvlup_reward(*level, state)
837            }
838            OverlordEvent::ClaimReferralDailyReward {} => {
839                self.handle_claim_referral_daily_reward(state)
840            }
841            OverlordEvent::PatronQuestCompleted { quest_id } => {
842                self.handle_patron_quest_completed(*quest_id, state)
843            }
844            OverlordEvent::HiddenQuestCompleted { quest_id } => {
845                self.handle_hidden_quest_completed(*quest_id, rand_gen, state)
846            }
847            OverlordEvent::QuestCompleted { .. } => self.handle_noop(state),
848            OverlordEvent::ReferralDailyRewardStatusUpdate {
849                referral_daily_reward_status,
850            } => self
851                .handle_referral_daily_reward_status_update(*referral_daily_reward_status, state),
852
853            // AutoChest
854            OverlordEvent::EnableAutoChest {} => self.handle_enable_auto_chest(state),
855            OverlordEvent::DisableAutoChest {} => self.handle_disable_auto_chest(state),
856
857            OverlordEvent::EnableAutoChestFilter { filter_id } => {
858                self.handle_enable_auto_chest_filter(*filter_id, state)
859            }
860            OverlordEvent::DisableAutoChestFilter {} => {
861                self.handle_disable_auto_chest_filter(state)
862            }
863
864            OverlordEvent::EnableAutoChestPowerCompare {} => {
865                self.handle_enable_auto_chest_power_compare(state)
866            }
867            OverlordEvent::DisableAutoChestPowerCompare {} => {
868                self.handle_disable_auto_chest_power_compare(state)
869            }
870
871            OverlordEvent::UpdateAutoChestBatchSize { batch_size } => {
872                self.handle_update_auto_chest_batch_size(*batch_size, state)
873            }
874
875            OverlordEvent::NewAutoChestFilter { filter } => {
876                self.handle_new_auto_chest_filter((**filter).clone(), state)
877            }
878
879            OverlordEvent::UpdateAutoChestFilter { updated_filter } => {
880                self.handle_update_auto_chest_filter((**updated_filter).clone(), state)
881            }
882
883            OverlordEvent::RemoveAutoChestFilter { filter_id } => {
884                self.handle_remove_auto_chest_filter(*filter_id, state)
885            }
886
887            // Technical
888            OverlordEvent::SetCustomValue { key, value } => {
889                self.handle_set_custom_value(key.clone(), *value, state)
890            }
891            OverlordEvent::SetConnectionStore { key, value } => {
892                self.handle_set_connection_store(key.clone(), *value, state)
893            }
894
895            // Ability Presets
896            OverlordEvent::CreateAbilityPreset { .. } => self.handle_noop(state),
897            OverlordEvent::EditAbilityPreset { .. } => self.handle_noop(state),
898
899            // AfkReward
900            OverlordEvent::ClaimAfkReward {} => self.handle_noop(state),
901            OverlordEvent::AfkRewardClaimed {} => self.handle_noop(state),
902            OverlordEvent::AfkRewardsGatingUnlocked {} => {
903                self.handle_afk_rewards_gating_unlocked(state)
904            }
905            OverlordEvent::ClaimAfkInstantRewardGems {} => self.handle_noop(state),
906
907            // Bundles
908            OverlordEvent::ClaimBundleStepGeneric { .. } => self.handle_noop(state),
909            OverlordEvent::AddBundleGroup { bundle_ids, source } => {
910                self.handle_add_bundle_group(bundle_ids, *source, state)
911            }
912
913            // Classes
914            OverlordEvent::LevelUpClass { .. } => self.handle_noop(state),
915            OverlordEvent::Respec { .. } => self.handle_noop(state),
916
917            // User Account
918            OverlordEvent::LinkGuestAccount { .. } => self.handle_noop(state),
919            OverlordEvent::SetUsername { .. } => self.handle_noop(state),
920            OverlordEvent::SetCharacterBlocked { .. } => self.handle_noop(state),
921
922            // Cheat
923            OverlordEvent::SetMaxHp {
924                entity_id,
925                new_max_hp,
926                new_hp,
927            } => self.handle_set_max_hp(*entity_id, *new_max_hp, *new_hp, state),
928
929            OverlordEvent::RunCheat { cheat } => {
930                self.handle_run_cheat(cheat, current_tick, rand_gen, state)
931            }
932
933            OverlordEvent::Error { .. } => self.handle_noop(state),
934            OverlordEvent::CustomEvent { .. } => self.handle_noop(state),
935
936            // Currencies — the event itself performs the state mutation
937            OverlordEvent::CurrencyIncrease { currencies, .. } => {
938                self.handle_currency_increase(currencies, state)
939            }
940            OverlordEvent::CurrencyDecrease { currencies, .. } => {
941                self.handle_currency_decrease(currencies, state)
942            }
943            // Skins
944            OverlordEvent::BuySkins { .. } => self.handle_noop(state),
945            OverlordEvent::EquipAndUnequipSkins { .. } => self.handle_noop(state),
946
947            // Mails
948            OverlordEvent::ClaimMail { .. } => self.handle_noop(state),
949            OverlordEvent::ClaimAllMails {} => self.handle_noop(state),
950            OverlordEvent::ClaimAllQuests { .. } => self.handle_noop(state),
951            OverlordEvent::MakeRead { .. } => self.handle_noop(state),
952            OverlordEvent::MakeAllRead {} => self.handle_noop(state),
953            OverlordEvent::DeleteMail { .. } => self.handle_noop(state),
954            OverlordEvent::DeleteAllMails {} => self.handle_noop(state),
955            OverlordEvent::NewMail { new_mail } => {
956                self.handle_new_mail((**new_mail).clone(), state)
957            }
958            // Offers
959            OverlordEvent::NewOffer { .. } => self.handle_noop(state),
960            OverlordEvent::BuyOffer { .. } => self.handle_noop(state),
961            OverlordEvent::ResetOffers { new_offers } => {
962                self.handle_reset_offers(new_offers, state)
963            }
964            OverlordEvent::OfferPurchaseCompleted { .. } => self.handle_noop(state),
965            OverlordEvent::OfferPurchaseFailed { .. } => self.handle_noop(state),
966            OverlordEvent::PurchasesBanned {} => self.handle_purchases_banned(state),
967
968            // Pets
969            OverlordEvent::EquipPet { slot_id, pet_id } => {
970                self.handle_equip_pet(*slot_id, *pet_id, current_tick, state)
971            }
972            OverlordEvent::UnequipPet { slot_id } => {
973                self.handle_unequip_pet(*slot_id, current_tick, state)
974            }
975            OverlordEvent::FastEquipPets {} => self.handle_noop(state),
976            OverlordEvent::EquipPets { equipped_pets } => {
977                self.handle_equip_pets(equipped_pets.clone(), current_tick, state)
978            }
979            OverlordEvent::UpgradePet { .. } => self.handle_noop(state),
980            OverlordEvent::UpgradeAllPets {} => self.handle_noop(state),
981            OverlordEvent::UpgradedPets { .. } => self.handle_noop(state),
982            OverlordEvent::UpgradePetSlot { .. } => self.handle_noop(state),
983            OverlordEvent::SetPetFacetLaw {
984                role,
985                law_template_id,
986            } => self.handle_set_pet_facet_law(*role, *law_template_id, state),
987
988            // Pet Gacha
989            OverlordEvent::OpenPetCase { .. } => self.handle_noop(state),
990            OverlordEvent::SetPetGachaWishlist { .. } => self.handle_noop(state),
991            OverlordEvent::PetCaseOpened { .. } => self.handle_noop(state),
992            OverlordEvent::NewPets { .. } => self.handle_noop(state),
993            OverlordEvent::UpgradePetCase {} => self.handle_noop(state),
994
995            // Tutorial
996            OverlordEvent::TutorialShown { .. } => self.handle_noop(state),
997            OverlordEvent::TutorialStepCompleted { step_number } => {
998                self.handle_tutorial_step_completed(*step_number, state)
999            }
1000            OverlordEvent::ClientLifecycle { .. } => self.handle_noop(state),
1001            OverlordEvent::SetFightContentLoading { fight_id, loading } => {
1002                self.handle_set_fight_content_loading(*fight_id, *loading, state)
1003            }
1004
1005            // Party
1006            OverlordEvent::AddCharacterToParty { .. } => self.handle_noop(state),
1007            OverlordEvent::RemoveCharacterFromParty {} => self.handle_noop(state),
1008            OverlordEvent::RefreshPartyPlayers {} => self.handle_noop(state),
1009            OverlordEvent::RefreshPartyMemberState {} => self.handle_noop(state),
1010
1011            // Talent Tree
1012            OverlordEvent::StartTalentResearch { .. } => self.handle_noop(state),
1013            OverlordEvent::TalentResearchStarted { .. } => self.handle_noop(state),
1014            OverlordEvent::SpeedupTalentResearch {} => self.handle_noop(state),
1015            OverlordEvent::SkipTalentResearch {} => self.handle_noop(state),
1016            OverlordEvent::ClaimTalentResearch {} => self.handle_claim_talent_research(state),
1017
1018            // Statue
1019            OverlordEvent::StatueRoll { .. } => self.handle_noop(state),
1020            OverlordEvent::StatueActivateSet { .. } => self.handle_noop(state),
1021            OverlordEvent::StatueAddSet {} => self.handle_noop(state),
1022            OverlordEvent::StatueRenameSet { .. } => self.handle_noop(state),
1023            OverlordEvent::StatueLockSlot { .. } => self.handle_noop(state),
1024            OverlordEvent::StatueRollNewSlot { .. } => self.handle_noop(state),
1025            // Cores / laws / bridges — all validated and persisted in the
1026            // monolith ws handler; the shared logic layer only carries state.
1027            OverlordEvent::UpgradeCore { .. } => self.handle_noop(state),
1028            OverlordEvent::ResetCores {} => self.handle_noop(state),
1029            OverlordEvent::UpgradeLaw { .. } => self.handle_noop(state),
1030            OverlordEvent::SlotLaw { .. } => self.handle_noop(state),
1031            OverlordEvent::UnslotLaw { .. } => self.handle_noop(state),
1032            OverlordEvent::CreateLawBridge { .. } => self.handle_noop(state),
1033            OverlordEvent::RemoveLawBridge { .. } => self.handle_noop(state),
1034            OverlordEvent::NewLawCopies { .. } => self.handle_noop(state),
1035            // Progress Pass
1036            OverlordEvent::ClaimProgressPassFreeReward { .. } => self.handle_noop(state),
1037            OverlordEvent::ClaimProgressPassPaidReward { .. } => self.handle_noop(state),
1038            OverlordEvent::ClaimAllProgressPassRewards { .. } => self.handle_noop(state),
1039            OverlordEvent::ClaimRatingRewards { .. } => self.handle_noop(state),
1040            OverlordEvent::SetRatingRewardAvailability { availability } => {
1041                self.handle_set_rating_reward_availability(availability.clone(), state)
1042            }
1043            OverlordEvent::UserRating { .. } => self.handle_noop(state),
1044            OverlordEvent::ShowRateUs {} => self.handle_noop(state),
1045            OverlordEvent::RateUsShown {} => self.handle_rate_us_shown(state),
1046            OverlordEvent::RateUs { .. } => self.handle_noop(state),
1047            OverlordEvent::WatchAd { .. } => self.handle_noop(state),
1048            OverlordEvent::ShowBird { .. } => self.handle_show_bird(state),
1049            OverlordEvent::BirdShown {} => self.handle_bird_shown(state),
1050            OverlordEvent::ResetAdUsage { placements } => {
1051                self.handle_reset_ad_usage(placements, state)
1052            }
1053            OverlordEvent::ResetInstantRewardGemsPressCount {} => {
1054                self.handle_reset_instant_reward_gems_press_count(state)
1055            }
1056            // Trigger/Effect Stones
1057            OverlordEvent::InsertStone {
1058                template_id,
1059                item_type,
1060                socket,
1061            } => self.handle_insert_stone(*template_id, *item_type, *socket, state),
1062            OverlordEvent::RemoveStone { template_id } => {
1063                self.handle_remove_stone(*template_id, state)
1064            }
1065            OverlordEvent::UpgradeStone { template_id } => {
1066                self.handle_upgrade_stone(*template_id, state)
1067            }
1068            // Unlike `FastEquipAbilities` / `UpgradeAllAbilities`, which are
1069            // `handle_noop` here because their data lives in monolith-only
1070            // tables, a stone inventory IS part of `character_state` — so both
1071            // bulk stone actions are ordinary pure transitions and run
1072            // identically in both dispatch paths.
1073            OverlordEvent::QuickEquipStones { item_type } => {
1074                self.handle_quick_equip_stones(*item_type, state)
1075            }
1076            OverlordEvent::UpgradeAllTriggerStones {} => {
1077                self.handle_upgrade_all_stones(StoneKind::Trigger, state)
1078            }
1079            OverlordEvent::UpgradeAllEffectStones {} => {
1080                self.handle_upgrade_all_stones(StoneKind::Effect, state)
1081            }
1082            OverlordEvent::PlayerNewStones {
1083                trigger_stones,
1084                effect_stones,
1085            } => self.handle_player_new_stones(trigger_stones, effect_stones, state),
1086
1087            // Artifacts
1088            OverlordEvent::EquipArtifact { template_id } => {
1089                self.handle_equip_artifact(*template_id, state)
1090            }
1091            OverlordEvent::InsertArtifactStone {
1092                template_id,
1093                socket,
1094            } => self.handle_insert_artifact_stone(*template_id, *socket, state),
1095            OverlordEvent::RemoveArtifactStone { template_id } => {
1096                self.handle_remove_artifact_stone(*template_id, state)
1097            }
1098            OverlordEvent::UpgradeArtifactStone { template_id } => {
1099                self.handle_upgrade_artifact_stone(*template_id, state)
1100            }
1101            OverlordEvent::UpgradeArtifact { template_id } => {
1102                self.handle_upgrade_artifact(*template_id, state)
1103            }
1104            OverlordEvent::SetArtifactStoneLawTarget {
1105                template_id,
1106                law_template_id,
1107            } => self.handle_set_artifact_stone_law_target(*template_id, *law_template_id, state),
1108            OverlordEvent::PlayerNewArtifacts {
1109                artifacts,
1110                artifact_stones,
1111            } => self.handle_player_new_artifacts(artifacts, artifact_stones, state),
1112            OverlordEvent::FireArtifactEffect {
1113                owner_id,
1114                effect_template_id,
1115                effect_level,
1116                share_permyriad,
1117                artifact_stone_id,
1118            } => self.handle_fire_artifact_effect(
1119                *owner_id,
1120                *effect_template_id,
1121                *effect_level,
1122                *share_permyriad,
1123                *artifact_stone_id,
1124                state,
1125            ),
1126        };
1127
1128        if result.success() {
1129            let (state, events) = result.state_and_events_mut();
1130            self.apply_success_hooks(state, events, event);
1131        }
1132
1133        result.events_mut().append(&mut events);
1134        self.post_event(&mut result);
1135        result
1136    }
1137
1138    /// Where the fight clock currently stands. The loop that drives a fight
1139    /// owns the clock; this is the read a caller needs to ask
1140    /// [`Self::collect_due_scheduled`] for a point in the future.
1141    pub fn fight_clock_now(&self) -> u64 {
1142        self.fight_clock.now()
1143    }
1144
1145    pub fn collect_due_scheduled(&mut self, current_tick: u64) -> Vec<OverlordEvent> {
1146        self.fight_clock.collect_due(current_tick)
1147    }
1148
1149    pub fn compute_fields(
1150        &self,
1151        state: &mut OverlordState,
1152        prev_state: &OverlordState,
1153    ) -> Vec<OverlordEvent> {
1154        let _span = tracing::info_span!("compute_fields").entered();
1155        let start = std::time::Instant::now();
1156        let game_config = self.game_config.get();
1157        let mut events = Vec::new();
1158        self.log_currency_change_metrics(state, prev_state);
1159
1160        if state.character_state.character.character_experience
1161            != prev_state.character_state.character.character_experience
1162        {
1163            events.append(&mut self.compute_character_level(state));
1164        }
1165
1166        if state.character_state != prev_state.character_state {
1167            match attributes::calculate_player_entity_stats_with_zeroes(
1168                &EntityState::Character(&state.character_state),
1169                &game_config,
1170            ) {
1171                Ok(attributes) => {
1172                    state.character_state.player_attributes = attributes.attributes.clone();
1173                    state.character_state.player_attributes.remove_zeroes();
1174
1175                    // A PvP fight was decided by a precalculation on a frozen
1176                    // snapshot and its outcome is already persisted. Anything
1177                    // that happens to the character while the player watches it
1178                    // — an equip, a level-up, an auto-chest — must not reach the
1179                    // entity in the ring, or the fight on screen stops being the
1180                    // fight that was booked.
1181                    if state.pvp_state.is_none()
1182                        && let Some(fight) = &mut state.active_fight
1183                        && let Some(entity) = fight
1184                            .entities
1185                            .iter_mut()
1186                            .find(|ent| ent.id == fight.player_id)
1187                    {
1188                        // Laws (OVT-2517) are side-dependent, so they live on
1189                        // the fight entity and NOT in the character-derived
1190                        // aggregation below. The overwrite is keyed by attribute
1191                        // code and replaces only the codes the character itself
1192                        // carries a non-zero value for — so it wipes some law
1193                        // deltas and leaves others untouched. Strip the whole
1194                        // law contribution first and fold it back after, which
1195                        // is exact either way; a blind re-add would double-count
1196                        // every law that moves a stat no other source touches.
1197                        //
1198                        // The snapshot comes off the ENTITY's own cores, so what
1199                        // is stripped is exactly what was folded in, whatever
1200                        // has happened to `character_state` since the fight
1201                        // started.
1202                        let law_snapshot = entity
1203                            .flip_state
1204                            .map(|flip| flip.active_side)
1205                            .map(|active_side| {
1206                                crate::entities::law_attribute_snapshot(
1207                                    entity,
1208                                    active_side,
1209                                    &game_config,
1210                                )
1211                            })
1212                            .unwrap_or_default();
1213
1214                        // The strip below momentarily takes the entity down to
1215                        // its LAWLESS max HP, and the law-delta helper clamps
1216                        // current HP to max (correct on a flip, where losing the
1217                        // law really should cost the margin). Here the law is
1218                        // not going away — it is being taken off and put back
1219                        // within one recompute — so the clamp must not fire on
1220                        // that intermediate state, or every mob kill would shave
1221                        // an HP law's whole margin off a full-HP player.
1222                        let hp_before = entity.hp;
1223
1224                        if !law_snapshot.is_empty() {
1225                            crate::entities::apply_law_attribute_change(
1226                                entity,
1227                                &law_snapshot,
1228                                &Default::default(),
1229                                &game_config,
1230                            );
1231                        }
1232
1233                        entity.max_hp = attributes.max_hp;
1234                        entity
1235                            .attributes
1236                            .0
1237                            .extend(attributes.attributes.0.iter().map(|(k, v)| (k.clone(), *v)));
1238                        entity.attributes.remove_zeroes();
1239
1240                        if !law_snapshot.is_empty() {
1241                            crate::entities::apply_law_attribute_change(
1242                                entity,
1243                                &Default::default(),
1244                                &law_snapshot,
1245                                &game_config,
1246                            );
1247                            // Round-trip is HP-neutral: same law contribution in
1248                            // and out, so effective HP must be exactly what it
1249                            // was, bounded by the final max.
1250                            entity.hp = hp_before.min(entity.max_hp);
1251                        }
1252                    }
1253                }
1254                Err(err) => {
1255                    tracing::error!("Failed to fill player entity attributes: {:?}", err);
1256                }
1257            }
1258
1259            match crate::behaviors::power::character_power(
1260                &crate::behaviors::power::CharacterPowerCtx {
1261                    character: &state.character_state,
1262                    config: &game_config,
1263                    lookups: self.behaviors.lookups(),
1264                },
1265            ) {
1266                Ok(power) => state.character_state.character.power = power,
1267                Err(err) => tracing::error!("Couldn't calculate player power: {err:?}"),
1268            }
1269        }
1270
1271        self.compute_fields_duration
1272            .record(start.elapsed().as_secs_f64(), &[]);
1273
1274        events
1275    }
1276}
1277
1278impl OverlordLogic {
1279    pub fn new(
1280        game_config: configs::SharedGameConfig,
1281        behaviors: Arc<BehaviorRegistry>,
1282        frontend: bool,
1283    ) -> Self {
1284        let meter = opentelemetry::global::meter("compute_fields");
1285        Self {
1286            game_config,
1287            behaviors,
1288            frontend,
1289            start_fight_tick: 0,
1290            fight_breakdown_acc: None,
1291            fight_clock: crate::fight::FightClock::default(),
1292            pending_fight_seed: None,
1293            dispatch_origin: CombatEventOrigin::Core,
1294            fight_rng: None,
1295            current_fight_draw: None,
1296            ended_fight_id: None,
1297            last_party_refresh_at: None,
1298            last_currency_source: None,
1299            compute_fields_duration: meter
1300                .f64_histogram("compute_fields_duration_seconds")
1301                .with_boundaries(vec![
1302                    0.0001, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1,
1303                ])
1304                .build(),
1305        }
1306    }
1307
1308    /// Sets the source label used by `log_currency_change_metrics` for the next
1309    /// `compute_fields` diff. Use when state mutations happen outside the normal
1310    /// `CurrencyIncrease` / `CurrencyDecrease` event flow (e.g., starter bundle
1311    /// seeding on character creation).
1312    pub fn set_last_currency_source(&mut self, source: Option<CurrencySource>) {
1313        self.last_currency_source = source.map(|s| format!("{s:?}"));
1314    }
1315
1316    /// Swaps the config this logic reads from. Locale-translated configs only
1317    /// differ in localized strings, so swapping mid-session is safe — used by
1318    /// the backend to switch a connection to the user's localized GameConfig
1319    /// once their language is known after auth.
1320    pub fn set_game_config(&mut self, game_config: configs::SharedGameConfig) {
1321        self.game_config = game_config;
1322    }
1323
1324    pub fn handle_set_custom_value(
1325        &self,
1326        key: String,
1327        value: i64,
1328        mut state: OverlordState,
1329    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1330        state
1331            .character_state
1332            .character
1333            .custom_values
1334            .insert(&key, value);
1335        EventHandleResult::ok(state)
1336    }
1337
1338    fn handle_set_connection_store(
1339        &self,
1340        key: String,
1341        value: i64,
1342        mut state: OverlordState,
1343    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1344        state.connection_store.insert(key, value);
1345        EventHandleResult::ok(state)
1346    }
1347
1348    pub fn handle_new_character_level(
1349        &self,
1350        level: i64,
1351        mut state: OverlordState,
1352    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1353        state.character_state.character.character_level = level;
1354
1355        EventHandleResult::ok(state)
1356    }
1357
1358    fn handle_purchases_banned(
1359        &self,
1360        mut state: OverlordState,
1361    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1362        state.character_state.character.purchases_banned = true;
1363        EventHandleResult::ok(state)
1364    }
1365
1366    fn handle_new_mail(
1367        &self,
1368        new_mail: Mail,
1369        mut state: OverlordState,
1370    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1371        if !state.incoming_mails.contains(&new_mail) {
1372            state.incoming_mails.push(new_mail);
1373        }
1374
1375        EventHandleResult::ok(state)
1376    }
1377
1378    pub fn handle_noop(
1379        &self,
1380        state: OverlordState,
1381    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1382        EventHandleResult::ok(state)
1383    }
1384
1385    pub fn handle_set_fight_content_loading(
1386        &self,
1387        fight_id: uuid::Uuid,
1388        loading: bool,
1389        mut state: OverlordState,
1390    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1391        let Some(fight) = state.active_fight.as_mut() else {
1392            tracing::warn!(
1393                %fight_id,
1394                loading,
1395                "Ignoring fight content loading state without an active fight"
1396            );
1397            return if loading {
1398                EventHandleResult::fail(state)
1399            } else {
1400                EventHandleResult::ok(state)
1401            };
1402        };
1403
1404        if fight.id != fight_id {
1405            tracing::warn!(
1406                %fight_id,
1407                active_fight_id = %fight.id,
1408                loading,
1409                "Ignoring stale fight content loading state"
1410            );
1411            return if loading {
1412                EventHandleResult::fail(state)
1413            } else {
1414                EventHandleResult::ok(state)
1415            };
1416        }
1417
1418        fight.paused = loading;
1419        tracing::info!(
1420            %fight_id,
1421            loading,
1422            "Updated fight content loading state"
1423        );
1424
1425        EventHandleResult::ok(state)
1426    }
1427
1428    pub fn handle_tutorial_step_completed(
1429        &self,
1430        step_number: i16,
1431        mut state: OverlordState,
1432    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1433        if !state
1434            .character_state
1435            .character
1436            .completed_tutorials
1437            .contains(&step_number)
1438        {
1439            state
1440                .character_state
1441                .character
1442                .completed_tutorials
1443                .push(step_number);
1444        } else {
1445            tracing::error!("Provided step {step_number} is already in state");
1446        }
1447        EventHandleResult::ok(state)
1448    }
1449}
1450
1451impl OverlordLogic {
1452    fn log_currency_change_metrics(&self, state: &OverlordState, prev_state: &OverlordState) {
1453        let character_id = state.character_state.character.id;
1454        // Chapter the player is in as this currency change lands, so a per-run
1455        // report can attribute faucet/sink per chapter (the glut detector).
1456        let chapter_level = state.character_state.character.current_chapter_level;
1457        let current_currencies = &state.character_state.currencies;
1458        let prev_currencies = &prev_state.character_state.currencies;
1459        let source = self.last_currency_source.as_deref().unwrap_or("unknown");
1460
1461        // These currency events are emitted inline (not through the monolith's
1462        // deferred-metrics span), so they need their own run-id tagging for the
1463        // per-run balance report to isolate them. Enter a parent span carrying
1464        // the sim run id ONLY when set — in production it is empty, so no span
1465        // is entered and these events are byte-identical to before.
1466        let run_id = analytics::run_id::run_id();
1467        let _run_span = (!run_id.is_empty())
1468            .then(|| tracing::info_span!(target: METRICS_TARGET, "sim_run", run_id = %run_id));
1469        let _run_guard = _run_span.as_ref().map(|s| s.enter());
1470
1471        for current_currency in current_currencies {
1472            let amount_before = prev_currencies
1473                .iter()
1474                .find(|unit| unit.currency_id == current_currency.currency_id)
1475                .map_or(0, |unit| unit.amount);
1476            let delta = current_currency.amount - amount_before;
1477            let amount_after = current_currency.amount;
1478            if delta > 0 {
1479                tracing::info!(
1480                    target: METRICS_TARGET,
1481                    character_id = %character_id,
1482                    event_type = "increase_currency",
1483                    currency_id = %current_currency.currency_id,
1484                    amount = delta,
1485                    amount_before,
1486                    amount_after,
1487                    chapter_level,
1488                    source = %source,
1489                    "Add currency",
1490                );
1491            } else if delta < 0 {
1492                tracing::info!(
1493                    target: METRICS_TARGET,
1494                    character_id = %character_id,
1495                    event_type = "decrease_currency",
1496                    currency_id = %current_currency.currency_id,
1497                    amount = -delta,
1498                    amount_before,
1499                    amount_after,
1500                    chapter_level,
1501                    source = %source,
1502                    "Decrease currency",
1503                );
1504            }
1505        }
1506
1507        for prev_currency in prev_currencies {
1508            if current_currencies
1509                .iter()
1510                .any(|unit| unit.currency_id == prev_currency.currency_id)
1511            {
1512                continue;
1513            }
1514
1515            // Currency disappeared from state: emit a decrease whose
1516            // post-balance is 0 (entry removed). Pre-balance is the value the
1517            // prev state still carried.
1518            let delta = -prev_currency.amount;
1519            if delta > 0 {
1520                tracing::info!(
1521                    target: METRICS_TARGET,
1522                    character_id = %character_id,
1523                    event_type = "increase_currency",
1524                    currency_id = %prev_currency.currency_id,
1525                    amount = delta,
1526                    amount_before = prev_currency.amount,
1527                    amount_after = prev_currency.amount + delta,
1528                    chapter_level,
1529                    source = %source,
1530                    "Add currency",
1531                );
1532            } else if delta < 0 {
1533                tracing::info!(
1534                    target: METRICS_TARGET,
1535                    character_id = %character_id,
1536                    event_type = "decrease_currency",
1537                    currency_id = %prev_currency.currency_id,
1538                    amount = -delta,
1539                    amount_before = prev_currency.amount,
1540                    amount_after = 0_i64,
1541                    chapter_level,
1542                    source = %source,
1543                    "Decrease currency",
1544                );
1545            }
1546        }
1547    }
1548
1549    pub fn compute_character_level(&self, state: &mut OverlordState) -> Vec<OverlordEvent> {
1550        let game_config = self.game_config.get();
1551        let stored_level = state.character_state.character.character_level;
1552        let experience = state.character_state.character.character_experience;
1553
1554        // Highest level the current experience unlocks — unbounded via the level
1555        // formula above the authored table. `.max(stored_level)` keeps leveling
1556        // monotonic (never drops the persisted level, e.g. after a config
1557        // rollback that lowered the authored max).
1558        let new_level = game_config
1559            .level_for_experience(experience)
1560            .max(stored_level);
1561
1562        if new_level != stored_level {
1563            vec![OverlordEvent::NewCharacterLevel { level: new_level }]
1564        } else {
1565            Vec::new()
1566        }
1567    }
1568
1569    fn try_give_new_offers(
1570        &self,
1571        state: &mut OverlordState,
1572        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
1573        trigger_event: &OverlordEvent,
1574    ) {
1575        for offer in &self.game_config.get().offers_templates {
1576            if !offer.enabled {
1577                continue;
1578            }
1579
1580            if offer.limit_of_buys.is_some_and(|limit| {
1581                state
1582                    .offers_info
1583                    .offer_buy_counts
1584                    .get(&offer.id)
1585                    .copied()
1586                    .unwrap_or(0)
1587                    >= limit as u32
1588            }) {
1589                continue;
1590            }
1591
1592            if state
1593                .offers_info
1594                .active_offers
1595                .iter()
1596                .any(|x| x.template_id == offer.id)
1597            {
1598                continue;
1599            }
1600
1601            if !offer.events_subscribe.contains(&trigger_event.to_string()) {
1602                continue;
1603            }
1604
1605            let should_give =
1606                match self.should_give_new_offer(&state.character_state, offer, trigger_event) {
1607                    Ok(progress) => progress,
1608                    Err(e) => {
1609                        tracing::error!(
1610                            "Failed determining for offer id: {}\n Error: {e:?}",
1611                            offer.id
1612                        );
1613                        continue;
1614                    }
1615                };
1616
1617            if should_give {
1618                events.push(EventPluginized::now(OverlordEvent::NewOffer {
1619                    offer_template_id: offer.id,
1620                }));
1621            }
1622        }
1623    }
1624
1625    fn should_give_new_offer(
1626        &self,
1627        character_state: &CharacterState,
1628        offer: &OfferTemplate,
1629        trigger_event: &OverlordEvent,
1630    ) -> anyhow::Result<bool> {
1631        // By the time we get here the candidate offer is already `enabled` and
1632        // the firing event is in its `events_subscribe` list (see
1633        // `try_give_new_offers`), so a subscribing offer is auto-given when its
1634        // event fires. No deployed offer subscribes to any event, so this path
1635        // only drives the test fixture's triggered offers.
1636        crate::behaviors::quests::triggers::always_trigger(
1637            &crate::behaviors::quests::triggers::TriggerCtx {
1638                trigger_event,
1639                character: character_state,
1640                offer,
1641                config: &self.game_config.get(),
1642            },
1643        )
1644    }
1645
1646    fn update_quests_progress(
1647        &self,
1648        state: &mut OverlordState,
1649        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
1650        trigger_event: &OverlordEvent,
1651    ) {
1652        let game_config = self.game_config.get();
1653        let mut progress_pass_tier_completed = false;
1654        let all_active_quests = state.quest_groups.get_not_claimed_quests_mut();
1655        let active_fight = &state.active_fight;
1656
1657        for active_quest in all_active_quests {
1658            let Some(quest_template) = game_config.quest(active_quest.id) else {
1659                continue;
1660            };
1661
1662            if state.patron.is_none()
1663                && (quest_template.quest_group_type == QuestGroupType::PatronLifetime
1664                    || quest_template.quest_group_type == QuestGroupType::PatronDaily)
1665            {
1666                continue;
1667            }
1668
1669            if !quest_template
1670                .events_subscribe
1671                .contains(&trigger_event.to_string())
1672            {
1673                continue;
1674            }
1675
1676            if quest_template.quest_group_type == QuestGroupType::LoopTask
1677                && !quest_template.progress_if_inactive
1678                && let Some(active_loop_task_id) =
1679                    state.character_state.character.active_loop_task_id
1680                && active_loop_task_id != quest_template.id
1681            {
1682                continue;
1683            }
1684
1685            let was_completed = active_quest.is_completed(quest_template.progress_target);
1686
1687            if !active_quest.is_completed(quest_template.progress_target) {
1688                let progress = match self.get_quest_progress(
1689                    &state.character_state,
1690                    active_fight,
1691                    active_quest,
1692                    quest_template.progress_behavior.as_deref(),
1693                    trigger_event,
1694                    &game_config,
1695                ) {
1696                    Ok(progress) => progress,
1697                    Err(e) => {
1698                        tracing::error!(
1699                            "Failed updating quest progress for quest id: {}\n Error: {e:?}",
1700                            active_quest.id
1701                        );
1702                        continue;
1703                    }
1704                };
1705                active_quest.current = progress;
1706            }
1707
1708            if !was_completed {
1709                if (quest_template.quest_group_type == QuestGroupType::PatronLifetime
1710                    || quest_template.quest_group_type == QuestGroupType::PatronDaily)
1711                    && active_quest.is_completed(quest_template.progress_target)
1712                {
1713                    events.push(EventPluginized::now(OverlordEvent::PatronQuestCompleted {
1714                        quest_id: active_quest.id,
1715                    }));
1716                }
1717
1718                if (quest_template.quest_group_type != QuestGroupType::Hidden)
1719                    && active_quest.is_completed(quest_template.progress_target)
1720                {
1721                    events.push(EventPluginized::now(OverlordEvent::QuestCompleted {
1722                        quest_id: active_quest.id,
1723                    }));
1724                }
1725
1726                if quest_template.quest_group_type == QuestGroupType::ProgressPass
1727                    && active_quest.is_completed(quest_template.progress_target)
1728                {
1729                    progress_pass_tier_completed = true;
1730                }
1731            }
1732
1733            if (quest_template.quest_group_type == QuestGroupType::Hidden)
1734                && active_quest.is_completed(quest_template.progress_target)
1735            {
1736                events.push(EventPluginized::now(OverlordEvent::HiddenQuestCompleted {
1737                    quest_id: active_quest.id,
1738                }));
1739            }
1740        }
1741
1742        if progress_pass_tier_completed {
1743            for tier_state in &mut state.progress_pass.tiers {
1744                let Some(tier_template) = game_config
1745                    .progress_pass
1746                    .tiers
1747                    .iter()
1748                    .find(|t| t.tier == tier_state.tier)
1749                else {
1750                    continue;
1751                };
1752                tier_state.is_unlocked = essences::progress_pass::is_progress_pass_tier_unlocked(
1753                    tier_template,
1754                    &state.quest_groups.progress_pass,
1755                    &game_config.quests,
1756                );
1757            }
1758        }
1759    }
1760
1761    pub fn get_quest_progress(
1762        &self,
1763        character_state: &CharacterState,
1764        active_fight: &Option<ActiveFight>,
1765        quest: &QuestInstance,
1766        progress_script_native: Option<&str>,
1767        trigger_event: &OverlordEvent,
1768        game_config: &GameConfig,
1769    ) -> anyhow::Result<i64> {
1770        // Native `conditional_progress` port (named by the quest's
1771        // `progress_script_native` ref). `quest` is the pre-update instance,
1772        // mirroring the `Quest` const the script saw. Missing / unregistered ref
1773        // leaves the progress unchanged.
1774        let Some(name) = progress_script_native else {
1775            return Ok(quest.current);
1776        };
1777        let Some(f) = self.behaviors.conditional_progress_fn(name) else {
1778            return Ok(quest.current);
1779        };
1780        f(
1781            &crate::behaviors::quests::progress::ConditionalProgressCtx {
1782                event: trigger_event,
1783                character_state,
1784                active_fight,
1785                quest,
1786                config: game_config,
1787                lookups: self.behaviors.lookups(),
1788            },
1789        )
1790    }
1791
1792    pub fn handle_currency_increase(
1793        &self,
1794        currencies: &[CurrencyUnit],
1795        mut state: OverlordState,
1796    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1797        let banked = self.drop_pre_gate_pet_currency(currencies, &state);
1798        increase_currencies(&mut state.character_state.currencies, &banked);
1799        EventHandleResult::ok(state)
1800    }
1801
1802    /// A2-BAL-003 §3.6: Beast Tokens do not bank before the ch35 pet gate.
1803    ///
1804    /// Opening a pet case is already server-denied before the gate, but that
1805    /// only stops the SPEND. Tokens kept accumulating from rating rewards,
1806    /// quests and Progress Pass tiers, so a player arrived at ch35 with a stock
1807    /// built up over thirty-four chapters and burned it instantly — which is
1808    /// exactly the pre-gate bank the card forbids, and it invalidates the pet
1809    /// acquisition horizons BAL-032 signs.
1810    ///
1811    /// Dropping them at the single banking chokepoint covers every source at
1812    /// once, including ones added later, rather than editing each bundle and
1813    /// hoping none reappears. The ch35 welcome grant is what seeds the first
1814    /// tokens instead.
1815    fn drop_pre_gate_pet_currency(
1816        &self,
1817        currencies: &[CurrencyUnit],
1818        state: &OverlordState,
1819    ) -> Vec<CurrencyUnit> {
1820        let game_config = self.game_config.get();
1821        let chapter = state.character_state.character.current_chapter_level;
1822        if game_config.pet_slots_for_chapter_level(chapter).is_some() {
1823            return currencies.to_vec();
1824        }
1825        let pet_currency = game_config.game_settings.pet_gacha.currency_id;
1826        currencies
1827            .iter()
1828            .filter(|unit| {
1829                if unit.currency_id == pet_currency {
1830                    tracing::debug!(
1831                        amount = unit.amount,
1832                        chapter,
1833                        "dropped pre-gate pet currency"
1834                    );
1835                    return false;
1836                }
1837                true
1838            })
1839            .cloned()
1840            .collect()
1841    }
1842
1843    pub fn handle_currency_decrease(
1844        &self,
1845        currencies: &[CurrencyUnit],
1846        mut state: OverlordState,
1847    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1848        let non_zero: Vec<_> = currencies
1849            .iter()
1850            .filter(|c| c.amount > 0)
1851            .cloned()
1852            .collect();
1853        if let Err(e) = decrease_currencies(&mut state.character_state.currencies, &non_zero) {
1854            tracing::error!("CurrencyDecrease failed: {e}");
1855            return EventHandleResult::fail(state);
1856        }
1857        EventHandleResult::ok(state)
1858    }
1859
1860    /// Creates a `CurrencyIncrease` event. The actual state mutation happens when the
1861    /// event is processed by the handler — callers don't need to mutate state manually.
1862    pub fn currency_increase(
1863        currencies: &[CurrencyUnit],
1864        currency_source: CurrencySource,
1865    ) -> EventPluginized<OverlordEvent, OverlordState> {
1866        EventPluginized::now(OverlordEvent::CurrencyIncrease {
1867            currencies: currencies.to_owned(),
1868            currency_source,
1869        })
1870    }
1871
1872    /// Creates a `CurrencyDecrease` event after validating sufficient balance.
1873    /// Returns `None` if the player doesn't have enough currency.
1874    /// The actual state mutation happens when the event is processed by the handler.
1875    pub fn currency_decrease(
1876        state: &OverlordState,
1877        currencies: &[CurrencyUnit],
1878        currency_consumer: CurrencyConsumer,
1879    ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
1880        if !check_can_decrease_currencies(&state.character_state.currencies, currencies) {
1881            tracing::error!(
1882                "currency_decrease({currency_consumer:?}): not enough currency, \
1883                 required={currencies:?}, available={:?}",
1884                state.character_state.currencies
1885            );
1886            return None;
1887        }
1888        Some(EventPluginized::now(OverlordEvent::CurrencyDecrease {
1889            currencies: currencies.to_owned(),
1890            currency_consumer,
1891        }))
1892    }
1893
1894    fn handle_reset_ad_usage(
1895        &mut self,
1896        placements: &[AdPlacement],
1897        mut state: OverlordState,
1898    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1899        let now = ::time::utc_now();
1900        for placement in placements {
1901            state.character_state.ad_usage.insert(
1902                *placement,
1903                AdUsageData {
1904                    daily_count: 0,
1905                    last_reset_at: now,
1906                },
1907            );
1908        }
1909
1910        EventHandleResult::ok(state)
1911    }
1912
1913    fn handle_reset_instant_reward_gems_press_count(
1914        &mut self,
1915        mut state: OverlordState,
1916    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1917        state
1918            .character_state
1919            .character
1920            .instant_reward_gems_press_count = 0;
1921        EventHandleResult::ok(state)
1922    }
1923
1924    pub fn handle_show_bird(
1925        &mut self,
1926        mut state: OverlordState,
1927    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1928        let bird_config = &self.game_config.get().ads_settings.bird_ad;
1929        let cooldown_until = ::time::utc_now()
1930            + chrono::TimeDelta::seconds(bird_config.post_show_cooldown_sec as i64);
1931        state.character_state.character.bird_cooldown_until = Some(cooldown_until);
1932        EventHandleResult::ok(state)
1933    }
1934
1935    pub fn handle_bird_shown(
1936        &mut self,
1937        mut state: OverlordState,
1938    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1939        let bird_config = &self.game_config.get().ads_settings.bird_ad;
1940        let cooldown_until =
1941            ::time::utc_now() + chrono::TimeDelta::seconds(bird_config.cooldown_sec as i64);
1942        state.character_state.character.bird_cooldown_until = Some(cooldown_until);
1943        EventHandleResult::ok(state)
1944    }
1945
1946    pub fn handle_rate_us_shown(
1947        &mut self,
1948        mut state: OverlordState,
1949    ) -> EventHandleResult<OverlordEvent, OverlordState> {
1950        state.character_state.character.rate_us_shown = true;
1951        EventHandleResult::ok(state)
1952    }
1953}