overlord_event_system/logic/
pet_facets.rs

1//! Pet Facets / Team Die v0 — the combat runtime.
2//!
3//! # The resolution boundary
4//!
5//! ```text
6//! Global Flip begins
7//! → the incoming side is determined
8//! → the Team Die picks one of THAT side's three facets
9//! → the outcome applies one instant payoff or one short window
10//! → the remaining incoming Flip payoffs execute
11//! ```
12//!
13//! That order is the whole reason [`OverlordLogic::handle_global_flip`] exists:
14//! the die is rolled and its outcome applied BEFORE
15//! [`OverlordLogic::apply_flip_to_laws`] delivers the bridge charge and fires
16//! the phase-start laws, and long before the artifact Flip Aspects run out of
17//! `apply_success_hooks`. The same function is the single dispatch arm for
18//! `GlobalFlip` in both paths — the pure dispatch and the monolith's merged arm
19//! — so neither can drift into rolling a different number of times.
20//!
21//! # Provenance
22//!
23//! A facet outcome is **Derived**: everything it lands as a combat outcome is
24//! emitted [`CombatEventOrigin::Proc`], so it can never ignite a Trigger Stone
25//! and never re-enter a law condition. It creates no Resonance, no Mastery, no
26//! Bridge charge and no Gauge — the two Clock Crow facets
27//! ([`PetFacet::AdvanceNotice`], [`PetFacet::RisingGate`]) are the design doc's
28//! one stated exception, and they are the Flip-Gauge pair whose entire content
29//! is handing out Gauge. Where a facet touches Resonance
30//! ([`PetFacet::LeadReading`]) or the Gauge of an existing proc
31//! ([`PetFacet::RisingGate`]) it MODIFIES the flow that was already going to
32//! run; it never opens a second one.
33//!
34//! A facet-generated repeat cannot repeat itself: [`mech::APPLYING`] is raised
35//! for the whole of an outcome, and [`OverlordLogic::apply_pet_facets`] — the
36//! hook that spends armed facet state — leaves immediately while it is up.
37//!
38//! # State
39//!
40//! Per-fight facet state lives in the player entity's `attributes` under
41//! `pet.*` ([`crate::mechanics::pet_facets`]), so it resets with the fight by
42//! construction. The three "one chosen Law" facets read a DURABLE selection
43//! ([`essences::pet_facets::PetFacetLawChoices`], set out of combat) and keep
44//! only the charge in the entity.
45
46use essences::combat_origin::CombatEventOrigin;
47use essences::cores::LawTemplateId;
48use essences::entity::EntityId;
49use essences::fight_breakdown::CombatSource;
50use essences::flip::WorldSide;
51use essences::pet_facets::{PetFacet, PetFacetLawRole};
52
53use crate::logic::combat_facts::{self, CombatFact};
54use crate::mechanics::{balance, fight::get_entity_stat, pet_facets as mech, stones as stone_mech};
55use crate::{
56    EventHandleResult,
57    event::OverlordEvent,
58    logic::{EventPluginized, handler::OverlordLogic},
59    state::OverlordState,
60};
61
62/// Everything a facet outcome may need, read once before any mutation.
63struct FacetCtx {
64    player_id: EntityId,
65    player_attack: f64,
66    player_max_hp: u64,
67    player_speed: i64,
68    target_id: Option<EntityId>,
69    /// The player's original Skills that are on cooldown right now — `Head
70    /// Start`'s surface. Class Basic Attacks are excluded: the facet says
71    /// "Skill cooldowns", and the auto-attack metronome is not one.
72    skill_ability_ids: Vec<essences::abilities::AbilityId>,
73    now: u64,
74}
75
76impl OverlordLogic {
77    // ---- Entry points -------------------------------------------------------
78
79    /// Dispatch arm for `GlobalFlip`, in BOTH paths.
80    ///
81    /// Order is the acceptance criterion, not a preference: roll and resolve the
82    /// Team Die of the incoming side, then hand the flip to the law layer, which
83    /// delivers bridge charge and fires the phase-start laws.
84    pub fn handle_global_flip(
85        &mut self,
86        entity_id: EntityId,
87        from_side: WorldSide,
88        to_side: WorldSide,
89        revision: u64,
90        rng: &mut rand::rngs::StdRng,
91        mut state: OverlordState,
92    ) -> EventHandleResult<OverlordEvent, OverlordState> {
93        let mut events = self.roll_team_die(&mut state, entity_id, to_side, Some(revision), rng);
94        events.extend(self.apply_flip_to_laws(&mut state, entity_id, from_side, to_side));
95        EventHandleResult::ok_events(state, events)
96    }
97
98    /// The entry roll: stepping into the starting world of a fight is the same
99    /// moment as arriving on a side through a flip, so it rolls the same die.
100    ///
101    /// `revision` is `None` here — a fight start is not a flip and carries no
102    /// revision, and the per-flip latch it feeds exists to stop ONE flip rolling
103    /// twice, which a fight start cannot do (the fight has exactly one).
104    pub fn roll_team_die_on_fight_start(
105        &mut self,
106        state: &mut OverlordState,
107        rng: &mut rand::rngs::StdRng,
108    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
109        let Some(fight) = state.active_fight.as_ref() else {
110            return Vec::new();
111        };
112        // Every character combatant enters their own world and rolls their own
113        // die: the local hero, the party ally, and a human PvP opponent.
114        let mut combatants = vec![fight.player_id];
115        combatants.extend(fight.party_player_id);
116        if let Some(pvp) = &state.pvp_state {
117            let opponent_id = pvp.opponent_state.id();
118            if fight.entities.iter().any(|e| e.id == opponent_id) {
119                combatants.push(opponent_id);
120            }
121        }
122        let mut events = Vec::new();
123        for combatant_id in combatants {
124            let Some(side) = state
125                .active_fight
126                .as_ref()
127                .and_then(|fight| fight.entities.iter().find(|e| e.id == combatant_id))
128                .and_then(|entity| entity.flip_state)
129                .map(|flip| flip.active_side)
130            else {
131                // No flip state means the flip is still locked for this
132                // combatant: there is no "world" to enter yet, so there is no
133                // die to roll.
134                continue;
135            };
136            events.extend(self.roll_team_die(state, combatant_id, side, None, rng));
137        }
138        events
139    }
140
141    /// Rolls the die for `side` and applies the single facet it lands on.
142    ///
143    /// Uniform `1/3` over the incoming side's three faces (one per equipped
144    /// pet), drawn from the fight's own `StdRng` — so the roll is
145    /// server-authoritative and reproducible for a seed. No manual roll, no
146    /// hold, no reroll, no weights.
147    fn roll_team_die(
148        &mut self,
149        state: &mut OverlordState,
150        entity_id: EntityId,
151        side: WorldSide,
152        revision: Option<u64>,
153        rng: &mut rand::rngs::StdRng,
154    ) -> Vec<EventPluginized<OverlordEvent, OverlordState>> {
155        let mut events = Vec::new();
156        let Some(fight) = state.active_fight.as_ref() else {
157            return events;
158        };
159        if fight.fight_ended || fight.fight_stopped {
160            return events;
161        }
162        // The flipping combatant rolls their OWN pets — resolvable for the
163        // hero, the party ally, and a human PvP opponent. A mob's flip (no
164        // CharacterState) rolls nothing.
165        let Some(build) = crate::entities::combatant_character_state(
166            &state.character_state,
167            &state.party,
168            &state.pvp_state,
169            fight,
170            entity_id,
171        ) else {
172            return events;
173        };
174        let equipped_pets = build.equipped_pets.clone();
175
176        // One roll per flip, however many times `GlobalFlip` reaches this arm.
177        // `revision + 1` so an untouched attribute reads as "not rolled yet"
178        // even at revision 0.
179        if let Some(revision) = revision {
180            let this_flip = revision as i64 + 1;
181            let Some(player) = state
182                .active_fight
183                .as_mut()
184                .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == entity_id))
185            else {
186                return events;
187            };
188            if mech::attr(player, mech::ROLL_REVISION) == this_flip {
189                return events;
190            }
191            player.attributes.set(mech::ROLL_REVISION, this_flip);
192            // A phase's own facet state dies with the phase, before the
193            // incoming phase's facet arms anything.
194            mech::clear_phase_state(player);
195        }
196
197        let game_config = self.game_config.get();
198        let faces = mech::eligible_facets(&game_config, &equipped_pets, side);
199        let Some(rolled) = mech::roll_team_die(rng, &faces) else {
200            return events;
201        };
202
203        let Some(ctx) = self.read_fight(state, entity_id) else {
204            return events;
205        };
206        {
207            let Some(player) = state
208                .active_fight
209                .as_mut()
210                .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == entity_id))
211            else {
212                return events;
213            };
214            player
215                .attributes
216                .set(mech::LAST_ROLL, facet_marker(rolled.facet));
217            // The recursion guard is raised BEFORE the outcome runs and lowered
218            // after, so anything the outcome dispatches that comes back through
219            // `apply_pet_facets` finds the door shut.
220            player.attributes.set(mech::APPLYING, 1);
221        }
222
223        self.apply_facet(state, &mut events, &ctx, rolled.facet, rolled.pet_level);
224
225        if let Some(player) = state
226            .active_fight
227            .as_mut()
228            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == entity_id))
229        {
230            player.attributes.set(mech::APPLYING, 0);
231        }
232        events
233    }
234
235    /// Reads everything a facet outcome needs, once, before any mutation.
236    fn read_fight(&self, state: &OverlordState, player_id: EntityId) -> Option<FacetCtx> {
237        let game_config = self.game_config.get();
238        let fight = state.active_fight.as_ref()?;
239        let player = fight.entities.iter().find(|e| e.id == player_id)?;
240        let skill_ability_ids = player
241            .actions_queue
242            .start_cast_entries()
243            .into_iter()
244            .map(|(ability_id, _)| ability_id)
245            .filter(|ability_id| {
246                combat_facts::cast_kind(&game_config, player, *ability_id)
247                    != combat_facts::CastKind::Basic
248            })
249            .collect();
250        Some(FacetCtx {
251            player_id,
252            player_attack: get_entity_stat(self.behaviors.lookups(), player, "attack"),
253            player_max_hp: player.max_hp,
254            player_speed: player
255                .attributes
256                .speed_or_baseline(game_config.game_settings.baseline_speed),
257            target_id: crate::logic::stones::nearest_enemy(&fight.entities, player),
258            skill_ability_ids,
259            now: self.fight_clock.now(),
260        })
261    }
262
263    // ---- The twenty outcomes ------------------------------------------------
264
265    /// Applies exactly one facet: one instant payoff or one short window.
266    fn apply_facet(
267        &mut self,
268        state: &mut OverlordState,
269        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
270        ctx: &FacetCtx,
271        facet: PetFacet,
272        pet_level: i64,
273    ) {
274        let game_config = self.game_config.get();
275        let settings = &game_config.pet_facet_settings;
276        let player_id = ctx.player_id;
277        let permyriad = |percent: f64| mech::permyriad(settings, percent, pet_level);
278        // A permyriad share of Attack, through the same damage constant every
279        // other derived hit in the game uses.
280        let derived = |share_permyriad: i64| {
281            (ctx.player_attack * (share_permyriad as f64 / 10_000.0) * balance::DMG_K)
282                .floor()
283                .max(0.0) as u64
284        };
285
286        match facet {
287            // ---- PET-01 Zip ----
288            PetFacet::HeadStart => {
289                let by_ticks = mech::ticks(settings, settings.head_start_ticks, pet_level);
290                let ability_ids = ctx.skill_ability_ids.clone();
291                let now = ctx.now;
292                Self::with_player(state, player_id, |player| {
293                    player
294                        .actions_queue
295                        .shorten_cooldowns_of(now, &ability_ids, by_ticks);
296                });
297            }
298            PetFacet::SecondSpark => Self::arm_pet_facet(
299                state,
300                player_id,
301                &[
302                    (
303                        mech::NEXT_SKILL_ECHO,
304                        permyriad(settings.second_spark_percent),
305                    ),
306                    (mech::NEXT_SKILL_ECHO_CHARGES, 1),
307                ],
308            ),
309
310            // ---- PET-02 Ledger Mimic ----
311            PetFacet::BudgetPlan => {
312                // A DISCOUNT is a smaller multiplier, so rank must not scale it
313                // the way it scales a bonus: `rank_multiplier` is applied to the
314                // discount depth (1 - multiplier), which is the number the
315                // facet is actually about.
316                let depth = (1.0 - settings.budget_plan_cost_multiplier).max(0.0)
317                    * settings.rank_multiplier(pet_level);
318                let multiplier = ((1.0 - depth).max(0.0) * 10_000.0).round() as i64;
319                Self::arm_pet_facet(
320                    state,
321                    player_id,
322                    &[
323                        (mech::BUDGET_MULT, multiplier),
324                        (mech::BUDGET_CASTS, settings.budget_plan_casts.max(1)),
325                    ],
326                )
327            }
328            PetFacet::OpenTab => Self::arm_pet_facet(
329                state,
330                player_id,
331                &[
332                    (
333                        mech::OPEN_TAB_SURCHARGE,
334                        permyriad(settings.open_tab_surcharge_percent),
335                    ),
336                    (
337                        mech::OPEN_TAB_PAYLOAD_SHARE,
338                        permyriad(settings.open_tab_payload_share_percent),
339                    ),
340                    (mech::OPEN_TAB_CHARGES, 1),
341                ],
342            ),
343
344            // ---- PET-03 Springpaw ----
345            PetFacet::Overtime => {
346                let delta = (ctx.player_speed as f64
347                    * (permyriad(settings.overtime_attack_speed_percent) as f64 / 10_000.0))
348                    .round() as i64;
349                self.apply_timed_attribute(
350                    events,
351                    player_id,
352                    "speed",
353                    delta,
354                    settings.overtime_duration_ticks,
355                );
356            }
357            PetFacet::MagicVolley => {
358                let damage = derived(permyriad(settings.magic_volley_percent));
359                for _ in 0..settings.magic_volley_bolts.max(0) {
360                    let Some(target_id) = ctx.target_id else {
361                        break;
362                    };
363                    push_derived_damage(
364                        events,
365                        player_id,
366                        target_id,
367                        damage,
368                        PetFacet::MagicVolley,
369                    );
370                }
371            }
372
373            // ---- PET-04 Bulwark Slime ----
374            PetFacet::SafetyNet => {
375                // The stone runtime's own reduction key: one place reads
376                // "incoming damage is reduced" (`mechanics::fight::damage`), and
377                // a second key would silently not compose with the first.
378                self.apply_timed_attribute(
379                    events,
380                    player_id,
381                    stone_mech::INCOMING_REDUCTION,
382                    permyriad(settings.safety_net_reduction_percent).clamp(0, 10_000),
383                    settings.safety_net_duration_ticks,
384                );
385            }
386            PetFacet::Retaliation => Self::arm_pet_facet(
387                state,
388                player_id,
389                &[
390                    (mech::RETALIATION, permyriad(settings.retaliation_percent)),
391                    (mech::RETALIATION_CHARGES, 1),
392                ],
393            ),
394
395            // ---- PET-05 Lucky Bat ----
396            PetFacet::Calibration => {
397                // Again the stone key, for the same reason: the crit roll in
398                // `mechanics::fight::attack` reads exactly one armed bonus.
399                Self::arm_pet_facet(
400                    state,
401                    player_id,
402                    &[
403                        (
404                            stone_mech::NEXT_ATTACK_CRIT,
405                            permyriad(settings.calibration_crit_percent),
406                        ),
407                        (
408                            stone_mech::NEXT_ATTACK_CRIT_CHARGES,
409                            settings.calibration_attacks.max(1),
410                        ),
411                    ],
412                )
413            }
414            PetFacet::LuckyStar => Self::arm_pet_facet(
415                state,
416                player_id,
417                &[
418                    (mech::LUCKY_STAR, permyriad(settings.lucky_star_percent)),
419                    (mech::LUCKY_STAR_CHARGES, 1),
420                ],
421            ),
422
423            // ---- PET-06 Owl Auditor ----
424            PetFacet::LeadReading => Self::arm_pet_facet(
425                state,
426                player_id,
427                &[
428                    (
429                        mech::LEAD_READING_BONUS,
430                        permyriad(settings.lead_reading_resonance_percent),
431                    ),
432                    (
433                        mech::LEAD_READING_CHARGES,
434                        settings.lead_reading_activations.max(1),
435                    ),
436                ],
437            ),
438            PetFacet::WildReading => {
439                let until = ctx.now as i64 + settings.wild_reading_duration_ticks as i64 + 1;
440                Self::arm_pet_facet(
441                    state,
442                    player_id,
443                    &[
444                        (
445                            mech::WILD_READING_BONUS,
446                            permyriad(settings.wild_reading_effect_percent),
447                        ),
448                        (mech::WILD_READING_UNTIL, until),
449                    ],
450                )
451            }
452
453            // ---- PET-08 Clock Crow ----
454            PetFacet::AdvanceNotice => {
455                let share = (permyriad(settings.advance_notice_gauge_percent) as f64 / 10_000.0)
456                    .clamp(0.0, 0.99);
457                let threshold =
458                    crate::entities::combatant_flip_threshold(state, player_id, &game_config);
459                let opening = threshold * share;
460                let mut snapshot = None;
461                if let Some(player) = state
462                    .active_fight
463                    .as_mut()
464                    .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == player_id))
465                    && let Some(flip) = player.flip_state.as_mut()
466                {
467                    // The doc's one stated exception to "a facet creates no
468                    // Gauge". Set rather than added: the phase has just started,
469                    // the accumulator is at zero, and "starts with 12%" is a
470                    // floor on the opening reading, not a gain on top of one.
471                    flip.progress = flip.progress.max(opening);
472                    snapshot = Some(*flip);
473                }
474                if let Some(snapshot) = snapshot {
475                    crate::entities::mirror_combatant_flip_state(state, player_id, snapshot);
476                }
477            }
478            PetFacet::RisingGate => Self::arm_pet_facet(
479                state,
480                player_id,
481                &[
482                    (
483                        mech::RISING_GATE_MULT,
484                        // BAL-014: a multiplier on the proc's own Gauge award,
485                        // interpolated over pet levels instead of rank growth.
486                        (configs::pet_facets::PetFacetSettings::level_interpolated(
487                            settings.rising_gate_multiplier_l1,
488                            settings.rising_gate_multiplier_l10,
489                            pet_level,
490                        ) * 100.0)
491                            .round() as i64,
492                    ),
493                    (mech::RISING_GATE_PROCS, settings.rising_gate_procs.max(1)),
494                ],
495            ),
496
497            // ---- PET-09 Toolbox Goblin ----
498            PetFacet::PreparedSlots => Self::arm_pet_facet(
499                state,
500                player_id,
501                &[(
502                    mech::PREPARED_SLOTS,
503                    permyriad(settings.prepared_slots_percent),
504                )],
505            ),
506            PetFacet::FirstSpell => Self::arm_pet_facet(
507                state,
508                player_id,
509                &[
510                    (mech::FIRST_SPELL, permyriad(settings.first_spell_percent)),
511                    (mech::FIRST_SPELL_CHARGES, 1),
512                ],
513            ),
514
515            // ---- PET-10 Mirror Moth ----
516            PetFacet::Souvenir => Self::arm_pet_facet(
517                state,
518                player_id,
519                &[
520                    (mech::SOUVENIR_SHARE, permyriad(settings.souvenir_percent)),
521                    (mech::SOUVENIR_CHARGES, settings.souvenir_procs.max(1)),
522                ],
523            ),
524            PetFacet::DreamReader => Self::arm_pet_facet(
525                state,
526                player_id,
527                &[
528                    (mech::DREAM_READER, permyriad(settings.dream_reader_percent)),
529                    (mech::DREAM_READER_CHARGES, 1),
530                ],
531            ),
532
533            // ---- PET-11 Lunchbox Boar ----
534            PetFacet::PackedLunch => {
535                let heal = (ctx.player_max_hp as f64
536                    * (permyriad(settings.packed_lunch_heal_percent) as f64 / 10_000.0))
537                    .round() as u64;
538                if heal > 0 {
539                    // Overheal is discarded by `handle_heal`, which clamps to
540                    // `max_hp` — the facet needs no rule of its own for it.
541                    events.push(EventPluginized::now(OverlordEvent::Heal {
542                        by_entity_id: Some(player_id),
543                        entity_id: player_id,
544                        heal,
545                        origin: CombatEventOrigin::Proc,
546                        source: CombatSource::PetFacetProc {
547                            facet: PetFacet::PackedLunch,
548                        },
549                    }));
550                }
551            }
552            PetFacet::LifeBloom => {
553                // BAL-014: L1/L10 endpoints interpolated over pet levels. The
554                // interpolation IS the level scaling — running the result
555                // through the generic rank multiplier again would double-scale
556                // it (10% at L10 would become 14.5%), so convert directly, the
557                // same way Rising Gate does.
558                let percent = configs::pet_facets::PetFacetSettings::level_interpolated(
559                    settings.life_bloom_percent_l1,
560                    settings.life_bloom_percent_l10,
561                    pet_level,
562                );
563                self.apply_timed_attribute(
564                    events,
565                    player_id,
566                    mech::LIFE_BLOOM,
567                    (percent * 100.0).round() as i64,
568                    settings.life_bloom_duration_ticks,
569                );
570            }
571        }
572    }
573
574    /// Writes facet state straight onto the player entity. A magnitude is a
575    /// VALUE: re-arming overwrites it rather than stacking it into nonsense.
576    fn arm_pet_facet(state: &mut OverlordState, player_id: EntityId, pairs: &[(&str, i64)]) {
577        Self::with_player(state, player_id, |player| {
578            for (key, value) in pairs {
579                player.attributes.set(key, *value);
580            }
581        });
582    }
583
584    // ---- Spending armed facet state ----------------------------------------
585
586    /// Success hook: spends the facet state this Core event consumes.
587    ///
588    /// The pet half of what [`crate::logic::stones::spend_effect_state`] does
589    /// for stones, kept in its own hook because a facet must work for a player
590    /// with nothing socketed — the stones hook leaves through an early return
591    /// for exactly that player.
592    ///
593    /// Everything it emits is `Proc`. It leaves immediately while
594    /// [`mech::APPLYING`] is raised, which is the guard against a
595    /// facet-generated repeat repeating itself.
596    pub fn apply_pet_facets(
597        &mut self,
598        state: &mut OverlordState,
599        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
600        event: &OverlordEvent,
601    ) {
602        let Some(fight) = state.active_fight.as_ref() else {
603            return;
604        };
605        if fight.fight_ended || fight.fight_stopped {
606            return;
607        }
608        // Every character combatant spends their OWN armed facet state: the
609        // armed keys live on the entity, so the pass is per combatant.
610        let mut combatants = vec![fight.player_id];
611        combatants.extend(fight.party_player_id);
612        if let Some(pvp) = &state.pvp_state {
613            let opponent_id = pvp.opponent_state.id();
614            if fight.entities.iter().any(|e| e.id == opponent_id) {
615                combatants.push(opponent_id);
616            }
617        }
618        for combatant_id in combatants {
619            self.apply_pet_facets_for(state, events, event, combatant_id);
620        }
621    }
622
623    /// One combatant's pass of [`Self::apply_pet_facets`].
624    fn apply_pet_facets_for(
625        &mut self,
626        state: &mut OverlordState,
627        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
628        event: &OverlordEvent,
629        combatant_id: EntityId,
630    ) {
631        let Some(fight) = state.active_fight.as_ref() else {
632            return;
633        };
634        let player_id = combatant_id;
635        let Some(player) = fight.entities.iter().find(|e| e.id == player_id) else {
636            return;
637        };
638        if mech::attr(player, mech::APPLYING) != 0 {
639            return;
640        }
641        // Cheap gate: this hook runs on every successfully handled event of the
642        // session, and a player with no armed facet state has nothing to spend.
643        if !ARMED_KEYS.iter().any(|key| mech::attr(player, key) != 0) {
644            return;
645        }
646
647        let game_config = self.game_config.get();
648        let facts: Vec<CombatFact> = {
649            let classified = combat_facts::classify(&game_config, fight, event);
650            combat_facts::facts_for(&classified, player_id).collect()
651        };
652        if facts.is_empty() {
653            return;
654        }
655
656        let player_attack = get_entity_stat(self.behaviors.lookups(), player, "attack");
657        let enemy_ids = crate::logic::stones::living_enemies(&fight.entities, player);
658        let is_skill =
659            combat_facts::current_cast_kind(player) == Some(combat_facts::CastKind::Skill);
660
661        let derived = |share_permyriad: i64| {
662            (player_attack * (share_permyriad as f64 / 10_000.0) * balance::DMG_K)
663                .floor()
664                .max(0.0) as u64
665        };
666
667        let Some(player) = state
668            .active_fight
669            .as_mut()
670            .and_then(|fight| fight.entities.iter_mut().find(|e| e.id == player_id))
671        else {
672            return;
673        };
674
675        let mut produced: Vec<(EntityId, u64, PetFacet)> = Vec::new();
676        let mut healed = 0_u64;
677        for fact in facts {
678            match fact {
679                // `PET-04 Retaliation`: the next Core hit ON THE HERO answers
680                // with a derived area burst.
681                CombatFact::HitTaken { .. } => {
682                    if let Some(share) =
683                        mech::armed(player, mech::RETALIATION, mech::RETALIATION_CHARGES)
684                    {
685                        let damage = derived(share);
686                        for target in &enemy_ids {
687                            produced.push((*target, damage, PetFacet::Retaliation));
688                        }
689                        mech::spend_charge(player, mech::RETALIATION, mech::RETALIATION_CHARGES);
690                    }
691                }
692                CombatFact::HitLanded {
693                    crit,
694                    victim,
695                    damage,
696                } => {
697                    // `PET-11 Life Bloom`: a share of the Core damage this hit
698                    // dealt, healed back. Read literally off the damage, which
699                    // is what the facet says, rather than off Attack.
700                    let bloom = mech::attr(player, mech::LIFE_BLOOM);
701                    if bloom > 0 {
702                        let heal = (damage as f64 * bloom as f64 / 10_000.0).round() as u64;
703                        if heal > 0 {
704                            healed += heal;
705                        }
706                    }
707                    // `PET-05 Lucky Star`: one jackpot crit, on the enemy that
708                    // took it.
709                    if crit
710                        && let Some(share) =
711                            mech::armed(player, mech::LUCKY_STAR, mech::LUCKY_STAR_CHARGES)
712                    {
713                        produced.push((victim, derived(share), PetFacet::LuckyStar));
714                        mech::spend_charge(player, mech::LUCKY_STAR, mech::LUCKY_STAR_CHARGES);
715                    }
716                    if is_skill {
717                        // `PET-01 Second Spark`: one derived repeat of the next
718                        // original Skill.
719                        //
720                        // A share of what that Skill JUST LANDED, not of Attack.
721                        // The facet's wording is the same shape as `First
722                        // Spell`'s ("executes a second time at 100%") — it
723                        // repeats the thing, so it is worth what the thing was
724                        // worth. The facets that are priced off Attack say so
725                        // (`Magic Volley` 35% Attack, `Lucky Star` 180% Attack,
726                        // `Retaliation` 220% Attack); this one does not.
727                        if let Some(share) = mech::armed(
728                            player,
729                            mech::NEXT_SKILL_ECHO,
730                            mech::NEXT_SKILL_ECHO_CHARGES,
731                        ) {
732                            let repeat =
733                                (damage as f64 * share as f64 / 10_000.0).floor().max(0.0) as u64;
734                            produced.push((victim, repeat, PetFacet::SecondSpark));
735                            mech::spend_charge(
736                                player,
737                                mech::NEXT_SKILL_ECHO,
738                                mech::NEXT_SKILL_ECHO_CHARGES,
739                            );
740                        }
741                        // `PET-02 Open Tab`: the surcharge it paid at cast time,
742                        // as extra payload on the hit that cast produced.
743                        if let Some(bonus) = mech::armed(
744                            player,
745                            mech::NEXT_SKILL_BONUS,
746                            mech::NEXT_SKILL_BONUS_CHARGES,
747                        ) {
748                            produced.push((victim, derived(bonus), PetFacet::OpenTab));
749                            mech::spend_charge(
750                                player,
751                                mech::NEXT_SKILL_BONUS,
752                                mech::NEXT_SKILL_BONUS_CHARGES,
753                            );
754                        }
755                    }
756                }
757                _ => {}
758            }
759        }
760
761        for (target, damage, facet) in produced {
762            push_derived_damage(events, player_id, target, damage, facet);
763        }
764        if healed > 0 {
765            events.push(EventPluginized::now(OverlordEvent::Heal {
766                by_entity_id: Some(player_id),
767                entity_id: player_id,
768                heal: healed,
769                origin: CombatEventOrigin::Proc,
770                source: CombatSource::PetFacetProc {
771                    facet: PetFacet::LifeBloom,
772                },
773            }));
774        }
775    }
776
777    // ---- The three chosen-Law facets ---------------------------------------
778
779    /// The Law a chosen-Law facet acts on: the player's own selection when it is
780    /// still eligible, otherwise the deterministic fallback.
781    ///
782    /// Eligibility is `role`-specific because the three draw from three
783    /// different populations, and the fallback is "the lowest slot index of the
784    /// eligible side" — deterministic, non-panicking, and never a random pick:
785    /// a player who has never opened the picker gets a stable answer rather than
786    /// a facet that silently does nothing.
787    pub(crate) fn pet_facet_law(
788        &self,
789        state: &OverlordState,
790        owner_id: EntityId,
791        role: PetFacetLawRole,
792    ) -> Option<LawTemplateId> {
793        let game_config = self.game_config.get();
794        let owner = state
795            .active_fight
796            .as_ref()?
797            .entities
798            .iter()
799            .find(|e| e.id == owner_id)?;
800        let active_side = owner.flip_state?.active_side;
801        let wanted_side = match role {
802            // `Lead Reading` names a SOURCE law: it banks Resonance when the law
803            // fires, and a law only fires while its own side is up.
804            PetFacetLawRole::LeadReading | PetFacetLawRole::WildReading => Some(active_side),
805            PetFacetLawRole::DreamReader => Some(active_side.flipped()),
806        };
807
808        let eligible = |law_id: LawTemplateId| -> Option<i64> {
809            let owned = owner.law_cores.law(law_id)?;
810            let slot_index = owned.slot_index?;
811            let template = crate::mechanics::cores::law_template(&game_config, law_id)?;
812            if !template.is_active {
813                return None;
814            }
815            if wanted_side.is_some_and(|side| template.side != side) {
816                return None;
817            }
818            Some(slot_index)
819        };
820
821        // The OWNER's own selection: `law_cores` is the entity-local cores
822        // snapshot, so a party ally's or a PvP opponent's choice resolves from
823        // their build, never from the session character's.
824        let chosen = owner.law_cores.pet_facet_law_choices.get(role);
825        if let Some(chosen) = chosen
826            && eligible(chosen).is_some()
827        {
828            return Some(chosen);
829        }
830        // Fallback: lowest slot index on the eligible side, ties broken by law
831        // id so two laws in the same slot can never make the answer depend on
832        // vector order.
833        owner
834            .law_cores
835            .slotted_laws()
836            .filter_map(|owned| eligible(owned.template_id).map(|slot| (slot, owned.template_id)))
837            .min_by_key(|(slot, law_id)| (*slot, *law_id))
838            .map(|(_, law_id)| law_id)
839    }
840
841    /// Sets (or clears) one of the three out-of-combat Law selections.
842    ///
843    /// Refuses a law the character does not own — the selection is a pointer
844    /// into the player's own collection, and a dangling one would make the
845    /// fallback unreachable without ever telling anyone.
846    pub fn handle_set_pet_facet_law(
847        &self,
848        role: PetFacetLawRole,
849        law_template_id: Option<LawTemplateId>,
850        mut state: OverlordState,
851    ) -> EventHandleResult<OverlordEvent, OverlordState> {
852        if let Some(law_template_id) = law_template_id {
853            if self
854                .game_config
855                .get()
856                .laws
857                .iter()
858                .all(|law| law.id != law_template_id)
859            {
860                tracing::error!("SetPetFacetLaw refused: unknown law {law_template_id}");
861                return EventHandleResult::fail(state);
862            }
863            if state.character_state.cores.law(law_template_id).is_none() {
864                tracing::error!("SetPetFacetLaw refused: law {law_template_id} is not owned");
865                return EventHandleResult::fail(state);
866            }
867        }
868        state
869            .character_state
870            .cores
871            .pet_facet_law_choices
872            .set(role, law_template_id);
873        EventHandleResult::ok(state)
874    }
875}
876
877/// Every key whose presence means "this player has facet state to spend". Read
878/// as the cheap gate of [`OverlordLogic::apply_pet_facets`].
879const ARMED_KEYS: [&str; 5] = [
880    mech::RETALIATION_CHARGES,
881    mech::LUCKY_STAR_CHARGES,
882    mech::NEXT_SKILL_ECHO_CHARGES,
883    mech::NEXT_SKILL_BONUS_CHARGES,
884    mech::LIFE_BLOOM,
885];
886
887/// `PetFacet` as a stable non-zero marker, so an untouched attribute (`0`) reads
888/// as "the die has not been rolled".
889fn facet_marker(facet: PetFacet) -> i64 {
890    use strum::IntoEnumIterator;
891    PetFacet::iter()
892        .position(|candidate| candidate == facet)
893        .map_or(0, |index| index as i64 + 1)
894}
895
896/// The facet a marker written by [`facet_marker`] names.
897pub fn facet_from_marker(marker: i64) -> Option<PetFacet> {
898    use strum::IntoEnumIterator;
899    if marker <= 0 {
900        return None;
901    }
902    PetFacet::iter().nth(marker as usize - 1)
903}
904
905/// One derived hit: `Proc`, so it feeds no trigger and starts no cascade.
906fn push_derived_damage(
907    events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
908    player_id: EntityId,
909    target_id: EntityId,
910    damage: u64,
911    facet: PetFacet,
912) {
913    if damage == 0 {
914        return;
915    }
916    events.push(EventPluginized::now(OverlordEvent::Damage {
917        by_entity_id: Some(player_id),
918        entity_id: target_id,
919        damage,
920        damage_data: Default::default(),
921        origin: CombatEventOrigin::Proc,
922        source: CombatSource::PetFacetProc { facet },
923    }));
924}