overlord_event_system/mechanics/
artifacts.rs

1//! Artifact combat runtime — the pure half.
2//!
3//! The data lives in [`essences::artifacts`] and the knobs in
4//! [`configs::artifacts`]; this module is the arithmetic and the per-fight
5//! bookkeeping keys that turn them into behaviour. The orchestration — which
6//! event runs which rule — is [`crate::logic::artifacts`], and the Aspect rules
7//! that ride on a trigger fire are woven into [`crate::logic::stones`].
8//!
9//! Like the stone runtime, **all per-fight artifact state lives in the player
10//! entity's `attributes`**, namespaced `artifact.*`: it resets exactly when the
11//! fight's entities are rebuilt, needs no schema or Unity change, and is reached
12//! identically from the pure dispatch and from the monolith's merged arms. No
13//! attribute code is named `artifact`, so the namespace cannot collide with a
14//! stat (`crate::mechanics::fight::get_entity_stat` composes `<code>`,
15//! `<code>.bonus` and `<code>.mod`).
16
17use essences::artifacts::{ArtifactCollection, ArtifactSocketSlot};
18use essences::cores::{LawBridgeCharges, LawTemplateId};
19use essences::items::ItemType;
20
21use configs::artifacts::{ArtifactStoneRule, ArtifactStoneTemplate, ArtifactWorldLaw};
22use configs::game_config::GameConfig;
23
24/// Flip revision (plus one) at which this slot's effect last took the
25/// `VA-04 Opening Five` bonus. `0` means "not yet this fight" — hence the `+ 1`,
26/// so revision 0 is distinguishable from "never".
27pub fn opening_five_key(item_type: ItemType) -> String {
28    format!("artifact.of.{item_type}")
29}
30
31/// Flip revision (plus one) at which this slot's effect last took its
32/// `FA-03 Warm Start` repeat. Same shape as [`opening_five_key`], separate key:
33/// the two rules live in different sockets and are worn together.
34pub fn warm_start_key(item_type: ItemType) -> String {
35    format!("artifact.ws.{item_type}")
36}
37
38/// Flip revision (plus one) the `FA-04 Afterimage` counter belongs to. A new
39/// phase is a new count, and the marker is what tells the two apart.
40pub const AFTERIMAGE_REVISION: &str = "artifact.ai.rev";
41
42/// How many trigger procs `FA-04 Afterimage` has already accompanied in this
43/// phase.
44pub const AFTERIMAGE_COUNT: &str = "artifact.ai.n";
45
46/// Tick at which «Теневой круг» (`HA-02 Shadow Round`) next fires.
47pub const SHADOW_CIRCLE_DUE: &str = "artifact.sc.due";
48
49/// Which slot «Теневой круг» visits next; it walks the two-sided slots in order
50/// and wraps.
51pub const SHADOW_CIRCLE_INDEX: &str = "artifact.sc.idx";
52
53/// Flip revision (plus one) the Flip-Aspect rule last ran for. One flip runs one
54/// volley even if the `GlobalFlip` event is dispatched more than once.
55pub const FLIP_ASPECT_REVISION: &str = "artifact.flip.rev";
56
57/// Which slot resolved last, as [`slot_order_marker`]. The two slot-order rules
58/// (`VA-02 Alternator`, `VA-03 Focused Engine`) are defined against "the
59/// previous slot", and up to five slots answer one Core event, so the answer has
60/// to survive both the batch and the event boundary.
61pub const LAST_RESOLVED_SLOT: &str = "artifact.slot.last";
62
63/// How many times in a row the same slot has resolved, counting repeats only:
64/// `0` on a fresh slot, `1` on its first repeat. `VA-03` turns this into its
65/// stacking bonus.
66pub const SLOT_REPEAT_STREAK: &str = "artifact.slot.n";
67
68/// Position of `item_type` in the order the slot rules resolve in, plus one.
69///
70/// The order is `ItemType::iter()` filtered to the two-sided slots (Weapon →
71/// Torso → Head → Gloves → Shoulders) — the same one `HA-03 Cross Relay` relays
72/// along and the fire loop already walks, so "the next slot" and "the previous
73/// slot" mean the same thing to every rule. The `+ 1` keeps `0` meaning "no slot
74/// has resolved yet this fight", which is what an untouched attribute reads as.
75pub fn slot_order_marker(item_type: ItemType) -> i64 {
76    use strum::IntoEnumIterator;
77    ItemType::iter()
78        .filter(|slot| slot.supports_world_side())
79        .position(|slot| slot == item_type)
80        .map_or(0, |index| index as i64 + 1)
81}
82
83/// The World Law of the worn artifact, or `None` when nothing is worn.
84///
85/// Read from [`ArtifactCollection::equipped`] and nowhere else — that is the
86/// whole of "the law works only while the artifact is worn, and drops the
87/// moment another one is put on".
88pub fn equipped_world_law(
89    config: &GameConfig,
90    collection: &ArtifactCollection,
91) -> Option<ArtifactWorldLaw> {
92    let equipped = collection.equipped?;
93    config
94        .artifacts
95        .iter()
96        .find(|artifact| artifact.id == equipped)
97        .and_then(|artifact| artifact.world_law)
98}
99
100/// The stone in `socket` together with its upgrade level, or `None` when the
101/// socket is empty.
102///
103/// A stone whose template vanished from the config (content pulled from under a
104/// live collection) is skipped and logged, never fatal — same rule the stone
105/// runtime uses.
106pub fn socketed_aspect<'a>(
107    config: &'a GameConfig,
108    collection: &ArtifactCollection,
109    socket: ArtifactSocketSlot,
110) -> Option<(&'a ArtifactStoneTemplate, i64)> {
111    let stone = collection.socketed(socket)?;
112    let Some(template) = config
113        .artifact_stones
114        .iter()
115        .find(|entry| entry.id == stone.template_id)
116    else {
117        tracing::warn!(
118            template_id = %stone.template_id,
119            "Skipping socketed artifact stone with no catalog entry"
120        );
121        return None;
122    };
123    Some((template, stone.level))
124}
125
126/// The ownership bonus of a whole collection, as `("<code>.mod", permyriad)`
127/// pairs ready for `calculate_entity_stats_with_mods`.
128///
129/// Three rules, all of them design:
130///
131/// * it sums over **every owned artifact**, not just the worn one — an artifact
132///   in the collection pays exactly as much as an artifact on the character;
133/// * each artifact pays whatever ITS OWN `ownership_bonuses` list says, in any
134///   set of attributes. Two artifacts naming the same attribute add up on that
135///   attribute's key;
136/// * the artifact's level scales each stat by
137///   `percent + percent_per_level * (level - 1)`, so a duplicate that raised the
138///   level raises the bonus.
139///
140/// Keyed by the attribute's CODE, because `<code>.mod` is what combat
141/// (`fight::get_entity_stat`) and the power scalar
142/// (`balance::get_attr_from_attrs`) compose. `GameConfig::validate_artifacts`
143/// refuses a bonus on an attribute nothing composes, so every pair returned here
144/// reaches a real stat.
145///
146/// Percentages become permyriad because that is the unit `.mod` is read in
147/// (`+10000 == +1.0`): `1%` is `100`. Entries that round to zero are dropped —
148/// an empty collection, or one whose artifacts pay nothing, returns nothing.
149pub fn ownership_stat_mods(
150    config: &GameConfig,
151    collection: &ArtifactCollection,
152) -> Vec<(String, i64)> {
153    // BTreeMap, not HashMap: the pair order reaches an attribute map that tests
154    // and logs compare, and a stable order costs nothing here.
155    let mut percents: std::collections::BTreeMap<&str, f64> = std::collections::BTreeMap::new();
156
157    for artifact in &collection.artifacts {
158        let Some(template) = config
159            .artifacts
160            .iter()
161            .find(|entry| entry.id == artifact.template_id)
162        else {
163            tracing::warn!(
164                template_id = %artifact.template_id,
165                "Owned artifact has no catalog entry — its ownership bonus is skipped"
166            );
167            continue;
168        };
169
170        for bonus in &template.ownership_bonuses {
171            let Some(attribute) = config
172                .attributes
173                .iter()
174                .find(|attr| attr.id == bonus.attribute_id)
175            else {
176                tracing::warn!(
177                    template_id = %artifact.template_id,
178                    attribute_id = %bonus.attribute_id,
179                    "Artifact ownership bonus references an unknown attribute — skipped"
180                );
181                continue;
182            };
183            *percents.entry(attribute.code.as_str()).or_insert(0.0) +=
184                bonus.percent_at_level(artifact.level);
185        }
186    }
187
188    percents
189        .into_iter()
190        .filter_map(|(code, percent)| {
191            let permyriad = (percent * 100.0).round() as i64;
192            (permyriad != 0).then(|| (format!("{code}.mod"), permyriad))
193        })
194        .collect()
195}
196
197/// The flip threshold this collection actually plays against.
198///
199/// `ART-03 Halfway Bell` is the only artifact that moves it, and it moves it as
200/// a **share** of whatever `FlipSettings::progress_threshold` is configured — so
201/// a later rebalance of the threshold keeps meaning "at 60% of the bar". Every
202/// other collection (and every player with nothing worn) gets the configured
203/// threshold unchanged.
204///
205/// This is the single reader of the law: gauge accumulation, the `VA-01`
206/// ramp, the `HA-04` half-gauge gate and the equip-time re-denomination all call
207/// it, so there is exactly one definition of "the bar" per character.
208pub fn flip_threshold(config: &GameConfig, collection: &ArtifactCollection) -> f64 {
209    let base = config.flip_settings.progress_threshold;
210    let share = config
211        .artifacts_settings
212        .flip_threshold_share(equipped_world_law(config, collection));
213    base * share
214}
215
216/// How full the gauge is, in `[0, 1]`, against the bar this player plays
217/// against. A nonsense bar reads as empty rather than dividing by zero.
218pub fn gauge_fill(progress: f64, threshold: f64) -> f64 {
219    if !threshold.is_finite() || threshold <= 0.0 || !progress.is_finite() || progress <= 0.0 {
220        return 0.0;
221    }
222    (progress / threshold).clamp(0.0, 1.0)
223}
224
225/// Strength multiplier `VA-01 Crescendo` applies to an effect fired right now.
226///
227/// Ramps linearly from `floor_percent` on an empty gauge to `peak_percent` just
228/// before the flip (`85% → 130%`), so the rule pays a build that lets the gauge
229/// fill and *charges* one that flips the instant it can — both ends are the
230/// stone's own numbers, not a bonus bolted onto plain strength.
231pub fn crescendo_multiplier(
232    peak_percent: f64,
233    floor_percent: f64,
234    progress: f64,
235    threshold: f64,
236) -> f64 {
237    let filled = gauge_fill(progress, threshold);
238    let strength = floor_percent + (peak_percent - floor_percent) * filled;
239    (strength / 100.0).max(0.0)
240}
241
242/// Strength multiplier for a rule that states the effect's **total** strength as
243/// a percentage (`VA-04 Opening Five` at `150%`): `100` is plain.
244pub fn total_multiplier(magnitude_percent: f64) -> f64 {
245    (magnitude_percent / 100.0).max(0.0)
246}
247
248/// Share of full strength a rule that rebroadcasts an effect fires it at
249/// («Фоновый голос», «Теневой круг», the two Flip rules).
250pub fn share_multiplier(magnitude_percent: f64) -> f64 {
251    (magnitude_percent / 100.0).max(0.0)
252}
253
254/// A percentage stated as a DELTA (`+35`, `−15`) turned into a multiplier.
255/// Floored at zero: a rule may cancel a number, never invert its sign.
256fn delta_multiplier(percent: f64) -> f64 {
257    (1.0 + percent / 100.0).max(0.0)
258}
259
260// ---------------------------------------------------------------------------
261// The right (Law) column.
262// ---------------------------------------------------------------------------
263
264/// One socketed Law-column stone, flattened to the numbers its rule needs.
265///
266/// Flattened for the same reason as [`crate::logic::artifacts::AspectRuleInstance`]:
267/// the callers read this before mutating the fight, and an owned `Copy` value
268/// keeps the config borrow from outliving the read.
269#[derive(Clone, Copy, Debug, PartialEq)]
270pub struct LawRuleInstance {
271    pub rule: ArtifactStoneRule,
272    /// The rule's magnitude at the stone's current level, in percent.
273    pub magnitude: f64,
274    /// The rule's second percentage. Never scaled by level.
275    pub secondary_magnitude: f64,
276    /// The rule's own parameter (extra bridges for `BL-01`). Never scaled.
277    pub rule_param: i64,
278    /// The law the player pointed this stone at. Always `Some` for a rule that
279    /// [`ArtifactStoneRule::needs_law_target`] — a stone with no choice is
280    /// dropped at read time, so no rule site has to check.
281    pub target: Option<LawTemplateId>,
282}
283
284impl LawRuleInstance {
285    fn targets(&self, law_id: LawTemplateId) -> bool {
286        self.target == Some(law_id)
287    }
288}
289
290/// How a hidden law may be woken on this event, by whichever source offers the
291/// most (design §3.3: the largest available multiplier, never the sum).
292///
293/// Two numbers rather than one because the strongest source may be spent:
294/// `HL-04 Single Lesson` offers `100%` but only once per phase, and once its
295/// latch is taken the law must fall back to whatever is still free rather than
296/// stop waking. The caller claims the latch only when it actually uses
297/// `once_share`.
298#[derive(Clone, Copy, Debug, Default, PartialEq)]
299pub struct HiddenWake {
300    /// Largest share offered by a source with no per-phase limit.
301    pub free_share: f64,
302    /// Share offered by `HL-04`, or `0.0` when it is not offering one here.
303    pub once_share: f64,
304}
305
306impl HiddenWake {
307    pub fn is_silent(&self) -> bool {
308        self.free_share <= 0.0 && self.once_share <= 0.0
309    }
310}
311
312/// Everything the right column does to the law runtime, resolved in ONE read.
313///
314/// One value, three sockets plus the World Law — read once per hook, exactly
315/// like `live_aspect_in_socket` does for the left column. The all-neutral
316/// [`LawColumnMods::NONE`] is what a player with no artifact gets, and every
317/// method below answers `1.0` / `None` / the untouched base for it, so that
318/// player's path through `logic::laws` is byte-identical to the one before this
319/// feature.
320#[derive(Clone, Copy, Debug, Default, PartialEq)]
321pub struct LawColumnMods {
322    visible: Option<LawRuleInstance>,
323    hidden: Option<LawRuleInstance>,
324    bridge: Option<LawRuleInstance>,
325    /// `ART-02 Thin Mirror`'s share, or `0.0` when it is not worn.
326    thin_mirror_share: f64,
327}
328
329impl LawColumnMods {
330    /// A player with no artifact, no Law stones and no World Law.
331    pub const NONE: Self = Self {
332        visible: None,
333        hidden: None,
334        bridge: None,
335        thin_mirror_share: 0.0,
336    };
337
338    /// Whether nothing here can change anything. The hot hooks check this first.
339    pub fn is_inert(&self) -> bool {
340        *self == Self::NONE
341    }
342
343    /// Whether any source can wake a law of the side that is down. Gating the
344    /// whole hidden-evaluation path on this is what keeps hidden counters from
345    /// advancing for a player who has no way to use them.
346    pub fn wakes_hidden_laws(&self) -> bool {
347        self.thin_mirror_share > 0.0
348            || self
349                .hidden
350                .is_some_and(|stone| stone.rule.wakes_hidden_law())
351    }
352
353    /// Multiplier on the numbers of `law_id`'s effect, from the Visible Law
354    /// socket alone. Bridge amplification is priced separately and multiplies
355    /// on top.
356    ///
357    /// `charges` and `capacity` are needed by `VL-04 Full Confidence`, whose
358    /// gate is "the chosen law has filled its outgoing bridge". The charge only
359    /// grows inside a phase and the flip zeroes it, so reading it live is
360    /// already "until the Flip" — no latch needed.
361    pub fn effect_multiplier(
362        &self,
363        law_id: LawTemplateId,
364        charges: &LawBridgeCharges,
365        capacity_hundredths: i64,
366    ) -> f64 {
367        let Some(stone) = self.visible.filter(|stone| stone.targets(law_id)) else {
368            return 1.0;
369        };
370        match stone.rule {
371            // The Resonance-for-Effect trades: the effect side is the SECOND
372            // number and it is negative.
373            ArtifactStoneRule::SourceFocus | ArtifactStoneRule::ExportPower => {
374                delta_multiplier(stone.secondary_magnitude)
375            }
376            // The mirror trade: the effect side is the first number.
377            ArtifactStoneRule::CurrentPower => delta_multiplier(stone.magnitude),
378            ArtifactStoneRule::FullConfidence
379                if charges.pending_from(law_id) >= capacity_hundredths =>
380            {
381                delta_multiplier(stone.magnitude)
382            }
383            _ => 1.0,
384        }
385    }
386
387    /// Multiplier on `law_id`'s Resonance. The Visible Law and Bridge Law
388    /// sockets are different sockets, so a build may wear one of each and both
389    /// apply.
390    pub fn resonance_multiplier(&self, law_id: LawTemplateId, charges: &LawBridgeCharges) -> f64 {
391        let mut multiplier = 1.0;
392        if let Some(stone) = self.visible.filter(|stone| stone.targets(law_id)) {
393            multiplier *= match stone.rule {
394                ArtifactStoneRule::SourceFocus | ArtifactStoneRule::ExportPower => {
395                    delta_multiplier(stone.magnitude)
396                }
397                ArtifactStoneRule::CurrentPower => delta_multiplier(stone.secondary_magnitude),
398                _ => 1.0,
399            };
400        }
401        // `BL-01 Extra Span` pays the whole build, not one chosen law: every
402        // bridge banks more Resonance per proc. Its rank raises that gain
403        // instead of raising capacity, so a wider build never makes a single
404        // unit of Resonance worth less (BAL-028).
405        if let Some(stone) = self
406            .bridge
407            .filter(|stone| stone.rule == ArtifactStoneRule::ExtraSpan)
408        {
409            multiplier *= delta_multiplier(stone.magnitude);
410        }
411        // `BL-05 Reciprocal Gate` addresses a BRIDGE through one of its laws,
412        // and pays BOTH directions — so any law sharing a bridge with the
413        // chosen one is covered, including the chosen one itself.
414        if let Some(stone) = self
415            .bridge
416            .filter(|stone| stone.rule == ArtifactStoneRule::ReciprocalGate)
417            && let Some(target) = stone.target
418            && charges
419                .bridges
420                .iter()
421                .any(|bridge| bridge.contains(target) && bridge.contains(law_id))
422        {
423            multiplier *= delta_multiplier(stone.magnitude);
424        }
425        multiplier
426    }
427
428    /// `VL-05 First Article`: the share the FIRST activation of every
429    /// active-side law in a phase applies its effect at, or `None` when the
430    /// stone is not worn. Resonance is deliberately untouched — without that
431    /// clause the stone would also speed up bridges and simply be stronger than
432    /// its socket neighbours instead of different.
433    pub fn first_activation_multiplier(&self) -> Option<f64> {
434        self.visible
435            .filter(|stone| stone.rule == ArtifactStoneRule::FirstArticle)
436            .map(|stone| total_multiplier(stone.magnitude))
437    }
438
439    /// Bridge capacity after the Bridge Law socket, in the same units as the
440    /// base. Floored at one unit: a stone may shorten a bridge, never delete it.
441    ///
442    /// `BL-01 Extra Span` is deliberately absent: it buys an extra bridge and a
443    /// richer Resonance gain, and leaves capacity at the base (BAL-028).
444    pub fn bridge_capacity(&self, base: i64) -> i64 {
445        let Some(stone) = self.bridge else {
446            return base;
447        };
448        let scaled = match stone.rule {
449            ArtifactStoneRule::DeepSpan | ArtifactStoneRule::ShortSpan => {
450                base as f64 * delta_multiplier(stone.magnitude)
451            }
452            _ => return base,
453        };
454        (scaled.round() as i64).max(1)
455    }
456
457    /// Recipient amplification cap after the Bridge Law socket, in permyriad.
458    ///
459    /// `BL-02 Deep Span` moves capacity and ceiling on the SAME rank ladder, so
460    /// each rank keeps the value of one unit of Resonance while raising the
461    /// ceiling (BAL-028); `BL-03 Short Span` keeps its own authored trade in
462    /// `secondary_magnitude`.
463    pub fn bridge_amplification_cap(&self, base: i64) -> i64 {
464        let Some(stone) = self.bridge else {
465            return base;
466        };
467        let scaled = match stone.rule {
468            ArtifactStoneRule::DeepSpan => base as f64 * delta_multiplier(stone.magnitude),
469            ArtifactStoneRule::ShortSpan => {
470                base as f64 * delta_multiplier(stone.secondary_magnitude)
471            }
472            _ => return base,
473        };
474        (scaled.round() as i64).max(0)
475    }
476
477    /// Extra bridges allowed over the `min(core level) − 1` budget (`BL-01`).
478    pub fn extra_bridges(&self) -> i64 {
479        self.bridge
480            .filter(|stone| stone.rule == ArtifactStoneRule::ExtraSpan)
481            .map_or(0, |stone| stone.rule_param.max(0))
482    }
483
484    /// The strongest wake available to `law_id`, a law of the side that is
485    /// currently DOWN.
486    ///
487    /// Design §3.3: several sources may name one law, and the answer is the
488    /// LARGEST multiplier, never the sum. The two shares are kept apart so a
489    /// spent once-per-phase source falls back instead of silencing the law.
490    pub fn hidden_wake(
491        &self,
492        law_id: LawTemplateId,
493        gauge_fill: f64,
494        charges: &LawBridgeCharges,
495        capacity_hundredths: i64,
496    ) -> HiddenWake {
497        let mut wake = HiddenWake {
498            // `ART-02` names every hidden law at once and has no limit.
499            free_share: self.thin_mirror_share,
500            once_share: 0.0,
501        };
502        let Some(stone) = self.hidden else {
503            return wake;
504        };
505        match stone.rule {
506            ArtifactStoneRule::ProxyReader if stone.targets(law_id) => {
507                wake.free_share = wake.free_share.max(share_multiplier(stone.magnitude));
508            }
509            ArtifactStoneRule::LateAwakening
510                if stone.targets(law_id) && gauge_fill >= stone.secondary_magnitude / 100.0 =>
511            {
512                wake.free_share = wake.free_share.max(share_multiplier(stone.magnitude));
513            }
514            ArtifactStoneRule::SingleLesson if stone.targets(law_id) => {
515                wake.once_share = share_multiplier(stone.magnitude);
516            }
517            // `HL-05` names a BRIDGE through one of its laws and pays the end
518            // that is currently down — which is `law_id` whenever it shares the
519            // chosen law's bridge and the charge flowing toward it is full.
520            ArtifactStoneRule::ReadyTarget => {
521                if let Some(target) = stone.target
522                    && let Some(bridge) = charges
523                        .bridges
524                        .iter()
525                        .find(|bridge| bridge.contains(target) && bridge.contains(law_id))
526                    && let Some(source) = bridge.other_end(law_id)
527                    && charges.pending_from(source) >= capacity_hundredths
528                {
529                    wake.free_share = wake.free_share.max(share_multiplier(stone.magnitude));
530                }
531            }
532            _ => {}
533        }
534        wake
535    }
536
537    /// `HL-02 Linked Echo`: the law at the far end of the chosen law's bridge,
538    /// and the share it applies its effect at, when `fired_law` is the chosen
539    /// one.
540    ///
541    /// The only wake that does NOT evaluate the woken law's own condition —
542    /// that is the whole rule, and it is why it lives outside
543    /// [`Self::hidden_wake`].
544    pub fn linked_echo(
545        &self,
546        fired_law: LawTemplateId,
547        charges: &LawBridgeCharges,
548    ) -> Option<(LawTemplateId, f64)> {
549        let stone = self
550            .hidden
551            .filter(|stone| stone.rule == ArtifactStoneRule::LinkedEcho)?;
552        if !stone.targets(fired_law) {
553            return None;
554        }
555        let partner = charges
556            .bridges
557            .iter()
558            .find(|bridge| bridge.contains(fired_law))?
559            .other_end(fired_law)?;
560        Some((partner, share_multiplier(stone.magnitude)))
561    }
562}
563
564/// Reads the three Law sockets and the worn World Law into one value.
565///
566/// A stone is dropped here, once, when it cannot do anything: its template is
567/// gone from the catalog, it ships `active: false`, or its rule needs a chosen
568/// law and the player has not chosen one. That last case is the whole of "a
569/// stone with no target does nothing" — no rule site repeats the check.
570pub fn law_column_mods(config: &GameConfig, collection: &ArtifactCollection) -> LawColumnMods {
571    if collection.stones.is_empty() && collection.equipped.is_none() {
572        return LawColumnMods::NONE;
573    }
574    let read = |socket: ArtifactSocketSlot| -> Option<LawRuleInstance> {
575        let (template, level) = socketed_aspect(config, collection, socket)?;
576        if !template.active {
577            return None;
578        }
579        let target = collection
580            .socketed(socket)
581            .and_then(|stone| stone.law_target);
582        if template.rule.needs_law_target() && target.is_none() {
583            return None;
584        }
585        Some(LawRuleInstance {
586            rule: template.rule,
587            magnitude: template.magnitude_at_level(level),
588            secondary_magnitude: template.secondary_magnitude,
589            rule_param: template.rule_param,
590            target,
591        })
592    };
593
594    LawColumnMods {
595        visible: read(ArtifactSocketSlot::VisibleLaw),
596        hidden: read(ArtifactSocketSlot::HiddenLaw),
597        bridge: read(ArtifactSocketSlot::BridgeLaw),
598        thin_mirror_share: config
599            .artifacts_settings
600            .thin_mirror_share(equipped_world_law(config, collection)),
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn bookkeeping_keys_are_per_slot_and_namespaced() {
610        assert_ne!(
611            opening_five_key(ItemType::Weapon),
612            opening_five_key(ItemType::Gloves),
613            "each slot tracks its own phase"
614        );
615        assert_ne!(
616            opening_five_key(ItemType::Weapon),
617            warm_start_key(ItemType::Weapon),
618            "two rules worn together must not share one marker"
619        );
620        for key in [
621            opening_five_key(ItemType::Weapon),
622            warm_start_key(ItemType::Weapon),
623            SHADOW_CIRCLE_DUE.to_string(),
624            SHADOW_CIRCLE_INDEX.to_string(),
625            FLIP_ASPECT_REVISION.to_string(),
626            AFTERIMAGE_REVISION.to_string(),
627            AFTERIMAGE_COUNT.to_string(),
628            LAST_RESOLVED_SLOT.to_string(),
629            SLOT_REPEAT_STREAK.to_string(),
630        ] {
631            assert!(
632                key.starts_with("artifact."),
633                "{key} may collide with a stat"
634            );
635        }
636    }
637
638    /// The slot order the two slot-rules resolve in: dense, one-based, and the
639    /// same `ItemType::iter()` order `HA-03 Cross Relay` relays along. `0` is
640    /// reserved for "nothing has resolved yet", which is what an untouched
641    /// attribute reads as — so no real slot may take it.
642    #[test]
643    fn the_slot_order_is_one_based_and_gapless() {
644        use strum::IntoEnumIterator;
645        let markers: Vec<i64> = ItemType::iter()
646            .filter(|slot| slot.supports_world_side())
647            .map(slot_order_marker)
648            .collect();
649        assert_eq!(markers, (1..=markers.len() as i64).collect::<Vec<_>>());
650        for slot in ItemType::iter().filter(|slot| !slot.supports_world_side()) {
651            assert_eq!(
652                slot_order_marker(slot),
653                0,
654                "{slot:?} has no two-sided socket and must not occupy a position"
655            );
656        }
657    }
658
659    /// `VA-01` is a ramp between two of its own numbers: below plain strength on
660    /// an empty gauge, above it at the bar.
661    #[test]
662    fn crescendo_ramps_between_its_floor_and_its_peak() {
663        assert_eq!(crescendo_multiplier(130.0, 85.0, 0.0, 1_000.0), 0.85);
664        assert!(
665            (crescendo_multiplier(130.0, 85.0, 500.0, 1_000.0) - 1.075).abs() < 1e-9,
666            "halfway is halfway between the two ends"
667        );
668        assert!((crescendo_multiplier(130.0, 85.0, 1_000.0, 1_000.0) - 1.3).abs() < 1e-9);
669        // Past the bar the gauge has already flipped; clamp rather than
670        // extrapolate.
671        assert!((crescendo_multiplier(130.0, 85.0, 5_000.0, 1_000.0) - 1.3).abs() < 1e-9);
672    }
673
674    #[test]
675    fn a_broken_threshold_reads_as_an_empty_gauge() {
676        assert_eq!(gauge_fill(100.0, 0.0), 0.0);
677        assert_eq!(gauge_fill(f64::NAN, 1_000.0), 0.0);
678        assert_eq!(
679            crescendo_multiplier(130.0, 85.0, 100.0, 0.0),
680            0.85,
681            "an unreadable bar is the empty-gauge end of the ramp, never a guess"
682        );
683    }
684
685    #[test]
686    fn the_strength_multipliers_read_their_percentages() {
687        assert_eq!(total_multiplier(150.0), 1.5);
688        assert_eq!(total_multiplier(100.0), 1.0);
689        assert_eq!(total_multiplier(-5.0), 0.0);
690        assert_eq!(share_multiplier(30.0), 0.3);
691        assert_eq!(share_multiplier(-5.0), 0.0);
692    }
693}