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