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}