essences/
cores.rs

1//! Twin cores, laws and bridges (OVT-2517), v0.2.
2//!
3//! Two cores — one per [`WorldSide`] — carry a level each. The level product
4//! drives a stat multiplier (see `configs::cores::CoresSettings`) and each
5//! core's own level drives how many law slots it offers.
6//!
7//! A law in v0.2 is `condition (Core event) → effect + Resonance into the
8//! bridge`:
9//!
10//! * the **condition** is an existing Core combat occurrence or an already
11//!   computed property of a cast ("every 5th Basic Attack", "Core Dodge",
12//!   "original Skill touched ≥3 targets");
13//! * the **effect** is ONE action — AoE damage, a cooldown cut, turning the
14//!   next attack into an AoE, a derived skill repeat, a timed stat buff. It
15//!   carries exactly one number ([`LawTemplate::effect_value`]); the old
16//!   `enhanced_value` twin is gone;
17//! * the **Resonance** is what the law puts into its bridge per fire.
18//!
19//! Only the laws of the ACTIVE side work — a hidden law's condition is not even
20//! evaluated.
21//!
22//! A bridge is bidirectional and carries two independent charges, one per
23//! direction ([`BridgeCharge`]). A phase's fires fill the charge that flows
24//! AWAY from the active side; the flip delivers it to the receiving law, which
25//! then runs amplified for the whole next phase. Amplification multiplies the
26//! effect's numbers only — never the condition, never the Resonance, which is
27//! what makes self-amplification inexpressible rather than merely bounded.
28//!
29//! This module holds the durable player-side shapes, the per-fight charge state
30//! and the config template for one law; the arithmetic (slots, multiplier,
31//! bridge limits, charge delivery) lives in
32//! `overlord_event_system::mechanics::cores` and the combat runtime in
33//! `overlord_event_system::logic::laws`.
34
35use crate::abilities::AbilityTag;
36use crate::flip::WorldSide;
37use crate::items::AttributeId;
38use crate::prelude::*;
39
40#[declare]
41pub type LawTemplateId = Uuid;
42
43/// What makes a law fire. Every variant is a Core occurrence or a property of
44/// one — nothing a law (or any other modifier) produced can satisfy any of
45/// them, which is the anti-loop rule of the design (plan §8).
46///
47/// The numeric parameter of a condition lives in
48/// [`LawTemplate::condition_value`] and its tag in
49/// [`LawTemplate::condition_tag`], so retuning "every 5th" to "every 4th" is a
50/// config edit.
51///
52/// * `NthBasicAttack` — every `condition_value`-th Core Basic Attack.
53/// * `OriginalSkillCast` — one Core cast of a non-basic ability.
54/// * `CriticalHit` — a Core critical hit landed by the owner.
55/// * `Dodge` — a Core evasion by the owner.
56/// * `NthHitTaken` — every `condition_value`-th Core hit landed on the owner.
57/// * `SkillWithTag` — a Core original Skill carrying `condition_tag` resolved.
58/// * `SkillMinTargets` — a Core original Skill that damaged at least
59///   `condition_value` distinct targets.
60/// * `SkillManaAtMost` / `SkillManaAtLeast` — per-cast mana cost bounds. Mana
61///   does not exist on this branch, so laws using these ship INACTIVE
62///   ([`LawTemplate::is_active`]) and are never evaluated.
63/// * `KilledEnemy` — a Core action of the owner killed an enemy.
64/// * `HpFellBelow` — the owner's HP crossed `condition_value` permyriad of max
65///   HP downwards.
66/// * `PhaseStarted` — the owner's own side just came up.
67/// * `DistinctSkillsWithin` — `condition_value` DIFFERENT original Skills
68///   resolved inside [`LawTemplate::condition_window_ticks`].
69///
70/// Variant docs live on the enum rather than on the variants so the admin
71/// schema generates a plain picker instead of a `oneOf` of one-value enums.
72#[derive(
73    Clone,
74    Copy,
75    Debug,
76    Default,
77    Serialize,
78    Deserialize,
79    PartialEq,
80    Eq,
81    Hash,
82    JsonSchema,
83    Tsify,
84    strum_macros::Display,
85    strum_macros::EnumString,
86)]
87#[tsify(from_wasm_abi, into_wasm_abi)]
88pub enum LawCondition {
89    #[default]
90    NthBasicAttack,
91    OriginalSkillCast,
92    CriticalHit,
93    Dodge,
94    NthHitTaken,
95    SkillWithTag,
96    SkillMinTargets,
97    SkillManaAtMost,
98    SkillManaAtLeast,
99    KilledEnemy,
100    HpFellBelow,
101    PhaseStarted,
102    DistinctSkillsWithin,
103}
104
105impl LawCondition {
106    /// Whether this condition prices the cast off the per-cast mana cost the
107    /// pool actually charged. Implemented since the mana wiring — kept as the
108    /// explicit list of which conditions read the pool.
109    pub const fn needs_mana(self) -> bool {
110        matches!(self, Self::SkillManaAtMost | Self::SkillManaAtLeast)
111    }
112
113    /// Whether this condition is decided BEFORE the triggering cast resolves.
114    /// Those laws are evaluated from the cast entry point so an effect that
115    /// changes the cast itself (`RL-01` "this strike deals +100%") lands on the
116    /// strike that triggered it.
117    pub const fn is_pre_cast(self) -> bool {
118        matches!(
119            self,
120            Self::NthBasicAttack
121                | Self::OriginalSkillCast
122                | Self::SkillWithTag
123                | Self::SkillManaAtMost
124                | Self::SkillManaAtLeast
125                | Self::DistinctSkillsWithin
126        )
127    }
128}
129
130/// What a law does when it fires. One action, one number
131/// ([`LawTemplate::effect_value`]) plus an optional duration.
132///
133/// * `ThisAttackDamageBonus` — the strike that triggered the law deals
134///   `+value%` damage.
135/// * `NextAttackDamageBonus` — the owner's next Basic Attack deals `+value%`.
136/// * `NextAttackSplash` — the owner's next Basic Attack additionally strikes
137///   every OTHER living enemy for `value%` of Attack.
138/// * `ReduceLongestCooldown` — the largest remaining skill cooldown drops by
139///   `value` ticks.
140/// * `ReduceAllCooldowns` — every remaining skill cooldown drops by `value`
141///   ticks.
142/// * `ReadyShortestCooldown` — the skill with the smallest remaining cooldown
143///   becomes ready now (`value` unused).
144/// * `HealPercentMaxHp` — restore `value%` of max HP.
145/// * `DamageBuff` — `+value%` damage for `effect_duration_ticks`.
146/// * `AttackSpeedBuff` — `+value%` Basic Attack speed for
147///   `effect_duration_ticks`.
148/// * `DamageReduction` — `-value%` incoming damage for
149///   `effect_duration_ticks`.
150/// * `AoeDerivedDamage` — derived damage worth `value%` of Attack to every
151///   living enemy.
152/// * `NextSkillPayloadBonus` — the owner's next original Skill deals
153///   `+value%`.
154/// * `NextSkillEcho` — the owner's next original Skill echoes: its target
155///   additionally takes `value%` of Attack as derived damage.
156///
157/// IMPLEMENTER'S NOTE on `NextSkillEcho` (`FL-06`, `FL-12`). The catalog words
158/// it "the next original Skill gets a derived repeat at `value%`". A fractional
159/// re-cast is not expressible on this branch: ability scripts take a level, not
160/// a power scalar, so a "40% repeat" would have to be either a full second cast
161/// or nothing. The echo is the honest form of the same beat — a derived,
162/// non-Core strike worth `value%` of Attack landing with the skill — and it
163/// keeps the anti-loop trivial, because nothing re-enters the cast path. When
164/// abilities grow a power scalar this becomes a real scaled repeat without a
165/// content change.
166#[derive(
167    Clone,
168    Copy,
169    Debug,
170    Default,
171    Serialize,
172    Deserialize,
173    PartialEq,
174    Eq,
175    Hash,
176    JsonSchema,
177    Tsify,
178    strum_macros::Display,
179    strum_macros::EnumString,
180)]
181#[tsify(from_wasm_abi, into_wasm_abi)]
182pub enum LawEffect {
183    #[default]
184    ThisAttackDamageBonus,
185    NextAttackDamageBonus,
186    NextAttackSplash,
187    ReduceLongestCooldown,
188    ReduceAllCooldowns,
189    ReadyShortestCooldown,
190    HealPercentMaxHp,
191    DamageBuff,
192    AttackSpeedBuff,
193    DamageReduction,
194    AoeDerivedDamage,
195    NextSkillPayloadBonus,
196    NextSkillEcho,
197    /// BAL-029 `Open with Magic`: cuts the SHORTEST remaining Skill cooldown
198    /// by `effect_value` ticks, capped at ready. New variants append at the
199    /// tail.
200    ReduceShortestCooldown,
201}
202
203impl LawEffect {
204    /// Whether the effect leaves a timed state behind and therefore needs a
205    /// non-zero [`LawTemplate::effect_duration_ticks`].
206    pub const fn is_timed(self) -> bool {
207        matches!(
208            self,
209            Self::DamageBuff | Self::AttackSpeedBuff | Self::DamageReduction
210        )
211    }
212}
213
214/// One attribute the law moves while its side is up. This is the law's PASSIVE
215/// half; the active half is [`LawTemplate::effect`].
216///
217/// One number per modifier: the v0.1 `enhanced_value` twin is gone. Bridge
218/// amplification is a percentage multiplier applied on top of this value, so a
219/// law level and a bridge can never drift apart the way two authored numbers
220/// could.
221///
222/// Values are in the attribute's own native units — the same units
223/// `class_levels`, talents and statue grades use (`crit_chance` and every
224/// `*.mod` are permyriad: `1500` = +15%, `10000` = ×1.0).
225#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
226pub struct LawAttributeModifier {
227    #[schemars(title = "Атрибут", schema_with = "attribute_link_id_schema")]
228    pub attribute_id: AttributeId,
229
230    #[schemars(title = "Базовое значение")]
231    pub base_value: i64,
232}
233
234/// A law as authored in config.
235///
236/// Not `Eq`: the Power coefficients are floats (BAL-030).
237#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Tsify)]
238pub struct LawTemplate {
239    #[schemars(schema_with = "id_schema")]
240    pub id: LawTemplateId,
241
242    #[schemars(title = "Название")]
243    pub name: i18n::I18nString,
244
245    #[schemars(title = "Описание")]
246    pub description: i18n::I18nString,
247
248    #[schemars(
249        title = "Сторона",
250        description = "Закон ставится только в слот ядра своей стороны и работает только пока эта сторона активна."
251    )]
252    pub side: WorldSide,
253
254    /// Deterministic unlock: raising the core of [`Self::side`] to this level
255    /// grants the law outright, at level 1 with no copies. Random law-copy
256    /// drops stay what they always were — copy income for LEVELLING an owned
257    /// law, never the unlock path.
258    ///
259    /// `GameConfig::validate_cores_settings` requires every law to be reachable
260    /// by `CoresSettings::max_slot_level()`, so "both cores at the last
261    /// slot-opening level" always means "every law owned".
262    #[schemars(
263        title = "Уровень ядра, на котором закон выдаётся",
264        description = "Прокачка ядра своей стороны до этого уровня выдаёт закон детерминированно (1 уровень, 0 копий). Должен укладываться в уровень, на котором открыт последний слот."
265    )]
266    pub unlock_core_level: i64,
267
268    #[schemars(
269        title = "Условие",
270        description = "Core-событие, на котором закон срабатывает. Условие скрытого закона не считается."
271    )]
272    pub condition: LawCondition,
273
274    #[schemars(
275        title = "Параметр условия",
276        description = "N для «каждый N-й», минимум целей, порог HP в перимириадах. 0 если условию параметр не нужен."
277    )]
278    pub condition_value: i64,
279
280    #[schemars(
281        title = "Тег абилки в условии",
282        description = "Только для условия «Original Skill с тегом»; иначе пусто."
283    )]
284    pub condition_tag: Option<AbilityTag>,
285
286    #[schemars(
287        title = "Окно условия, тики",
288        description = "Только для условия «N разных Skill за время»; иначе 0."
289    )]
290    pub condition_window_ticks: i64,
291
292    #[schemars(
293        title = "Раз за фазу",
294        description = "Закон срабатывает не более одного раза за фазу своей стороны."
295    )]
296    pub once_per_phase: bool,
297
298    #[schemars(title = "Эффект")]
299    pub effect: LawEffect,
300
301    #[schemars(
302        title = "Величина эффекта",
303        description = "Одно число в единицах эффекта: проценты (перимириады), тики кулдауна. Именно это число умножает усиление моста."
304    )]
305    pub effect_value: i64,
306
307    #[schemars(
308        title = "Длительность эффекта, тики",
309        description = "0 для мгновенных эффектов."
310    )]
311    pub effect_duration_ticks: i64,
312
313    #[schemars(
314        title = "Резонанс",
315        description = "Сколько закон кладёт в свой мост за срабатывание. Усилением моста НЕ умножается."
316    )]
317    pub resonance: i64,
318
319    #[schemars(
320        title = "Закон включён",
321        description = "Выключенный закон лежит в каталоге, но его условие не считается и пассивные модификаторы не действуют. Так выключают контент, которому ещё рано."
322    )]
323    pub is_active: bool,
324
325    #[schemars(
326        title = "Модификаторы атрибутов",
327        description = "Пассивная часть закона, действует пока его сторона активна. Усиление моста умножает и её."
328    )]
329    pub modifiers: Vec<LawAttributeModifier>,
330
331    #[schemars(
332        title = "Множитель Power на первом ранге",
333        description = "Приблизительный вклад в displayed/matchmaking Power (BAL-030). 1.0 = не влияет. Промежуточные ранги интерполируются линейно по ln(q)."
334    )]
335    pub power_q_first_rank: f64,
336
337    #[schemars(
338        title = "Множитель Power на максимальном ранге",
339        description = "Значение того же множителя на последнем ранге. Должен быть не меньше значения на первом."
340    )]
341    pub power_q_max_rank: f64,
342
343    #[schemars(title = "Иконка", schema_with = "schema_loader::asset_law_icon_schema")]
344    pub icon_path: String,
345}
346
347/// One law the character owns. There is at most one row per law template: the
348/// first copy becomes the working instance, every further copy is stock for the
349/// upgrade ladder (raw copies, their own level irrelevant).
350#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
351#[tsify(from_wasm_abi, into_wasm_abi)]
352pub struct OwnedLaw {
353    pub template_id: LawTemplateId,
354    /// 1-based; raised by spending `copies` against the upgrade ladder.
355    pub level: i64,
356    /// Raw copies in stock, not yet spent on the ladder.
357    pub copies: i64,
358    /// Slot on the core of this law's own side, `None` when unslotted.
359    pub slot_index: Option<i64>,
360}
361
362/// One bridge. Always stored side-normalized, so "exactly two laws from
363/// different cores" is structural rather than a runtime check that could rot.
364#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
365#[tsify(from_wasm_abi, into_wasm_abi)]
366pub struct LawBridge {
367    pub real_law_id: LawTemplateId,
368    pub fantasy_law_id: LawTemplateId,
369}
370
371impl LawBridge {
372    /// The far end of the bridge for `law_id`, or `None` when `law_id` is not
373    /// part of this bridge.
374    pub fn other_end(&self, law_id: LawTemplateId) -> Option<LawTemplateId> {
375        if law_id == self.real_law_id {
376            Some(self.fantasy_law_id)
377        } else if law_id == self.fantasy_law_id {
378            Some(self.real_law_id)
379        } else {
380            None
381        }
382    }
383
384    pub fn contains(&self, law_id: LawTemplateId) -> bool {
385        law_id == self.real_law_id || law_id == self.fantasy_law_id
386    }
387}
388
389/// Durable cores/laws/bridges state of one character. The all-zero default is
390/// the pre-feature character: no cores, no laws, no bridges, multiplier ×1.0.
391#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
392#[tsify(from_wasm_abi, into_wasm_abi)]
393pub struct CoresState {
394    pub real_level: i64,
395    pub fantasy_level: i64,
396    pub laws: Vec<OwnedLaw>,
397    pub bridges: Vec<LawBridge>,
398    /// Out-of-combat Law pre-selections for the three Pet Facets that act on
399    /// "one chosen Law" (`LeadReading`, `WildReading`, `DreamReader`). Kept with
400    /// the laws because that is what they select; the all-`None` default is a
401    /// character who has never opened the picker.
402    pub pet_facet_law_choices: crate::pet_facets::PetFacetLawChoices,
403}
404
405impl CoresState {
406    pub fn level_of(&self, side: WorldSide) -> i64 {
407        match side {
408            WorldSide::Real => self.real_level,
409            WorldSide::Fantasy => self.fantasy_level,
410        }
411    }
412
413    pub fn set_level(&mut self, side: WorldSide, level: i64) {
414        match side {
415            WorldSide::Real => self.real_level = level,
416            WorldSide::Fantasy => self.fantasy_level = level,
417        }
418    }
419
420    pub fn law(&self, template_id: LawTemplateId) -> Option<&OwnedLaw> {
421        self.laws.iter().find(|law| law.template_id == template_id)
422    }
423
424    pub fn law_mut(&mut self, template_id: LawTemplateId) -> Option<&mut OwnedLaw> {
425        self.laws
426            .iter_mut()
427            .find(|law| law.template_id == template_id)
428    }
429
430    /// How many bridges `law_id` currently participates in. v0.2 caps this at
431    /// one (`mechanics::cores::MAX_BRIDGES_PER_LAW`): a law has exactly one
432    /// partner, and the interesting choice is who it is.
433    pub fn bridge_count_for_law(&self, law_id: LawTemplateId) -> usize {
434        self.bridges
435            .iter()
436            .filter(|bridge| bridge.contains(law_id))
437            .count()
438    }
439
440    /// The bridge `law_id` belongs to, if any.
441    pub fn bridge_of(&self, law_id: LawTemplateId) -> Option<&LawBridge> {
442        self.bridges.iter().find(|bridge| bridge.contains(law_id))
443    }
444
445    /// Laws currently sitting in a slot, in slot order.
446    pub fn slotted_laws(&self) -> impl Iterator<Item = &OwnedLaw> {
447        self.laws.iter().filter(|law| law.slot_index.is_some())
448    }
449}
450
451/// Fixed-point scale of every bridge charge: charges are stored in HUNDREDTHS
452/// of a unit and read back floored to whole units.
453///
454/// Why the accumulator is fractional (post-merge plan §8). Resonance is a small
455/// integer 1..5 — eleven of the twenty-four laws sit at 2 — and the right-column
456/// artifact stones state their effect as a percentage of it (`VL-01` `+50%`,
457/// `BL-05` `+25%`). `+25%` of 2 is 2.5, so rounding at every fire would decide
458/// the stone instead of the design: rounding down makes it do nothing, rounding
459/// up reaches a capacity-10 bridge in four fires instead of five and turns
460/// `+25%` into `+50%`. Keeping the exact value and flooring only at READ time
461/// makes two fires of 2.5 worth 5 and one fire worth 2 — the remainder is not
462/// lost, it just does not count until it completes a unit.
463///
464/// Integer hundredths rather than `f64`: this state lives between ticks on a
465/// deterministic server, and floating point would accumulate divergence.
466///
467/// A fractional remainder alive at a flip is DELIVERED with the rest of the
468/// charge and then floored by the reader, so half a unit simply never counts —
469/// the same rule as "charge above capacity burns".
470pub const CHARGE_SCALE: i64 = 100;
471
472/// One live bridge of ONE combatant, with its two independent charges, in
473/// hundredths of a unit ([`CHARGE_SCALE`]).
474///
475/// `toward_*` is the charge being FILLED by the law on the opposite side while
476/// that side is up; `delivered_to_*` is what a law received at the last flip
477/// and is what amplifies it for the whole current phase.
478#[derive(Clone, Debug, PartialEq, Eq)]
479pub struct BridgeCharge {
480    pub real_law_id: LawTemplateId,
481    pub fantasy_law_id: LawTemplateId,
482    /// Resonance put in by the Real law, waiting for the flip that hands it to
483    /// the Fantasy law.
484    pub toward_fantasy: i64,
485    /// Resonance put in by the Fantasy law, waiting for the Real law.
486    pub toward_real: i64,
487    /// Units delivered to the Real law at the last flip.
488    pub delivered_to_real: i64,
489    /// Units delivered to the Fantasy law at the last flip.
490    pub delivered_to_fantasy: i64,
491}
492
493impl BridgeCharge {
494    pub fn new(real_law_id: LawTemplateId, fantasy_law_id: LawTemplateId) -> Self {
495        Self {
496            real_law_id,
497            fantasy_law_id,
498            toward_fantasy: 0,
499            toward_real: 0,
500            delivered_to_real: 0,
501            delivered_to_fantasy: 0,
502        }
503    }
504
505    pub fn contains(&self, law_id: LawTemplateId) -> bool {
506        law_id == self.real_law_id || law_id == self.fantasy_law_id
507    }
508
509    /// The far end of this bridge for `law_id`, or `None` when `law_id` is not
510    /// part of it.
511    pub fn other_end(&self, law_id: LawTemplateId) -> Option<LawTemplateId> {
512        if law_id == self.real_law_id {
513            Some(self.fantasy_law_id)
514        } else if law_id == self.fantasy_law_id {
515            Some(self.real_law_id)
516        } else {
517            None
518        }
519    }
520
521    /// Which side of this bridge `law_id` sits on.
522    pub fn side_of(&self, law_id: LawTemplateId) -> Option<WorldSide> {
523        if law_id == self.real_law_id {
524            Some(WorldSide::Real)
525        } else if law_id == self.fantasy_law_id {
526            Some(WorldSide::Fantasy)
527        } else {
528            None
529        }
530    }
531}
532
533/// Per-fight bridge state of ONE combatant. Lives on the combat entity rather
534/// than on the fight because PvP and party fights hold several heroes, each
535/// with an independent active side and independent charges.
536///
537/// Combat-runtime only, exactly like `Entity::proc_entropy`: it is
538/// `serde(skip)`ped off the wire and the client state patch. `active_fight` is
539/// never persisted (`storage::state` always hydrates it as `None`), so there is
540/// no durable copy that could drift.
541/// Carries the capacity and the amplification ceiling this combatant's bridges
542/// play against, rather than reading them from config at every call. They are
543/// fixed for the whole fight (artifact stones only change out of combat) and
544/// the artifact's Bridge Law socket moves them per player, so keeping them here
545/// is what lets the strip-and-refold of the passive law contribution
546/// (`entities::refresh_law_attributes`) use the same numbers coming and going —
547/// a config lookup could not, because it has no idea whose bridges these are.
548#[derive(Clone, Debug, Default, PartialEq, Eq)]
549pub struct LawBridgeCharges {
550    pub bridges: Vec<BridgeCharge>,
551    /// Charge ONE direction can hold, in hundredths ([`CHARGE_SCALE`]).
552    /// Everything above it burns.
553    pub capacity_hundredths: i64,
554    /// Law Power a completely full bridge is worth, in permyriad.
555    pub amplification_cap_permyriad: i64,
556}
557
558impl LawBridgeCharges {
559    pub fn new(
560        bridges: Vec<BridgeCharge>,
561        capacity_hundredths: i64,
562        amplification_cap_permyriad: i64,
563    ) -> Self {
564        Self {
565            bridges,
566            capacity_hundredths,
567            amplification_cap_permyriad,
568        }
569    }
570
571    pub fn is_empty(&self) -> bool {
572        self.bridges.is_empty()
573    }
574
575    /// Puts `amount` of Resonance in on behalf of `law_id`, i.e. into the
576    /// charge that flows AWAY from it. Everything above `capacity` burns, like
577    /// Flip Gauge overflow. Returns how much actually landed.
578    ///
579    /// `amount` is in hundredths ([`CHARGE_SCALE`]); the ceiling is this
580    /// combatant's own [`Self::capacity_hundredths`].
581    pub fn add_resonance(&mut self, law_id: LawTemplateId, amount: i64) -> i64 {
582        if amount <= 0 {
583            return 0;
584        }
585        let capacity = self.capacity_hundredths.max(0);
586        let Some(bridge) = self
587            .bridges
588            .iter_mut()
589            .find(|bridge| bridge.contains(law_id))
590        else {
591            return 0;
592        };
593        let charge = if law_id == bridge.real_law_id {
594            &mut bridge.toward_fantasy
595        } else {
596            &mut bridge.toward_real
597        };
598        let before = *charge;
599        *charge = (before + amount).min(capacity);
600        *charge - before
601    }
602
603    /// Charge currently sitting in the direction `law_id` fills, in hundredths.
604    pub fn pending_from(&self, law_id: LawTemplateId) -> i64 {
605        self.bridges
606            .iter()
607            .find(|bridge| bridge.contains(law_id))
608            .map_or(0, |bridge| {
609                if law_id == bridge.real_law_id {
610                    bridge.toward_fantasy
611                } else {
612                    bridge.toward_real
613                }
614            })
615    }
616
617    /// WHOLE units `law_id` received at the last flip — the floor of the exact
618    /// charge. This is the only number amplification is ever priced from; the
619    /// fractional remainder is kept by [`Self::delivered_hundredths`] but never
620    /// counts until it completes a unit.
621    ///
622    /// Zero when the law is in no bridge, which is what makes an unbridged law
623    /// run unamplified.
624    pub fn delivered_units(&self, law_id: LawTemplateId) -> i64 {
625        self.delivered_hundredths(law_id).div_euclid(CHARGE_SCALE)
626    }
627
628    /// Exact charge `law_id` received at the last flip, in hundredths.
629    pub fn delivered_hundredths(&self, law_id: LawTemplateId) -> i64 {
630        self.bridges
631            .iter()
632            .find(|bridge| bridge.contains(law_id))
633            .map_or(0, |bridge| {
634                if law_id == bridge.real_law_id {
635                    bridge.delivered_to_real
636                } else {
637                    bridge.delivered_to_fantasy
638                }
639            })
640    }
641
642    /// Flip step 1+2 (plan §3): every accumulated charge is handed to the law
643    /// at the far end and the charge is zeroed.
644    ///
645    /// Both directions are delivered, not just the one the outgoing phase
646    /// filled: the other direction was zeroed by the previous flip and has had
647    /// no active law to fill it since, so delivering it is a no-op that keeps
648    /// the step free of "which side was up" bookkeeping.
649    pub fn deliver(&mut self) {
650        for bridge in &mut self.bridges {
651            bridge.delivered_to_fantasy = bridge.toward_fantasy;
652            bridge.delivered_to_real = bridge.toward_real;
653            bridge.toward_fantasy = 0;
654            bridge.toward_real = 0;
655        }
656    }
657
658    /// Wipes every charge and every live amplification. Called at the start of
659    /// a new fight.
660    pub fn reset(&mut self) {
661        for bridge in &mut self.bridges {
662            bridge.toward_fantasy = 0;
663            bridge.toward_real = 0;
664            bridge.delivered_to_real = 0;
665            bridge.delivered_to_fantasy = 0;
666        }
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    /// Everything below is stated in hundredths, the unit the accumulator
675    /// actually holds.
676    const CAP_10: i64 = 10 * CHARGE_SCALE;
677
678    fn charges(real: LawTemplateId, fantasy: LawTemplateId) -> LawBridgeCharges {
679        LawBridgeCharges::new(vec![BridgeCharge::new(real, fantasy)], CAP_10, 5_000)
680    }
681
682    #[test]
683    fn bridge_other_end_is_symmetric() {
684        let real = Uuid::now_v7();
685        let fantasy = Uuid::now_v7();
686        let bridge = LawBridge {
687            real_law_id: real,
688            fantasy_law_id: fantasy,
689        };
690
691        assert_eq!(bridge.other_end(real), Some(fantasy));
692        assert_eq!(bridge.other_end(fantasy), Some(real));
693        assert_eq!(bridge.other_end(Uuid::now_v7()), None);
694    }
695
696    /// Acceptance criterion #5: charge accumulates up to capacity and anything
697    /// above it burns rather than banking.
698    #[test]
699    fn charge_above_capacity_burns() {
700        let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
701        let mut charges = charges(real, fantasy);
702
703        assert_eq!(charges.add_resonance(real, 4 * CHARGE_SCALE), 400);
704        assert_eq!(charges.add_resonance(real, 4 * CHARGE_SCALE), 400);
705        // Only 2 of the 5 fit; the rest burns.
706        assert_eq!(charges.add_resonance(real, 5 * CHARGE_SCALE), 200);
707        assert_eq!(charges.pending_from(real), CAP_10);
708        assert_eq!(charges.add_resonance(real, 5 * CHARGE_SCALE), 0);
709    }
710
711    /// Post-merge plan §8: the accumulator keeps the exact value, the reader
712    /// floors it. Two fires worth 2.5 make 5; one fire worth 2.5 reads as 2 and
713    /// the half is remembered, not dropped.
714    #[test]
715    fn a_fractional_resonance_accumulates_exactly_and_reads_floored() {
716        let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
717        let mut charges = charges(real, fantasy);
718
719        charges.add_resonance(real, 250);
720        charges.deliver();
721        assert_eq!(charges.delivered_hundredths(fantasy), 250);
722        assert_eq!(
723            charges.delivered_units(fantasy),
724            2,
725            "half a unit does not count yet"
726        );
727
728        charges.add_resonance(real, 250);
729        charges.add_resonance(real, 250);
730        charges.deliver();
731        assert_eq!(
732            charges.delivered_units(fantasy),
733            5,
734            "the two halves completed a whole unit"
735        );
736    }
737
738    /// The two directions are independent: what the Real law puts in never
739    /// shows up as what the Real law received.
740    #[test]
741    fn the_two_directions_are_independent() {
742        let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
743        let mut charges = charges(real, fantasy);
744
745        charges.add_resonance(real, 6 * CHARGE_SCALE);
746        assert_eq!(charges.pending_from(real), 6 * CHARGE_SCALE);
747        assert_eq!(charges.pending_from(fantasy), 0);
748        assert_eq!(charges.delivered_units(real), 0);
749        assert_eq!(charges.delivered_units(fantasy), 0);
750    }
751
752    /// Acceptance criterion #6: a flip hands the charge to the RECEIVER and
753    /// zeroes it.
754    #[test]
755    fn a_flip_delivers_to_the_far_end_and_zeroes() {
756        let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
757        let mut charges = charges(real, fantasy);
758
759        charges.add_resonance(real, 7 * CHARGE_SCALE);
760        charges.deliver();
761
762        assert_eq!(charges.delivered_units(fantasy), 7, "far end receives");
763        assert_eq!(charges.delivered_units(real), 0, "filler receives nothing");
764        assert_eq!(charges.pending_from(real), 0, "charge is spent");
765    }
766
767    /// A law with no bridge is never amplified and never banks Resonance.
768    #[test]
769    fn an_unbridged_law_neither_fills_nor_receives() {
770        let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
771        let lonely = Uuid::now_v7();
772        let mut charges = charges(real, fantasy);
773
774        assert_eq!(charges.add_resonance(lonely, 5 * CHARGE_SCALE), 0);
775        charges.deliver();
776        assert_eq!(charges.delivered_units(lonely), 0);
777    }
778
779    #[test]
780    fn reset_clears_charges_and_amplification() {
781        let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
782        let mut charges = charges(real, fantasy);
783
784        charges.add_resonance(real, 5 * CHARGE_SCALE);
785        charges.deliver();
786        charges.add_resonance(fantasy, 3 * CHARGE_SCALE);
787        charges.reset();
788
789        assert_eq!(charges.delivered_units(fantasy), 0);
790        assert_eq!(charges.pending_from(fantasy), 0);
791    }
792}