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}