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, i18n::Translate)]
238pub struct LawTemplate {
239 #[schemars(schema_with = "id_schema")]
240 pub id: LawTemplateId,
241
242 #[schemars(title = "Название")]
243 #[translate]
244 pub name: i18n::I18nString,
245
246 #[schemars(title = "Описание")]
247 #[translate]
248 pub description: i18n::I18nString,
249
250 #[schemars(
251 title = "Сторона",
252 description = "Закон ставится только в слот ядра своей стороны и работает только пока эта сторона активна."
253 )]
254 pub side: WorldSide,
255
256 /// Deterministic unlock: raising the core of [`Self::side`] to this level
257 /// grants the law outright, at level 1 with no copies. Random law-copy
258 /// drops stay what they always were — copy income for LEVELLING an owned
259 /// law, never the unlock path.
260 ///
261 /// `GameConfig::validate_cores_settings` requires every law to be reachable
262 /// by `CoresSettings::max_slot_level()`, so "both cores at the last
263 /// slot-opening level" always means "every law owned".
264 #[schemars(
265 title = "Уровень ядра, на котором закон выдаётся",
266 description = "Прокачка ядра своей стороны до этого уровня выдаёт закон детерминированно (1 уровень, 0 копий). Должен укладываться в уровень, на котором открыт последний слот."
267 )]
268 pub unlock_core_level: i64,
269
270 #[schemars(
271 title = "Условие",
272 description = "Core-событие, на котором закон срабатывает. Условие скрытого закона не считается."
273 )]
274 pub condition: LawCondition,
275
276 #[schemars(
277 title = "Параметр условия",
278 description = "N для «каждый N-й», минимум целей, порог HP в перимириадах. 0 если условию параметр не нужен."
279 )]
280 pub condition_value: i64,
281
282 #[schemars(
283 title = "Тег абилки в условии",
284 description = "Только для условия «Original Skill с тегом»; иначе пусто."
285 )]
286 pub condition_tag: Option<AbilityTag>,
287
288 #[schemars(
289 title = "Окно условия, тики",
290 description = "Только для условия «N разных Skill за время»; иначе 0."
291 )]
292 pub condition_window_ticks: i64,
293
294 #[schemars(
295 title = "Раз за фазу",
296 description = "Закон срабатывает не более одного раза за фазу своей стороны."
297 )]
298 pub once_per_phase: bool,
299
300 #[schemars(title = "Эффект")]
301 pub effect: LawEffect,
302
303 #[schemars(
304 title = "Величина эффекта",
305 description = "Одно число в единицах эффекта: проценты (перимириады), тики кулдауна. Именно это число умножает усиление моста."
306 )]
307 pub effect_value: i64,
308
309 #[schemars(
310 title = "Длительность эффекта, тики",
311 description = "0 для мгновенных эффектов."
312 )]
313 pub effect_duration_ticks: i64,
314
315 #[schemars(
316 title = "Резонанс",
317 description = "Сколько закон кладёт в свой мост за срабатывание. Усилением моста НЕ умножается."
318 )]
319 pub resonance: i64,
320
321 #[schemars(
322 title = "Закон включён",
323 description = "Выключенный закон лежит в каталоге, но его условие не считается и пассивные модификаторы не действуют. Так выключают контент, которому ещё рано."
324 )]
325 pub is_active: bool,
326
327 #[schemars(
328 title = "Модификаторы атрибутов",
329 description = "Пассивная часть закона, действует пока его сторона активна. Усиление моста умножает и её."
330 )]
331 pub modifiers: Vec<LawAttributeModifier>,
332
333 #[schemars(
334 title = "Множитель Power на первом ранге",
335 description = "Приблизительный вклад в displayed/matchmaking Power (BAL-030). 1.0 = не влияет. Промежуточные ранги интерполируются линейно по ln(q)."
336 )]
337 pub power_q_first_rank: f64,
338
339 #[schemars(
340 title = "Множитель Power на максимальном ранге",
341 description = "Значение того же множителя на последнем ранге. Должен быть не меньше значения на первом."
342 )]
343 pub power_q_max_rank: f64,
344
345 #[schemars(title = "Иконка", schema_with = "schema_loader::asset_law_icon_schema")]
346 pub icon_path: String,
347}
348
349/// One law the character owns. There is at most one row per law template: the
350/// first copy becomes the working instance, every further copy is stock for the
351/// upgrade ladder (raw copies, their own level irrelevant).
352#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
353#[tsify(from_wasm_abi, into_wasm_abi)]
354pub struct OwnedLaw {
355 pub template_id: LawTemplateId,
356 /// 1-based; raised by spending `copies` against the upgrade ladder.
357 pub level: i64,
358 /// Raw copies in stock, not yet spent on the ladder.
359 pub copies: i64,
360 /// Slot on the core of this law's own side, `None` when unslotted.
361 pub slot_index: Option<i64>,
362}
363
364/// One bridge. Always stored side-normalized, so "exactly two laws from
365/// different cores" is structural rather than a runtime check that could rot.
366#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
367#[tsify(from_wasm_abi, into_wasm_abi)]
368pub struct LawBridge {
369 pub real_law_id: LawTemplateId,
370 pub fantasy_law_id: LawTemplateId,
371}
372
373impl LawBridge {
374 /// The far end of the bridge for `law_id`, or `None` when `law_id` is not
375 /// part of this bridge.
376 pub fn other_end(&self, law_id: LawTemplateId) -> Option<LawTemplateId> {
377 if law_id == self.real_law_id {
378 Some(self.fantasy_law_id)
379 } else if law_id == self.fantasy_law_id {
380 Some(self.real_law_id)
381 } else {
382 None
383 }
384 }
385
386 pub fn contains(&self, law_id: LawTemplateId) -> bool {
387 law_id == self.real_law_id || law_id == self.fantasy_law_id
388 }
389}
390
391/// Durable cores/laws/bridges state of one character. The all-zero default is
392/// the pre-feature character: no cores, no laws, no bridges, multiplier ×1.0.
393#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
394#[tsify(from_wasm_abi, into_wasm_abi)]
395pub struct CoresState {
396 pub real_level: i64,
397 pub fantasy_level: i64,
398 pub laws: Vec<OwnedLaw>,
399 pub bridges: Vec<LawBridge>,
400 /// Out-of-combat Law pre-selections for the three Pet Facets that act on
401 /// "one chosen Law" (`LeadReading`, `WildReading`, `DreamReader`). Kept with
402 /// the laws because that is what they select; the all-`None` default is a
403 /// character who has never opened the picker.
404 pub pet_facet_law_choices: crate::pet_facets::PetFacetLawChoices,
405}
406
407impl CoresState {
408 pub fn level_of(&self, side: WorldSide) -> i64 {
409 match side {
410 WorldSide::Real => self.real_level,
411 WorldSide::Fantasy => self.fantasy_level,
412 }
413 }
414
415 pub fn set_level(&mut self, side: WorldSide, level: i64) {
416 match side {
417 WorldSide::Real => self.real_level = level,
418 WorldSide::Fantasy => self.fantasy_level = level,
419 }
420 }
421
422 pub fn law(&self, template_id: LawTemplateId) -> Option<&OwnedLaw> {
423 self.laws.iter().find(|law| law.template_id == template_id)
424 }
425
426 pub fn law_mut(&mut self, template_id: LawTemplateId) -> Option<&mut OwnedLaw> {
427 self.laws
428 .iter_mut()
429 .find(|law| law.template_id == template_id)
430 }
431
432 /// How many bridges `law_id` currently participates in. v0.2 caps this at
433 /// one (`mechanics::cores::MAX_BRIDGES_PER_LAW`): a law has exactly one
434 /// partner, and the interesting choice is who it is.
435 pub fn bridge_count_for_law(&self, law_id: LawTemplateId) -> usize {
436 self.bridges
437 .iter()
438 .filter(|bridge| bridge.contains(law_id))
439 .count()
440 }
441
442 /// The bridge `law_id` belongs to, if any.
443 pub fn bridge_of(&self, law_id: LawTemplateId) -> Option<&LawBridge> {
444 self.bridges.iter().find(|bridge| bridge.contains(law_id))
445 }
446
447 /// Laws currently sitting in a slot, in slot order.
448 pub fn slotted_laws(&self) -> impl Iterator<Item = &OwnedLaw> {
449 self.laws.iter().filter(|law| law.slot_index.is_some())
450 }
451}
452
453/// Fixed-point scale of every bridge charge: charges are stored in HUNDREDTHS
454/// of a unit and read back floored to whole units.
455///
456/// Why the accumulator is fractional (post-merge plan §8). Resonance is a small
457/// integer 1..5 — eleven of the twenty-four laws sit at 2 — and the right-column
458/// artifact stones state their effect as a percentage of it (`VL-01` `+50%`,
459/// `BL-05` `+25%`). `+25%` of 2 is 2.5, so rounding at every fire would decide
460/// the stone instead of the design: rounding down makes it do nothing, rounding
461/// up reaches a capacity-10 bridge in four fires instead of five and turns
462/// `+25%` into `+50%`. Keeping the exact value and flooring only at READ time
463/// makes two fires of 2.5 worth 5 and one fire worth 2 — the remainder is not
464/// lost, it just does not count until it completes a unit.
465///
466/// Integer hundredths rather than `f64`: this state lives between ticks on a
467/// deterministic server, and floating point would accumulate divergence.
468///
469/// A fractional remainder alive at a flip is DELIVERED with the rest of the
470/// charge and then floored by the reader, so half a unit simply never counts —
471/// the same rule as "charge above capacity burns".
472pub const CHARGE_SCALE: i64 = 100;
473
474/// One live bridge of ONE combatant, with its two independent charges, in
475/// hundredths of a unit ([`CHARGE_SCALE`]).
476///
477/// `toward_*` is the charge being FILLED by the law on the opposite side while
478/// that side is up; `delivered_to_*` is what a law received at the last flip
479/// and is what amplifies it for the whole current phase.
480#[derive(Clone, Debug, PartialEq, Eq)]
481pub struct BridgeCharge {
482 pub real_law_id: LawTemplateId,
483 pub fantasy_law_id: LawTemplateId,
484 /// Resonance put in by the Real law, waiting for the flip that hands it to
485 /// the Fantasy law.
486 pub toward_fantasy: i64,
487 /// Resonance put in by the Fantasy law, waiting for the Real law.
488 pub toward_real: i64,
489 /// Units delivered to the Real law at the last flip.
490 pub delivered_to_real: i64,
491 /// Units delivered to the Fantasy law at the last flip.
492 pub delivered_to_fantasy: i64,
493}
494
495impl BridgeCharge {
496 pub fn new(real_law_id: LawTemplateId, fantasy_law_id: LawTemplateId) -> Self {
497 Self {
498 real_law_id,
499 fantasy_law_id,
500 toward_fantasy: 0,
501 toward_real: 0,
502 delivered_to_real: 0,
503 delivered_to_fantasy: 0,
504 }
505 }
506
507 pub fn contains(&self, law_id: LawTemplateId) -> bool {
508 law_id == self.real_law_id || law_id == self.fantasy_law_id
509 }
510
511 /// The far end of this bridge for `law_id`, or `None` when `law_id` is not
512 /// part of it.
513 pub fn other_end(&self, law_id: LawTemplateId) -> Option<LawTemplateId> {
514 if law_id == self.real_law_id {
515 Some(self.fantasy_law_id)
516 } else if law_id == self.fantasy_law_id {
517 Some(self.real_law_id)
518 } else {
519 None
520 }
521 }
522
523 /// Which side of this bridge `law_id` sits on.
524 pub fn side_of(&self, law_id: LawTemplateId) -> Option<WorldSide> {
525 if law_id == self.real_law_id {
526 Some(WorldSide::Real)
527 } else if law_id == self.fantasy_law_id {
528 Some(WorldSide::Fantasy)
529 } else {
530 None
531 }
532 }
533}
534
535/// Per-fight bridge state of ONE combatant. Lives on the combat entity rather
536/// than on the fight because PvP and party fights hold several heroes, each
537/// with an independent active side and independent charges.
538///
539/// Combat-runtime only, exactly like `Entity::proc_entropy`: it is
540/// `serde(skip)`ped off the wire and the client state patch. `active_fight` is
541/// never persisted (`storage::state` always hydrates it as `None`), so there is
542/// no durable copy that could drift.
543/// Carries the capacity and the amplification ceiling this combatant's bridges
544/// play against, rather than reading them from config at every call. They are
545/// fixed for the whole fight (artifact stones only change out of combat) and
546/// the artifact's Bridge Law socket moves them per player, so keeping them here
547/// is what lets the strip-and-refold of the passive law contribution
548/// (`entities::refresh_law_attributes`) use the same numbers coming and going —
549/// a config lookup could not, because it has no idea whose bridges these are.
550#[derive(Clone, Debug, Default, PartialEq, Eq)]
551pub struct LawBridgeCharges {
552 pub bridges: Vec<BridgeCharge>,
553 /// Charge ONE direction can hold, in hundredths ([`CHARGE_SCALE`]).
554 /// Everything above it burns.
555 pub capacity_hundredths: i64,
556 /// Law Power a completely full bridge is worth, in permyriad.
557 pub amplification_cap_permyriad: i64,
558}
559
560impl LawBridgeCharges {
561 pub fn new(
562 bridges: Vec<BridgeCharge>,
563 capacity_hundredths: i64,
564 amplification_cap_permyriad: i64,
565 ) -> Self {
566 Self {
567 bridges,
568 capacity_hundredths,
569 amplification_cap_permyriad,
570 }
571 }
572
573 pub fn is_empty(&self) -> bool {
574 self.bridges.is_empty()
575 }
576
577 /// Puts `amount` of Resonance in on behalf of `law_id`, i.e. into the
578 /// charge that flows AWAY from it. Everything above `capacity` burns, like
579 /// Flip Gauge overflow. Returns how much actually landed.
580 ///
581 /// `amount` is in hundredths ([`CHARGE_SCALE`]); the ceiling is this
582 /// combatant's own [`Self::capacity_hundredths`].
583 pub fn add_resonance(&mut self, law_id: LawTemplateId, amount: i64) -> i64 {
584 if amount <= 0 {
585 return 0;
586 }
587 let capacity = self.capacity_hundredths.max(0);
588 let Some(bridge) = self
589 .bridges
590 .iter_mut()
591 .find(|bridge| bridge.contains(law_id))
592 else {
593 return 0;
594 };
595 let charge = if law_id == bridge.real_law_id {
596 &mut bridge.toward_fantasy
597 } else {
598 &mut bridge.toward_real
599 };
600 let before = *charge;
601 *charge = (before + amount).min(capacity);
602 *charge - before
603 }
604
605 /// Charge currently sitting in the direction `law_id` fills, in hundredths.
606 pub fn pending_from(&self, law_id: LawTemplateId) -> i64 {
607 self.bridges
608 .iter()
609 .find(|bridge| bridge.contains(law_id))
610 .map_or(0, |bridge| {
611 if law_id == bridge.real_law_id {
612 bridge.toward_fantasy
613 } else {
614 bridge.toward_real
615 }
616 })
617 }
618
619 /// WHOLE units `law_id` received at the last flip — the floor of the exact
620 /// charge. This is the only number amplification is ever priced from; the
621 /// fractional remainder is kept by [`Self::delivered_hundredths`] but never
622 /// counts until it completes a unit.
623 ///
624 /// Zero when the law is in no bridge, which is what makes an unbridged law
625 /// run unamplified.
626 pub fn delivered_units(&self, law_id: LawTemplateId) -> i64 {
627 self.delivered_hundredths(law_id).div_euclid(CHARGE_SCALE)
628 }
629
630 /// Exact charge `law_id` received at the last flip, in hundredths.
631 pub fn delivered_hundredths(&self, law_id: LawTemplateId) -> i64 {
632 self.bridges
633 .iter()
634 .find(|bridge| bridge.contains(law_id))
635 .map_or(0, |bridge| {
636 if law_id == bridge.real_law_id {
637 bridge.delivered_to_real
638 } else {
639 bridge.delivered_to_fantasy
640 }
641 })
642 }
643
644 /// Flip step 1+2 (plan §3): every accumulated charge is handed to the law
645 /// at the far end and the charge is zeroed.
646 ///
647 /// Both directions are delivered, not just the one the outgoing phase
648 /// filled: the other direction was zeroed by the previous flip and has had
649 /// no active law to fill it since, so delivering it is a no-op that keeps
650 /// the step free of "which side was up" bookkeeping.
651 pub fn deliver(&mut self) {
652 for bridge in &mut self.bridges {
653 bridge.delivered_to_fantasy = bridge.toward_fantasy;
654 bridge.delivered_to_real = bridge.toward_real;
655 bridge.toward_fantasy = 0;
656 bridge.toward_real = 0;
657 }
658 }
659
660 /// Wipes every charge and every live amplification. Called at the start of
661 /// a new fight.
662 pub fn reset(&mut self) {
663 for bridge in &mut self.bridges {
664 bridge.toward_fantasy = 0;
665 bridge.toward_real = 0;
666 bridge.delivered_to_real = 0;
667 bridge.delivered_to_fantasy = 0;
668 }
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 /// Everything below is stated in hundredths, the unit the accumulator
677 /// actually holds.
678 const CAP_10: i64 = 10 * CHARGE_SCALE;
679
680 fn charges(real: LawTemplateId, fantasy: LawTemplateId) -> LawBridgeCharges {
681 LawBridgeCharges::new(vec![BridgeCharge::new(real, fantasy)], CAP_10, 5_000)
682 }
683
684 #[test]
685 fn bridge_other_end_is_symmetric() {
686 let real = Uuid::now_v7();
687 let fantasy = Uuid::now_v7();
688 let bridge = LawBridge {
689 real_law_id: real,
690 fantasy_law_id: fantasy,
691 };
692
693 assert_eq!(bridge.other_end(real), Some(fantasy));
694 assert_eq!(bridge.other_end(fantasy), Some(real));
695 assert_eq!(bridge.other_end(Uuid::now_v7()), None);
696 }
697
698 /// Acceptance criterion #5: charge accumulates up to capacity and anything
699 /// above it burns rather than banking.
700 #[test]
701 fn charge_above_capacity_burns() {
702 let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
703 let mut charges = charges(real, fantasy);
704
705 assert_eq!(charges.add_resonance(real, 4 * CHARGE_SCALE), 400);
706 assert_eq!(charges.add_resonance(real, 4 * CHARGE_SCALE), 400);
707 // Only 2 of the 5 fit; the rest burns.
708 assert_eq!(charges.add_resonance(real, 5 * CHARGE_SCALE), 200);
709 assert_eq!(charges.pending_from(real), CAP_10);
710 assert_eq!(charges.add_resonance(real, 5 * CHARGE_SCALE), 0);
711 }
712
713 /// Post-merge plan §8: the accumulator keeps the exact value, the reader
714 /// floors it. Two fires worth 2.5 make 5; one fire worth 2.5 reads as 2 and
715 /// the half is remembered, not dropped.
716 #[test]
717 fn a_fractional_resonance_accumulates_exactly_and_reads_floored() {
718 let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
719 let mut charges = charges(real, fantasy);
720
721 charges.add_resonance(real, 250);
722 charges.deliver();
723 assert_eq!(charges.delivered_hundredths(fantasy), 250);
724 assert_eq!(
725 charges.delivered_units(fantasy),
726 2,
727 "half a unit does not count yet"
728 );
729
730 charges.add_resonance(real, 250);
731 charges.add_resonance(real, 250);
732 charges.deliver();
733 assert_eq!(
734 charges.delivered_units(fantasy),
735 5,
736 "the two halves completed a whole unit"
737 );
738 }
739
740 /// The two directions are independent: what the Real law puts in never
741 /// shows up as what the Real law received.
742 #[test]
743 fn the_two_directions_are_independent() {
744 let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
745 let mut charges = charges(real, fantasy);
746
747 charges.add_resonance(real, 6 * CHARGE_SCALE);
748 assert_eq!(charges.pending_from(real), 6 * CHARGE_SCALE);
749 assert_eq!(charges.pending_from(fantasy), 0);
750 assert_eq!(charges.delivered_units(real), 0);
751 assert_eq!(charges.delivered_units(fantasy), 0);
752 }
753
754 /// Acceptance criterion #6: a flip hands the charge to the RECEIVER and
755 /// zeroes it.
756 #[test]
757 fn a_flip_delivers_to_the_far_end_and_zeroes() {
758 let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
759 let mut charges = charges(real, fantasy);
760
761 charges.add_resonance(real, 7 * CHARGE_SCALE);
762 charges.deliver();
763
764 assert_eq!(charges.delivered_units(fantasy), 7, "far end receives");
765 assert_eq!(charges.delivered_units(real), 0, "filler receives nothing");
766 assert_eq!(charges.pending_from(real), 0, "charge is spent");
767 }
768
769 /// A law with no bridge is never amplified and never banks Resonance.
770 #[test]
771 fn an_unbridged_law_neither_fills_nor_receives() {
772 let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
773 let lonely = Uuid::now_v7();
774 let mut charges = charges(real, fantasy);
775
776 assert_eq!(charges.add_resonance(lonely, 5 * CHARGE_SCALE), 0);
777 charges.deliver();
778 assert_eq!(charges.delivered_units(lonely), 0);
779 }
780
781 #[test]
782 fn reset_clears_charges_and_amplification() {
783 let (real, fantasy) = (Uuid::now_v7(), Uuid::now_v7());
784 let mut charges = charges(real, fantasy);
785
786 charges.add_resonance(real, 5 * CHARGE_SCALE);
787 charges.deliver();
788 charges.add_resonance(fantasy, 3 * CHARGE_SCALE);
789 charges.reset();
790
791 assert_eq!(charges.delivered_units(fantasy), 0);
792 assert_eq!(charges.pending_from(fantasy), 0);
793 }
794}