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}