overlord_event_system/mechanics/
cores.rs

1//! Pure arithmetic and state machines for the twin cores, laws and bridges
2//! (OVT-2517, laws v0.2). No events, no IO — everything here is a function of
3//! `CoresState` + `GameConfig`, so every acceptance criterion in the plan is
4//! unit-testable without a fight or a database.
5//!
6//! Layering:
7//! * `core_multiplier` / `law_slots_for_level` / `max_bridges` — part A plus
8//!   the structural limits. Refitted for the artifact right column: one slot per
9//!   core level and a cap of 5, so `min(level) − 1` is always exactly one short
10//!   of what the slots allow and the bridge limit binds at every level again
11//!   (post-merge plan §8).
12//! * `laws_unlocked_by_core_level` / `grant_unlocked_laws` — the deterministic
13//!   law schedule: a core level hands out its side's batch outright, so random
14//!   copy drops fund LEVELLING only and never the unlock itself.
15//! * `build_law_bridge_charges` / `law_power_multiplier` — the per-fight bridge
16//!   charge machinery that replaced `LawRhythms`.
17//! * `active_law_attribute_deltas` — the passive half of the active side's
18//!   laws, amplified by whatever their bridge delivered at the last flip.
19
20use std::collections::BTreeMap;
21
22use configs::game_config::GameConfig;
23use essences::cores::{
24    BridgeCharge, CHARGE_SCALE, CoresState, LawBridge, LawBridgeCharges, LawTemplate,
25    LawTemplateId, OwnedLaw,
26};
27use essences::flip::WorldSide;
28
29use crate::mechanics::artifacts::LawColumnMods;
30
31/// Damage bonus armed for the owner's next BASIC attack, permyriad.
32///
33/// Spent by [`crate::mechanics::fight::attack`], which picks between this and
34/// [`ARM_SKILL`] by the cast kind recorded on the caster. Keeping the two
35/// families in two keys is what makes `RL-02` ("next Basic Attack") and
36/// `FL-02` ("next original Skill") two different laws rather than two names
37/// for one arm.
38pub const ARM_BASIC: &str = "law.arm_basic";
39
40/// Damage bonus armed for the owner's next original SKILL, permyriad. See
41/// [`ARM_BASIC`].
42pub const ARM_SKILL: &str = "law.arm_skill";
43
44/// Damage bonus armed for the strike being resolved RIGHT NOW, permyriad
45/// (`RL-01` "every 5th Basic Attack: THIS strike deals +100%").
46///
47/// Unlike [`ARM_BASIC`] and [`ARM_SKILL`] this is not gated on the cast kind:
48/// "this attack" is whatever is swinging, basic or skill. It is armed from the
49/// pre-cast hook, which runs before the cast computes its damage, so the strike
50/// that triggered the law is the one that spends it.
51///
52/// Its own key rather than the stones' `stone.next_attack_bonus`: that key is
53/// charge-gated by the stones runtime, and a law writing a magnitude into it
54/// with no charge behind it was read as unarmed — which is how this whole
55/// family shipped inert.
56pub const ARM_THIS: &str = "law.arm_this";
57
58/// Which of the two arms a cast of `kind` spends.
59pub fn law_arm_key(kind: crate::logic::combat_facts::CastKind) -> &'static str {
60    match kind {
61        crate::logic::combat_facts::CastKind::Basic => ARM_BASIC,
62        crate::logic::combat_facts::CastKind::Skill => ARM_SKILL,
63    }
64}
65
66/// The PER-STAT multiplier for the product of the two core levels — BAL-010's
67/// `M_stat = (1 + min(P/5, sqrt(P) − 0.2))^(1/2.75)`
68/// ([`configs::cores::core_stat_multiplier`] owns the arithmetic).
69///
70/// Product 0 (any character with an un-levelled core) gets ×1.0 — that is what
71/// makes "a character with no cores plays exactly as before" (acceptance #14)
72/// true by construction. The `config` parameter is kept so a future config
73/// knob has an obvious home and every caller already threads it.
74pub fn core_multiplier(config: &GameConfig, cores: &CoresState) -> f64 {
75    let _ = config;
76    let product = cores.real_level.saturating_mul(cores.fantasy_level);
77    configs::cores::core_stat_multiplier(product)
78}
79
80/// `slots = min(ceil(level / core_levels_per_slot), max_slots_per_core)`.
81/// Level 0 (no core) has no slots.
82pub fn law_slots_for_level(config: &GameConfig, level: i64) -> i64 {
83    if level <= 0 {
84        return 0;
85    }
86    let per_slot = config.cores_settings.core_levels_per_slot.get().max(1);
87    let slots = (level + per_slot - 1) / per_slot;
88    slots.min(config.cores_settings.max_slots_per_core)
89}
90
91/// `bridges = min(real_level, fantasy_level, max_slot_level) - 1 + extra`,
92/// floored at 0. With one core still at level 1 and no artifact, no bridge is
93/// allowed at all.
94///
95/// `extra` is `BL-01 Extra Span`'s "+1 bridge"
96/// ([`crate::mechanics::artifacts::LawColumnMods::extra_bridges`]), `0` for
97/// everyone else. It is worth exactly one bridge only because the slot ladder
98/// was refitted with it: at one slot per level and five slots, `min(level) − 1`
99/// is always one short of what the slots physically allow, so the extra bridge
100/// always has somewhere to go (post-merge plan §8). Under the old "a slot every
101/// two levels, cap 10" pair the limit stopped binding from level 4 and this half
102/// of the stone was dead.
103///
104/// The `max_slot_level` clamp is what keeps that true now that core levels are
105/// unbounded: without it a level-9999 core would allow 9998 bridges against 5
106/// possible, the limit would stop binding, and `BL-01` would go dead exactly the
107/// way the refit existed to prevent.
108pub fn max_bridges(config: &GameConfig, cores: &CoresState, extra: i64) -> i64 {
109    let level = cores
110        .real_level
111        .min(cores.fantasy_level)
112        .min(config.cores_settings.max_slot_level());
113    (level - 1 + extra.max(0)).max(0)
114}
115
116/// Drops the bridges that no longer fit `max_bridges`, and reports how many
117/// went.
118///
119/// The budget only ever shrinks one way: `BL-01 Extra Span` leaving the Bridge
120/// socket takes its `+1` with it. The bridge it paid for has to go too, or the
121/// stone is a ratchet — socket it, build the extra bridge, put another Bridge
122/// stone in its place and keep both. Core levels only rise, so this is the one
123/// direction that needs cleaning up, and it mirrors `UnslotLaw` dropping the
124/// bridges of the law it unslots.
125///
126/// The bridges that go are the tail of the list. Inside one session that is the
127/// newest first, but `character_law_bridges` is read back ordered by law id
128/// rather than by creation time, so after a reconnect it is simply the last in
129/// that order. Deterministic either way, and deliberately not shown to the
130/// player as "your newest bridge".
131pub fn prune_bridges_over_budget(config: &GameConfig, cores: &mut CoresState, extra: i64) -> usize {
132    let allowed = max_bridges(config, cores, extra).max(0) as usize;
133    if cores.bridges.len() <= allowed {
134        return 0;
135    }
136    let dropped = cores.bridges.len() - allowed;
137    cores.bridges.truncate(allowed);
138    dropped
139}
140
141/// Every law of `side` the deterministic schedule hands out at core `level` and
142/// below.
143///
144/// `<=`, not `==`, on purpose: that is the whole of the backfill requirement. A
145/// character who reached level 4 before a law was rescheduled onto level 2, or
146/// before the schedule existed at all, collects everything it owes on the next
147/// upgrade rather than needing a migration.
148pub fn laws_unlocked_by_core_level(
149    config: &GameConfig,
150    side: WorldSide,
151    level: i64,
152) -> impl Iterator<Item = LawTemplateId> + '_ {
153    config
154        .laws
155        .iter()
156        .filter(move |law| law.side == side && law.unlock_core_level <= level)
157        .map(|law| law.id)
158}
159
160/// Grants every law `side`'s core has earned at its CURRENT level, and reports
161/// how many were new.
162///
163/// Idempotent by construction: an owned law is skipped whole, so re-upgrading,
164/// replaying an event or backfilling never adds a copy. That matters because
165/// copies are the law upgrade currency — a duplicate grant would be free
166/// progression, not a cosmetic double.
167///
168/// A granted law is a plain level-1 `OwnedLaw` with zero copies, unslotted:
169/// identical to what the first random copy drop used to produce, so nothing
170/// downstream has to tell a granted law from a dropped one.
171pub fn grant_unlocked_laws(config: &GameConfig, cores: &mut CoresState, side: WorldSide) -> usize {
172    let level = cores.level_of(side);
173    let unlocked: Vec<LawTemplateId> = laws_unlocked_by_core_level(config, side, level).collect();
174    let mut granted = 0;
175    for template_id in unlocked {
176        if cores.law(template_id).is_some() {
177            continue;
178        }
179        cores.laws.push(OwnedLaw {
180            template_id,
181            level: 1,
182            copies: 0,
183            slot_index: None,
184        });
185        granted += 1;
186    }
187    granted
188}
189
190/// Law levels raise a value by the same fraction of its level-1 magnitude per
191/// step. Integer arithmetic keeps the growth exactly reproducible and keeps a
192/// negative value (e.g. −10% received damage) growing away from zero.
193///
194/// The step size itself is deliberately NOT a config knob yet: the values in
195/// the starter catalog are placeholders, so a second placeholder controlling
196/// how placeholders grow would only be noise. When the economy pass lands, this
197/// becomes a `CoresSettings` field.
198const LAW_LEVEL_GROWTH_PERMYRIAD: i64 = 2_500;
199
200fn grow_value(level_one_value: i64, steps: i64) -> i64 {
201    if steps <= 0 {
202        return level_one_value;
203    }
204    let growth = level_one_value.saturating_mul(LAW_LEVEL_GROWTH_PERMYRIAD * steps) / 10_000;
205    level_one_value.saturating_add(growth)
206}
207
208/// A law's own upgrade scaling, before any bridge amplification.
209pub fn law_value_at_level(level_one_value: i64, level: i64) -> i64 {
210    grow_value(level_one_value, (level - 1).max(0))
211}
212
213/// The number an effect (or a passive modifier) actually lands with: the
214/// authored value, grown by the law's upgrade level, then multiplied by the
215/// bridge amplification.
216///
217/// This is the ONE function that applies amplification. Resonance and every
218/// condition parameter deliberately never pass through here — that separation
219/// is what makes acceptance #8 structural instead of a rule someone has to
220/// remember.
221pub fn law_effect_number(level_one_value: i64, level: i64, amplification: f64) -> i64 {
222    let leveled = law_value_at_level(level_one_value, level) as f64;
223    (leveled * amplification).round() as i64
224}
225
226/// Builds the live bridge charges from the character's bridges. A bridge is
227/// live only when BOTH of its laws are slotted — an unslotted law is not on a
228/// core, so it can neither fill a charge nor receive one.
229///
230/// The capacity and the amplification ceiling this combatant plays against are
231/// baked in here, once: the Bridge Law socket moves both per player, and every
232/// later read (resonance banking, amplification, the passive strip-and-refold)
233/// takes them off the charges rather than off config.
234pub fn build_law_bridge_charges(
235    config: &GameConfig,
236    cores: &CoresState,
237    mods: &LawColumnMods,
238) -> LawBridgeCharges {
239    let mut bridges = Vec::new();
240    for bridge in &cores.bridges {
241        if slotted_law_template(config, cores, bridge.real_law_id).is_none()
242            || slotted_law_template(config, cores, bridge.fantasy_law_id).is_none()
243        {
244            continue;
245        }
246        bridges.push(BridgeCharge::new(bridge.real_law_id, bridge.fantasy_law_id));
247    }
248    LawBridgeCharges::new(
249        bridges,
250        bridge_capacity_hundredths(config, mods),
251        mods.bridge_amplification_cap(config.cores_settings.bridge_amplification_cap()),
252    )
253}
254
255/// Capacity of one bridge direction for this build, in hundredths — the
256/// configured whole-unit capacity, moved by the Bridge Law socket, scaled to the
257/// accumulator's own unit.
258pub fn bridge_capacity_hundredths(config: &GameConfig, mods: &LawColumnMods) -> i64 {
259    mods.bridge_capacity(config.cores_settings.bridge_capacity())
260        .saturating_mul(CHARGE_SCALE)
261}
262
263/// Law Power multiplier currently enjoyed by `law_id`: `1.0` for an unbridged
264/// law, a law whose partner banked nothing, and every law at the very start of
265/// a fight.
266///
267/// Priced off the combatant's own capacity and ceiling, so a `BL-03 Short Span`
268/// build reaches its (lower) cap in fewer fires and a `BL-02 Deep Span` build
269/// needs more — which is the whole trade the Bridge Law socket sells.
270pub fn law_power_multiplier(charges: &LawBridgeCharges, law_id: LawTemplateId) -> f64 {
271    configs::cores::law_power_multiplier_for(
272        charges.delivered_units(law_id),
273        charges.capacity_hundredths / CHARGE_SCALE,
274        charges.amplification_cap_permyriad,
275    )
276}
277
278fn slotted_law_template<'a>(
279    config: &'a GameConfig,
280    cores: &'a CoresState,
281    law_id: LawTemplateId,
282) -> Option<&'a LawTemplate> {
283    let owned = cores.law(law_id)?;
284    owned.slot_index?;
285    law_template(config, law_id)
286}
287
288pub fn law_template(config: &GameConfig, law_id: LawTemplateId) -> Option<&LawTemplate> {
289    config.laws.iter().find(|law| law.id == law_id)
290}
291
292/// Attribute deltas contributed by the laws that are actually doing something:
293/// slotted, on the side that is currently up, at their current level, scaled by
294/// whatever their bridge delivered at the last flip.
295///
296/// Laws of the inactive side contribute nothing (acceptance #3) — that is the
297/// whole reason this is keyed on `active_side` rather than folded into the
298/// shared attribute aggregation the way the core multiplier is.
299///
300/// An inactive law (`is_active == false`, the mana-dependent catalog entries)
301/// contributes nothing either: it is content that exists but does not run.
302pub fn active_law_attribute_deltas(
303    config: &GameConfig,
304    cores: &CoresState,
305    active_side: WorldSide,
306    charges: &LawBridgeCharges,
307) -> BTreeMap<String, i64> {
308    let mut deltas: BTreeMap<String, i64> = BTreeMap::new();
309    for owned in cores.slotted_laws() {
310        let Some(template) = law_template(config, owned.template_id) else {
311            continue;
312        };
313        if template.side != active_side || !template.is_active {
314            continue;
315        }
316        let amplification = law_power_multiplier(charges, owned.template_id);
317        for modifier in &template.modifiers {
318            let Some(attribute) = config
319                .attributes
320                .iter()
321                .find(|attr| attr.id == modifier.attribute_id)
322            else {
323                continue;
324            };
325            let value = law_effect_number(modifier.base_value, owned.level.max(1), amplification);
326            *deltas.entry(attribute.code.clone()).or_insert(0) += value;
327        }
328    }
329    deltas
330}
331
332/// Why a bridge could not be created.
333#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
334pub enum BridgeError {
335    #[error("a law referenced by the bridge is not owned")]
336    LawNotOwned,
337    #[error("a law referenced by the bridge is not in a slot")]
338    LawNotSlotted,
339    #[error("both laws belong to the same core")]
340    SameCore,
341    #[error("this bridge already exists")]
342    Duplicate,
343    #[error("a law may belong to at most one bridge")]
344    LawAlreadyBridged,
345    #[error("bridge count would exceed min(core level) - 1")]
346    BridgeLimitReached,
347}
348
349/// Maximum number of bridges one law may sit in.
350///
351/// **One, since laws v0.2** (plan §4). A law has exactly one partner, so the
352/// build decision is which pairing to make rather than how many wires to run;
353/// the asymmetry between a frequent `+1` source and a rare `+5` one is the
354/// whole content of that choice.
355pub const MAX_BRIDGES_PER_LAW: usize = 1;
356
357/// Validates a would-be bridge and returns it side-normalized.
358pub fn validate_new_bridge(
359    config: &GameConfig,
360    cores: &CoresState,
361    law_a: LawTemplateId,
362    law_b: LawTemplateId,
363    extra_bridges: i64,
364) -> Result<LawBridge, BridgeError> {
365    let template_a = law_template(config, law_a).ok_or(BridgeError::LawNotOwned)?;
366    let template_b = law_template(config, law_b).ok_or(BridgeError::LawNotOwned)?;
367    let owned_a = cores.law(law_a).ok_or(BridgeError::LawNotOwned)?;
368    let owned_b = cores.law(law_b).ok_or(BridgeError::LawNotOwned)?;
369
370    if owned_a.slot_index.is_none() || owned_b.slot_index.is_none() {
371        return Err(BridgeError::LawNotSlotted);
372    }
373    if template_a.side == template_b.side {
374        return Err(BridgeError::SameCore);
375    }
376
377    let (real_law_id, fantasy_law_id) = if template_a.side == WorldSide::Real {
378        (law_a, law_b)
379    } else {
380        (law_b, law_a)
381    };
382    let bridge = LawBridge {
383        real_law_id,
384        fantasy_law_id,
385    };
386
387    if cores.bridges.contains(&bridge) {
388        return Err(BridgeError::Duplicate);
389    }
390    // Checked BEFORE the budget so re-wiring an already-bridged law reports the
391    // reason the player can act on ("this law already has a partner") instead
392    // of a budget message that would be wrong the moment the budget is large.
393    for law_id in [law_a, law_b] {
394        if cores.bridge_count_for_law(law_id) >= MAX_BRIDGES_PER_LAW {
395            return Err(BridgeError::LawAlreadyBridged);
396        }
397    }
398    if cores.bridges.len() as i64 + 1 > max_bridges(config, cores, extra_bridges) {
399        return Err(BridgeError::BridgeLimitReached);
400    }
401
402    Ok(bridge)
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use configs::tests_game_config::generate_game_config_for_tests;
409
410    fn cores(real_level: i64, fantasy_level: i64) -> CoresState {
411        CoresState {
412            real_level,
413            fantasy_level,
414            ..Default::default()
415        }
416    }
417
418    /// BAL-004: the first bridge opens exactly at Cores L2/L2 — the lower core
419    /// binds, so no uneven pair below it allows a bridge.
420    #[test]
421    fn first_bridge_opens_exactly_at_l2_l2() {
422        let config = generate_game_config_for_tests();
423        assert_eq!(max_bridges(&config, &cores(1, 1), 0), 0);
424        assert_eq!(max_bridges(&config, &cores(2, 1), 0), 0);
425        assert_eq!(max_bridges(&config, &cores(1, 5), 0), 0);
426        assert_eq!(max_bridges(&config, &cores(2, 2), 0), 1);
427        assert_eq!(max_bridges(&config, &cores(3, 2), 0), 1);
428    }
429
430    /// BL-01 Extra Span is worth exactly one bridge at every level, and a
431    /// negative modifier can never push the limit below zero.
432    #[test]
433    fn extra_span_adds_exactly_one_bridge() {
434        let config = generate_game_config_for_tests();
435        assert_eq!(max_bridges(&config, &cores(1, 1), 1), 1);
436        assert_eq!(max_bridges(&config, &cores(2, 2), 1), 2);
437        assert_eq!(max_bridges(&config, &cores(1, 1), -3), 0);
438    }
439
440    /// The `max_slot_level` clamp keeps the limit binding now that core levels
441    /// are unbounded; without it the bridge limit would stop meaning anything.
442    #[test]
443    fn slot_ladder_clamps_unbounded_core_levels() {
444        let config = generate_game_config_for_tests();
445        let cap = config.cores_settings.max_slot_level();
446        assert_eq!(max_bridges(&config, &cores(9999, 9999), 0), cap - 1);
447        assert_eq!(max_bridges(&config, &cores(9999, 9999), 1), cap);
448    }
449}