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}