overlord_event_system/logic/
artifacts.rs

1//! Artifacts — collection handlers, acquisition, and the two Aspect rules that
2//! do not ride on a trigger fire.
3//!
4//! Three of the six Aspect rules hang off a trigger and live in
5//! [`crate::logic::stones`] («Нарастание», «Первый удар», «Фоновый голос»); the
6//! remaining three hang off the fight's own moments and live here: «Теневой
7//! круг» on the heartbeat, «Входящий залп» and «Поочерёдный вход» on the flip.
8//!
9//! # Provenance
10//!
11//! Every effect fired from this module goes through
12//! [`crate::logic::stones::OverlordLogic::run_stone_effect`], whose combat
13//! output is built `CombatEventOrigin::Proc` and whose bookkeeping output
14//! (`EntityIncrAttribute`) carries no provenance at all and is not trigger
15//! surface. Nothing here builds a combat event itself. That is what keeps the
16//! artifact from closing the loop the Core/Proc boundary exists to prevent: an
17//! artifact rebroadcasts effects, and a rebroadcast effect can never fire the
18//! trigger that would rebroadcast it again.
19//!
20//! The **second** loop guard is that an artifact-driven fire never feeds the
21//! flip gauge: `run_stone_effect`'s gauge return value is deliberately dropped
22//! at every call site in this module. Without it, «Входящий залп» could fill the
23//! gauge, flip, and volley again — the one cascade that is not covered by the
24//! Core/Proc boundary, because a flip is a state transition rather than a combat
25//! event and carries no provenance to inherit.
26//!
27//! The **third** guard is `artifact.flip.rev`: one flip runs at most one volley
28//! even if `GlobalFlip` reaches the hook more than once.
29
30use essences::artifacts::{
31    ArtifactCollection, ArtifactSocketColumn, ArtifactSocketSlot, ArtifactStoneTemplateId,
32    ArtifactTemplateId,
33};
34use essences::entity::EntityId;
35use essences::fight_breakdown::CombatSource;
36use essences::flip::WorldSide;
37use essences::items::ItemType;
38use essences::stones::{StoneSocketKey, StoneSocketSlot, StoneTier};
39
40use configs::artifacts::ArtifactStoneRule;
41use configs::game_config::GameConfig;
42use strum::{EnumCount, IntoEnumIterator};
43
44use crate::game_config_helpers::GameConfigLookup;
45use crate::mechanics::{artifacts as mech, fight::get_entity_stat};
46use crate::{
47    EventHandleResult,
48    event::OverlordEvent,
49    logic::{EventPluginized, handler::OverlordLogic, stones::StoneEffectCtx},
50    state::OverlordState,
51};
52
53/// Scale a rule's share is carried at on the wire. The event enum derives `Eq`,
54/// which rules out an `f64` field, so a deferred fire ships its share as
55/// ten-thousandths.
56const SHARE_PERMYRIAD: i64 = 10_000;
57
58/// One effect an artifact rule wants run: which stone, at what level, at what
59/// share of its strength, and how far in the future. Grouped rather than passed
60/// loose because the four are one decision — "run THIS effect, THEN".
61#[derive(Clone, Copy)]
62struct ArtifactFire {
63    effect_template_id: uuid::Uuid,
64    effect_level: i64,
65    share: f64,
66    /// Aspect stone whose rule ordered this fire — the breakdown row the
67    /// rebroadcast effect's damage and healing land in.
68    artifact_stone_id: ArtifactStoneTemplateId,
69    /// `0` everywhere except `FA-05 Staggered Entrance`, the only rule that
70    /// spaces its fires out. A non-zero delay defers the whole run through
71    /// [`OverlordEvent::FireArtifactEffect`].
72    delay_ticks: u64,
73}
74
75/// One socketed Aspect stone, flattened to the three numbers a rule needs.
76///
77/// Flattened deliberately: the callers read this *before* mutating the character
78/// state, and an owned copy keeps the config borrow from outliving that read.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub(crate) struct AspectRuleInstance {
81    pub(crate) rule: ArtifactStoneRule,
82    /// Catalog id of the socketed Aspect stone this reading came from.
83    pub(crate) stone_id: ArtifactStoneTemplateId,
84    /// The rule's magnitude at the stone's current upgrade level, in percent.
85    pub(crate) magnitude: f64,
86    /// The rule's second percentage (a floor, a cap, a share of the gauge).
87    /// Never scaled by level, for the same reason as `rule_param`.
88    pub(crate) secondary_magnitude: f64,
89    /// The rule's own parameter (a period in ticks, or a count). Never scaled by
90    /// level — an upgrade changes how strong the rule is, never what it does.
91    pub(crate) rule_param: i64,
92    /// `VA-05 Small Gears` only: the per-tier share, resolved for every tier at
93    /// read time. Flattened to an array rather than carried as the catalog's
94    /// `Vec` so this struct stays `Copy` like the rest of the flattened reading;
95    /// `100.0` everywhere for the fourteen rules that do not use it, which is
96    /// also what a missing catalog rung means.
97    pub(crate) tier_magnitudes: [f64; StoneTier::COUNT],
98}
99
100impl AspectRuleInstance {
101    /// The share this rule gives an effect lit by a trigger of `tier`, as a
102    /// multiplier. `1.0` for every rule except `VA-05`.
103    pub(crate) fn tier_share(&self, tier: StoneTier) -> f64 {
104        self.tier_magnitudes[tier as usize] / 100.0
105    }
106}
107
108/// The Aspect rule sitting in `socket` — `None` when the socket is empty, when
109/// the player owns no artifact at all, or when the stone in it ships switched
110/// off.
111///
112/// Three left-column rules (`VA-02 Alternator`, `VA-03 Focused Engine`,
113/// `VA-05 Small Gears`) are entered with their catalog numbers but cannot run
114/// here: two need a slot-ordering rule for simultaneous procs and one needs
115/// Legendary trigger content, and neither is on this branch. Filtering them at
116/// the single read point is what makes "switched off" mean the same thing to
117/// every rule site; `GameConfig::validate_artifacts` refuses to load such a stone
118/// with `active: true` in the first place.
119///
120/// A socketed-but-inactive stone is deliberately still *socketed*: the player
121/// keeps it, and the day its dependency ships it starts working with no
122/// migration.
123pub(crate) fn live_aspect_in_socket(
124    config: &GameConfig,
125    collection: &ArtifactCollection,
126    socket: ArtifactSocketSlot,
127) -> Option<AspectRuleInstance> {
128    let (template, level) = mech::socketed_aspect(config, collection, socket)?;
129    if !template.active {
130        return None;
131    }
132    let mut tier_magnitudes = [100.0; StoneTier::COUNT];
133    for tier in StoneTier::iter() {
134        tier_magnitudes[tier as usize] = template.tier_magnitude(tier);
135    }
136    Some(AspectRuleInstance {
137        rule: template.rule,
138        stone_id: template.id,
139        magnitude: template.magnitude_at_level(level),
140        secondary_magnitude: template.secondary_magnitude,
141        rule_param: template.rule_param,
142        tier_magnitudes,
143    })
144}
145
146/// Combat readings the flip/heartbeat rules need, taken in one immutable borrow.
147///
148/// Owns its enemy list rather than borrowing the fight: every caller reads first
149/// and mutates the fight afterwards. [`FightReadings::ctx`] hands out the
150/// borrowed, `Copy` view the effect runner wants.
151struct FightReadings {
152    player_id: EntityId,
153    target_id: Option<EntityId>,
154    enemy_ids: Vec<EntityId>,
155    player_attack: f64,
156    player_max_hp: u64,
157    player_speed: i64,
158    now: u64,
159    active_side: WorldSide,
160}
161
162impl FightReadings {
163    fn ctx(&self, source: CombatSource) -> StoneEffectCtx<'_> {
164        StoneEffectCtx {
165            source,
166            target_id: self.target_id,
167            enemy_ids: &self.enemy_ids,
168            player_attack: self.player_attack,
169            player_max_hp: self.player_max_hp,
170            player_speed: self.player_speed,
171            now: self.now,
172        }
173    }
174}
175
176impl OverlordLogic {
177    /// Wears one artifact. Exactly one is worn at a time, so this is also the
178    /// unequip path: the previously worn artifact keeps paying its ownership
179    /// bonus and loses only its World Law.
180    ///
181    /// Putting on `ART-03 Halfway Bell` moves the flip bar *under* charge the
182    /// player may already hold, so the persistent gauge is re-denominated here
183    /// against the new bar. Without it, the next gain would find an accumulator
184    /// past its threshold and zero it — an equip would silently burn up to a
185    /// full gauge.
186    pub fn handle_equip_artifact(
187        &self,
188        template_id: ArtifactTemplateId,
189        mut state: OverlordState,
190    ) -> EventHandleResult<OverlordEvent, OverlordState> {
191        // BAL-002/BAL-020: the artifact collection is server-denied before its
192        // ch21 gate, not just hidden by client navigation.
193        if state.character_state.character.current_chapter_level
194            < self.game_config.get().artifacts_settings.unlock_chapter
195        {
196            tracing::error!("EquipArtifact refused: artifacts are locked at this chapter");
197            return EventHandleResult::fail(state);
198        }
199        if let Err(err) = state.character_state.artifacts.equip(template_id) {
200            tracing::error!("EquipArtifact refused: {err}");
201            return EventHandleResult::fail(state);
202        }
203        let threshold =
204            mech::flip_threshold(&self.game_config.get(), &state.character_state.artifacts);
205        state.flip_state.retarget_progress(threshold);
206        EventHandleResult::ok(state)
207    }
208
209    /// Puts an artifact stone into one of the six sockets.
210    ///
211    /// Two gates, both from config: the stone's catalog entry names the only
212    /// socket it fits, and the unlock schedule says whether that socket is open
213    /// at the player's chapter. The right-hand (Law) column is scheduled past
214    /// the end of the campaign, so it is this same check — not a missing socket
215    /// — that keeps it shut.
216    pub fn handle_insert_artifact_stone(
217        &self,
218        template_id: ArtifactStoneTemplateId,
219        socket: ArtifactSocketSlot,
220        mut state: OverlordState,
221    ) -> EventHandleResult<OverlordEvent, OverlordState> {
222        let game_config = self.game_config.get();
223        let Some(template) = game_config
224            .artifact_stones
225            .iter()
226            .find(|entry| entry.id == template_id)
227        else {
228            tracing::error!("InsertArtifactStone: no catalog entry for {template_id}");
229            return EventHandleResult::fail(state);
230        };
231        let unlocked = game_config.artifacts_settings.is_socket_unlocked(
232            socket,
233            state.character_state.character.current_chapter_level,
234        );
235
236        if let Err(err) = state.character_state.artifacts.insert_stone(
237            template_id,
238            socket,
239            template.socket,
240            unlocked,
241        ) {
242            tracing::error!("InsertArtifactStone refused: {err}");
243            return EventHandleResult::fail(state);
244        }
245
246        EventHandleResult::ok(state)
247    }
248
249    /// Points a right-column stone at the law it acts on, or clears the choice.
250    ///
251    /// Three gates, and the third is the interesting one:
252    ///
253    /// * the stone is in the catalog and sits in a **Law** socket — an Aspect
254    ///   stone has no law to choose;
255    /// * the law is in the catalog;
256    /// * the law is **slotted on a core**. A law off the core does nothing, so
257    ///   pointing a stone at one would create a reference that has to be
258    ///   cleaned up later; refusing here plus clearing on `UnslotLaw` (the
259    ///   monolith's `unslot`) is what makes "no dangling reference" true from
260    ///   both ends.
261    ///
262    /// Deliberately NOT checked: that the law's side matches the socket. The
263    /// Law sockets are Visible / Hidden / Bridge, and visible-versus-hidden is a
264    /// PHASE, not a side — the same law is the visible one while its side is up
265    /// and the hidden one while it is down. Every slotted law is therefore a
266    /// legal target for every Law socket, and the half of the fight a stone
267    /// speaks in is decided by the flip (post-merge plan §8).
268    pub fn handle_set_artifact_stone_law_target(
269        &self,
270        template_id: ArtifactStoneTemplateId,
271        law_template_id: Option<essences::cores::LawTemplateId>,
272        mut state: OverlordState,
273    ) -> EventHandleResult<OverlordEvent, OverlordState> {
274        let game_config = self.game_config.get();
275        let Some(template) = game_config
276            .artifact_stones
277            .iter()
278            .find(|entry| entry.id == template_id)
279        else {
280            tracing::error!("SetArtifactStoneLawTarget: no catalog entry for {template_id}");
281            return EventHandleResult::fail(state);
282        };
283        let is_law_socket = template.socket.column() == ArtifactSocketColumn::Law;
284
285        let slotted = match law_template_id {
286            Some(law) => {
287                if crate::mechanics::cores::law_template(&game_config, law).is_none() {
288                    tracing::error!("SetArtifactStoneLawTarget: unknown law {law}");
289                    return EventHandleResult::fail(state);
290                }
291                state
292                    .character_state
293                    .cores
294                    .law(law)
295                    .is_some_and(|owned| owned.slot_index.is_some())
296            }
297            None => false,
298        };
299
300        if let Err(err) = state.character_state.artifacts.set_law_target(
301            template_id,
302            law_template_id,
303            is_law_socket,
304            slotted,
305        ) {
306            tracing::error!("SetArtifactStoneLawTarget refused: {err}");
307            return EventHandleResult::fail(state);
308        }
309        EventHandleResult::ok(state)
310    }
311
312    /// Takes a stone out of its socket. The stone keeps its level, its copies
313    /// and its chosen law, so re-socketing restores exactly what it was.
314    ///
315    /// Losing `BL-01 Extra Span` also gives back the bridge it paid for
316    /// ([`crate::mechanics::cores::prune_bridges_over_budget`]) — otherwise the
317    /// stone is a one-way ratchet. This is the artifact-side mirror of
318    /// `UnslotLaw` clearing the stone targets that pointed at the law it
319    /// unslotted: each side cleans up the other's dangling half.
320    pub fn handle_remove_artifact_stone(
321        &self,
322        template_id: ArtifactStoneTemplateId,
323        mut state: OverlordState,
324    ) -> EventHandleResult<OverlordEvent, OverlordState> {
325        if let Err(err) = state.character_state.artifacts.remove_stone(template_id) {
326            tracing::error!("RemoveArtifactStone refused: {err}");
327            return EventHandleResult::fail(state);
328        }
329        let game_config = self.game_config.get();
330        let extra =
331            mech::law_column_mods(&game_config, &state.character_state.artifacts).extra_bridges();
332        let dropped = crate::mechanics::cores::prune_bridges_over_budget(
333            &game_config,
334            &mut state.character_state.cores,
335            extra,
336        );
337        if dropped > 0 {
338            tracing::info!(
339                "RemoveArtifactStone {template_id}: dropped {dropped} bridge(s) left over the \
340                 budget"
341            );
342        }
343        EventHandleResult::ok(state)
344    }
345
346    /// Raises one artifact stone by one level, paid for in raw copies.
347    ///
348    /// The rule itself came whole with the first copy; a level only scales the
349    /// magnitude (design §3), which is why nothing here consults the rule.
350    pub fn handle_upgrade_artifact_stone(
351        &self,
352        template_id: ArtifactStoneTemplateId,
353        mut state: OverlordState,
354    ) -> EventHandleResult<OverlordEvent, OverlordState> {
355        let settings = &self.game_config.get().artifacts_settings;
356        let Some(stone) = state.character_state.artifacts.stone(template_id) else {
357            tracing::error!("UpgradeArtifactStone refused: unknown stone {template_id}");
358            return EventHandleResult::fail(state);
359        };
360        let next_level = stone.level + 1;
361        let Some(required) = settings.stone_upgrade_copies_required(next_level) else {
362            tracing::error!(
363                "UpgradeArtifactStone refused: no ladder rung for level {next_level} (stone is at \
364                 the cap or the config has a gap)"
365            );
366            return EventHandleResult::fail(state);
367        };
368
369        if let Err(err) = state.character_state.artifacts.upgrade_stone(
370            template_id,
371            required,
372            settings.max_stone_level,
373        ) {
374            tracing::error!("UpgradeArtifactStone refused: {err}");
375            return EventHandleResult::fail(state);
376        }
377
378        EventHandleResult::ok(state)
379    }
380
381    /// Raises one artifact by one level and spends the configured raw-copy rung.
382    ///
383    /// Ownership and cap are checked before consulting the GameConfig ladder;
384    /// the collection then atomically checks copies, spends them, and raises
385    /// the level. Every refusal returns the unmodified state.
386    pub fn handle_upgrade_artifact(
387        &self,
388        template_id: ArtifactTemplateId,
389        mut state: OverlordState,
390    ) -> EventHandleResult<OverlordEvent, OverlordState> {
391        let settings = &self.game_config.get().artifacts_settings;
392        // BAL-002/BAL-020: server-denied before the ch21 gate.
393        if state.character_state.character.current_chapter_level < settings.unlock_chapter {
394            tracing::error!("UpgradeArtifact refused: artifacts are locked at this chapter");
395            return EventHandleResult::fail(state);
396        }
397        let Some(artifact) = state.character_state.artifacts.get(template_id) else {
398            tracing::error!("UpgradeArtifact refused: unknown artifact {template_id}");
399            return EventHandleResult::fail(state);
400        };
401        if artifact.level >= settings.max_artifact_level {
402            tracing::error!(
403                "UpgradeArtifact refused: artifact {template_id} is already at max level {}",
404                settings.max_artifact_level
405            );
406            return EventHandleResult::fail(state);
407        }
408        let next_level = artifact.level + 1;
409        let Some(required) = settings.upgrade_copies_required(next_level) else {
410            tracing::error!(
411                "UpgradeArtifact refused: no ladder rung for level {next_level} (artifact is at \
412                 the cap or the config has a gap)"
413            );
414            return EventHandleResult::fail(state);
415        };
416
417        if let Err(err) = state.character_state.artifacts.upgrade_artifact(
418            template_id,
419            required,
420            settings.max_artifact_level,
421        ) {
422            tracing::error!("UpgradeArtifact refused: {err}");
423            return EventHandleResult::fail(state);
424        }
425
426        EventHandleResult::ok(state)
427    }
428
429    /// Banks granted artifacts and artifact stones.
430    ///
431    /// An artifact duplicate banks one copy, including at the level cap. A
432    /// stone duplicate likewise banks a copy. Neither grant path upgrades.
433    ///
434    /// A template that is not in the catalog is skipped and logged rather than
435    /// stored: a dangling id would sit in the collection forever with no name,
436    /// no rule and no way to remove it.
437    pub fn handle_player_new_artifacts(
438        &self,
439        artifacts: &[ArtifactTemplateId],
440        artifact_stones: &[ArtifactStoneTemplateId],
441        mut state: OverlordState,
442    ) -> EventHandleResult<OverlordEvent, OverlordState> {
443        let game_config = self.game_config.get();
444        for template_id in artifacts {
445            if !game_config
446                .artifacts
447                .iter()
448                .any(|entry| entry.id == *template_id)
449            {
450                tracing::warn!("Granted artifact {template_id} has no catalog entry — skipped");
451                continue;
452            }
453            state.character_state.artifacts.grant_artifact(*template_id);
454        }
455
456        for template_id in artifact_stones {
457            if !game_config
458                .artifact_stones
459                .iter()
460                .any(|entry| entry.id == *template_id)
461            {
462                tracing::warn!(
463                    "Granted artifact stone {template_id} has no catalog entry — skipped"
464                );
465                continue;
466            }
467            state.character_state.artifacts.grant_stone(*template_id);
468        }
469
470        EventHandleResult::ok(state)
471    }
472
473    /// BAL-031 faucet: one artifact stone from a campaign mob, a chapter boss
474    /// or a cleared dungeon (the caller supplies the leg's signed chance).
475    ///
476    /// Deliberately a different faucet from the artifacts themselves (design
477    /// §5), so the two collections never compete for one drop. The pick is
478    /// uniform over the stones of the socket families the player's chapter has
479    /// already made usable — before the first socket (ch91) the pool is empty
480    /// and nothing drops, after the last (ch191) it is all 29.
481    pub fn roll_artifact_stone_drop(
482        &self,
483        rng: &mut rand::rngs::StdRng,
484        chance: f64,
485        chapter_level: i64,
486    ) -> Option<EventPluginized<OverlordEvent, OverlordState>> {
487        if chance <= 0.0 {
488            return None;
489        }
490        let game_config = self.game_config.get();
491        let pool: Vec<_> = game_config
492            .artifact_stones
493            .iter()
494            .filter(|stone| {
495                game_config
496                    .artifacts_settings
497                    .is_socket_unlocked(stone.socket, chapter_level)
498            })
499            .map(|stone| stone.id)
500            .collect();
501        if pool.is_empty() {
502            return None;
503        }
504        if rand::RngExt::random::<f64>(rng) >= chance {
505            return None;
506        }
507        let index = rand::RngExt::random_range(rng, 0..pool.len());
508        let template_id = *pool.get(index)?;
509        Some(EventPluginized::now(OverlordEvent::PlayerNewArtifacts {
510            artifacts: Vec::new(),
511            artifact_stones: vec![template_id],
512        }))
513    }
514
515    /// BAL-031: the guaranteed compatible L1 stones the character's chapter
516    /// owes but the collection cannot serve. Each newly-usable socket family
517    /// (ch91/111/131/151/171/191) guarantees ONE stone of its family; a family
518    /// the player already owns any stone of needs nothing. Granting the result
519    /// converges, so the check is safe on every chapter advance and on connect.
520    pub fn missing_socket_artifact_stones(
521        game_config: &configs::game_config::GameConfig,
522        chapter: i64,
523        collection: &essences::artifacts::ArtifactCollection,
524    ) -> Vec<uuid::Uuid> {
525        let owned_sockets: std::collections::HashSet<_> = collection
526            .stones
527            .iter()
528            .filter_map(|stone| {
529                game_config
530                    .artifact_stones
531                    .iter()
532                    .find(|tpl| tpl.id == stone.template_id)
533                    .map(|tpl| tpl.socket)
534            })
535            .collect();
536        let mut missing: Vec<uuid::Uuid> = game_config
537            .artifacts_settings
538            .socket_unlocks
539            .iter()
540            .filter(|unlock| chapter >= unlock.unlock_chapter)
541            .filter(|unlock| !owned_sockets.contains(&unlock.socket))
542            .filter_map(|unlock| {
543                game_config
544                    .artifact_stones
545                    .iter()
546                    .find(|tpl| tpl.socket == unlock.socket && tpl.active)
547                    .map(|tpl| tpl.id)
548            })
549            .collect();
550        // Grant in template-id order — the same order `fetch_artifacts` loads
551        // the rows back in, so the granted in-memory collection and the next
552        // fetch stay byte-identical.
553        missing.sort();
554        missing
555    }
556
557    /// Readings the flip/heartbeat rules need. `None` when there is no live
558    /// fight — which is also the cheap gate that keeps this hook off the hot
559    /// path for every non-combat event.
560    fn read_fight_for_aspects(
561        &self,
562        state: &OverlordState,
563        owner_id: EntityId,
564    ) -> Option<FightReadings> {
565        let fight = state.active_fight.as_ref()?;
566        if fight.fight_ended || fight.fight_stopped {
567            return None;
568        }
569        let player_id = owner_id;
570        let player = fight.entities.iter().find(|e| e.id == player_id)?;
571        let game_config = self.game_config.get();
572
573        let active_side = player
574            .flip_state
575            .map_or_else(essences::flip::WorldSide::default, |flip| flip.active_side);
576
577        Some(FightReadings {
578            player_id,
579            target_id: crate::logic::stones::nearest_enemy(&fight.entities, player),
580            enemy_ids: crate::logic::stones::living_enemies(&fight.entities, player),
581            player_attack: get_entity_stat(self.behaviors.lookups(), player, "attack"),
582            player_max_hp: player.max_hp,
583            player_speed: player
584                .attributes
585                .speed_or_baseline(game_config.game_settings.baseline_speed),
586            now: self.fight_clock.now(),
587            active_side,
588        })
589    }
590
591    /// The two Aspect rules that hang off the fight rather than off a trigger:
592    /// «Теневой круг» on the heartbeat and the Flip pair on `GlobalFlip`.
593    ///
594    /// Runs from `apply_success_hooks`, like the stone triggers, for the same
595    /// reason: the events it reacts to are properties of the event, not of which
596    /// arm handled them.
597    ///
598    /// A build with no artifact leaves through the first early return having
599    /// touched no state at all.
600    pub fn apply_artifact_aspects(
601        &mut self,
602        state: &mut OverlordState,
603        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
604        event: &OverlordEvent,
605    ) {
606        let Some(fight) = state.active_fight.as_ref() else {
607            return;
608        };
609        // Every character combatant runs their OWN artifact: the local hero,
610        // the party ally, and a human PvP opponent. Mobs and arena filler bots
611        // carry no CharacterState and are skipped inside.
612        let mut combatants = vec![fight.player_id];
613        combatants.extend(fight.party_player_id);
614        if let Some(pvp) = &state.pvp_state {
615            let opponent_id = pvp.opponent_state.id();
616            if fight.entities.iter().any(|e| e.id == opponent_id) {
617                combatants.push(opponent_id);
618            }
619        }
620        for owner_id in combatants {
621            self.apply_artifact_aspects_for(state, events, event, owner_id);
622        }
623    }
624
625    /// One combatant's pass of [`Self::apply_artifact_aspects`].
626    fn apply_artifact_aspects_for(
627        &mut self,
628        state: &mut OverlordState,
629        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
630        event: &OverlordEvent,
631        owner_id: EntityId,
632    ) {
633        let Some(build) = crate::entities::combatant_character_state_of(state, owner_id) else {
634            return;
635        };
636        if build.artifacts.stones.is_empty() {
637            return;
638        }
639        match event {
640            OverlordEvent::FightProgress {} => self.run_shadow_circle(state, events, owner_id),
641            // Only the owner's own flip runs the owner's artifact.
642            OverlordEvent::GlobalFlip {
643                entity_id,
644                to_side,
645                revision,
646                ..
647            } if *entity_id == owner_id => {
648                self.run_flip_aspect(state, events, owner_id, *to_side, *revision)
649            }
650            _ => {}
651        }
652    }
653
654    /// «Теневой круг»: once every `rule_param` ticks, one hidden-side effect
655    /// fires by itself, walking the slots in order.
656    ///
657    /// The fire is gated on wall position (`now >= due`), never on a count of
658    /// heartbeats, and the next period is armed *before* the effect runs — the
659    /// same two invariants the interval trigger rests on, and for the same
660    /// reason: `FightProgress` is reachable from a Proc cascade.
661    fn run_shadow_circle(
662        &mut self,
663        state: &mut OverlordState,
664        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
665        owner_id: EntityId,
666    ) {
667        let game_config = self.game_config.get();
668        // Clones, not borrows: the loop below interleaves mutable fight
669        // borrows, and the build may live in `state.party` / `state.pvp_state`.
670        let Some(build) = crate::entities::combatant_character_state_of(state, owner_id) else {
671            return;
672        };
673        let stones = build.stones.clone();
674        let Some(aspect) = live_aspect_in_socket(
675            &game_config,
676            &build.artifacts,
677            ArtifactSocketSlot::HiddenAspect,
678        )
679        .filter(|aspect| aspect.rule == ArtifactStoneRule::ShadowRound) else {
680            return;
681        };
682        let Some(readings) = self.read_fight_for_aspects(state, owner_id) else {
683            return;
684        };
685
686        let now = self.fight_clock.now();
687        let period = aspect.rule_param.max(1);
688        let slots: Vec<ItemType> = ItemType::iter()
689            .filter(|t| t.supports_world_side())
690            .collect();
691        if slots.is_empty() {
692            return;
693        }
694
695        let (due, index) = {
696            let Some(player) = state
697                .active_fight
698                .as_ref()
699                .and_then(|fight| fight.entities.iter().find(|e| e.id == readings.player_id))
700            else {
701                return;
702            };
703            (
704                crate::mechanics::stones::attr(player, mech::SHADOW_CIRCLE_DUE),
705                crate::mechanics::stones::attr(player, mech::SHADOW_CIRCLE_INDEX),
706            )
707        };
708
709        // Not armed yet: this is the first heartbeat of the fight. Arm the
710        // period, do not fire on it — the same rule the interval trigger uses,
711        // so the circle cannot fire on tick 0 of every fight.
712        let armed = due != 0;
713        if armed && (now as i64) < due {
714            return;
715        }
716
717        let next_index = (index.rem_euclid(slots.len() as i64) + 1) % slots.len() as i64;
718        {
719            let Some(player) = state.active_fight.as_mut().and_then(|fight| {
720                fight
721                    .entities
722                    .iter_mut()
723                    .find(|e| e.id == readings.player_id)
724            }) else {
725                return;
726            };
727            player
728                .attributes
729                .set(mech::SHADOW_CIRCLE_DUE, now as i64 + period);
730            if armed {
731                player.attributes.set(mech::SHADOW_CIRCLE_INDEX, next_index);
732            }
733        }
734        if !armed {
735            return;
736        }
737
738        let slot = slots[index.rem_euclid(slots.len() as i64) as usize];
739        let hidden_side = readings.active_side.flipped();
740        let Some((template_id, level)) =
741            StoneSocketKey::new(slot, StoneSocketSlot::effect_for(hidden_side))
742                .and_then(|key| stones.socketed(key))
743                .map(|stone| (stone.template_id, stone.level))
744        else {
745            // The visited slot has no hidden effect — the circle moves on rather
746            // than searching, so its rate stays exactly one fire per period.
747            return;
748        };
749
750        self.fire_artifact_effect(
751            state,
752            events,
753            &readings,
754            ArtifactFire {
755                effect_template_id: template_id,
756                effect_level: level,
757                share: mech::share_multiplier(aspect.magnitude),
758                delay_ticks: 0,
759                artifact_stone_id: aspect.stone_id,
760            },
761        );
762    }
763
764    /// The three Flip-Aspect rules that land on the flip tick itself:
765    ///
766    /// * `FA-01 Incoming Salvo` — the five newly active effects, all at once;
767    /// * `FA-05 Staggered Entrance` — the same five, spaced by the stone's
768    ///   `rule_param`;
769    /// * `FA-02 Last Word` — the five *departing* effects instead, one last time.
770    ///
771    /// The first two are a side-grade pair (burst versus stream); the third is
772    /// their mirror in time — it pays the build that is being left behind rather
773    /// than the one arriving. The remaining two rules of the socket (`FA-03`,
774    /// `FA-04`) describe the ordinary procs *after* a flip and live in
775    /// [`crate::logic::stones`].
776    fn run_flip_aspect(
777        &mut self,
778        state: &mut OverlordState,
779        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
780        flipped_entity: EntityId,
781        to_side: WorldSide,
782        revision: u64,
783    ) {
784        let game_config = self.game_config.get();
785        // The flipping combatant's own build; the caller already matched the
786        // flip to its owner, so this can only be a character combatant.
787        let Some(build) = crate::entities::combatant_character_state_of(state, flipped_entity)
788        else {
789            return;
790        };
791        let stones = build.stones.clone();
792        let Some(aspect) = live_aspect_in_socket(
793            &game_config,
794            &build.artifacts,
795            ArtifactSocketSlot::FlipAspect,
796        ) else {
797            return;
798        };
799        // Which side speaks, and whether the fires land together or in order.
800        let (side, spacing) = match aspect.rule {
801            ArtifactStoneRule::IncomingSalvo => (to_side, 0),
802            ArtifactStoneRule::StaggeredEntrance => (to_side, aspect.rule_param.max(0) as u64),
803            ArtifactStoneRule::LastWord => (to_side.flipped(), 0),
804            _ => return,
805        };
806        let Some(readings) = self.read_fight_for_aspects(state, flipped_entity) else {
807            return;
808        };
809
810        // One volley per flip. `revision + 1` so an untouched attribute reads as
811        // "no volley yet" even at revision 0.
812        let this_flip = revision as i64 + 1;
813        {
814            let Some(player) = state.active_fight.as_mut().and_then(|fight| {
815                fight
816                    .entities
817                    .iter_mut()
818                    .find(|e| e.id == readings.player_id)
819            }) else {
820                return;
821            };
822            if crate::mechanics::stones::attr(player, mech::FLIP_ASPECT_REVISION) == this_flip {
823                return;
824            }
825            player.attributes.set(mech::FLIP_ASPECT_REVISION, this_flip);
826        }
827
828        let share = mech::share_multiplier(aspect.magnitude);
829        let fires: Vec<(ItemType, uuid::Uuid, i64)> = ItemType::iter()
830            .filter(|item_type| item_type.supports_world_side())
831            .filter_map(|item_type| {
832                StoneSocketKey::new(item_type, StoneSocketSlot::effect_for(side))
833                    .and_then(|key| stones.socketed(key))
834                    .map(|stone| (item_type, stone.template_id, stone.level))
835            })
836            .collect();
837
838        for (position, (_, template_id, level)) in fires.into_iter().enumerate() {
839            self.fire_artifact_effect(
840                state,
841                events,
842                &readings,
843                ArtifactFire {
844                    effect_template_id: template_id,
845                    effect_level: level,
846                    share,
847                    delay_ticks: spacing * position as u64,
848                    artifact_stone_id: aspect.stone_id,
849                },
850            );
851        }
852    }
853
854    /// Runs one Effect Stone as an artifact rebroadcast.
855    ///
856    /// Two things make this the single safe entry point for every artifact-fired
857    /// effect, and both are asserted by `tests/test_artifact_provenance.rs`:
858    ///
859    /// * it goes through `run_stone_effect`, so its combat output is `Proc` and
860    ///   can never re-enter a trigger;
861    /// * it **drops** the gauge contribution, so a rebroadcast cannot fill the
862    ///   gauge that would flip and rebroadcast again.
863    ///
864    /// The tier coefficient matrix is deliberately not consulted: there is no
865    /// trigger in this path, so there is no `(trigger tier × effect tier)` pair
866    /// to look up — the strength is the effect's own level scaling times the
867    /// rule's share.
868    fn fire_artifact_effect(
869        &mut self,
870        state: &mut OverlordState,
871        events: &mut Vec<EventPluginized<OverlordEvent, OverlordState>>,
872        readings: &FightReadings,
873        fire: ArtifactFire,
874    ) {
875        let ArtifactFire {
876            effect_template_id,
877            effect_level,
878            share,
879            delay_ticks,
880            artifact_stone_id,
881        } = fire;
882        if share <= 0.0 {
883            return;
884        }
885        // A spaced-out fire defers the whole run rather than the events it would
886        // emit. Under v0.2 arming is a direct write with no event to postpone,
887        // and deferring the run keeps `FA-05` a real side-grade of `FA-01`:
888        // effects half a second apart get spent between each other instead of
889        // overwriting one another inside a single tick.
890        if delay_ticks > 0 {
891            self.fight_clock.schedule(
892                OverlordEvent::FireArtifactEffect {
893                    owner_id: readings.player_id,
894                    effect_template_id,
895                    effect_level,
896                    share_permyriad: (share * SHARE_PERMYRIAD as f64).round() as i64,
897                    artifact_stone_id,
898                },
899                delay_ticks,
900            );
901            return;
902        }
903        let game_config = self.game_config.get();
904        let Some(effect) = game_config.effect_stone_template(effect_template_id) else {
905            tracing::warn!(
906                template_id = %effect_template_id,
907                "Skipping artifact-fired Effect Stone with no catalog entry"
908            );
909            return;
910        };
911        let magnitude = (effect.magnitude
912            + effect.magnitude_per_level * (effect_level - 1).max(0) as f64)
913            * share;
914
915        let _gauge_is_dropped_on_purpose = self.run_stone_effect(
916            state,
917            events,
918            readings.player_id,
919            effect,
920            magnitude,
921            readings.ctx(CombatSource::ArtifactStoneProc {
922                artifact_stone_template_id: artifact_stone_id,
923            }),
924        );
925    }
926
927    /// Runs the deferred half of `FA-05 Staggered Entrance`.
928    ///
929    /// The readings are taken **now**, not carried from the flip: the nearest
930    /// enemy half a second later may be a different one, and a fight that ended
931    /// in between leaves nothing to run against — in which case the event quietly
932    /// does nothing.
933    pub fn handle_fire_artifact_effect(
934        &mut self,
935        owner_id: EntityId,
936        effect_template_id: uuid::Uuid,
937        effect_level: i64,
938        share_permyriad: i64,
939        artifact_stone_id: ArtifactStoneTemplateId,
940        mut state: OverlordState,
941    ) -> EventHandleResult<OverlordEvent, OverlordState> {
942        let Some(readings) = self.read_fight_for_aspects(&state, owner_id) else {
943            return EventHandleResult::ok(state);
944        };
945        let mut events = Vec::new();
946        self.fire_artifact_effect(
947            &mut state,
948            &mut events,
949            &readings,
950            ArtifactFire {
951                effect_template_id,
952                effect_level,
953                share: share_permyriad as f64 / SHARE_PERMYRIAD as f64,
954                delay_ticks: 0,
955                artifact_stone_id,
956            },
957        );
958        EventHandleResult::ok_events(state, events)
959    }
960}