overlord_event_system/mechanics/stones.rs
1//! Trigger/Effect Stone combat runtime — the arithmetic and the bookkeeping
2//! keys. The orchestration is [`crate::logic::stones`].
3//!
4//! Per-fight trigger state lives in the player entity's `attributes`, the same
5//! idiom as `stun_until_tick` and `stagger_procs`: the fight rebuilds its
6//! entities, so the state resets by construction.
7//!
8//! Keys are namespaced `stone.*` so they cannot collide with a stat code —
9//! [`crate::mechanics::fight::get_entity_stat`] composes `<code>`,
10//! `<code>.bonus` and `<code>.mod`.
11
12use essences::entity::Entity;
13use essences::items::AttributeId;
14use essences::stones::{StoneInventory, StoneKind};
15
16use configs::stones::{EffectStoneTemplate, StonesSettings, TriggerStoneTemplate};
17
18/// Tick at which the whole build may fire again — one key, not one per slot.
19/// A condition that comes true inside the window is lost, and the sequence
20/// counter behind it restarts ([`reset_sequence_counters`]).
21pub const GLOBAL_COOLDOWN: &str = "stone.cd";
22
23/// BAL-027 `Battle Rhythm`: fight-clock tick at which the interval trigger in
24/// this socket fires next. `0` (absent) means "not armed yet" — the first
25/// sighting arms the metronome without firing.
26pub fn interval_due_key(item_type: essences::items::ItemType) -> String {
27 format!("stone.interval_due.{item_type}")
28}
29
30/// Damage bonus armed for the caster's next attacks, in permyriad
31/// (`2_000` = +20%). Consumed by [`crate::mechanics::fight::attack`], one
32/// charge per damaging swing.
33pub const NEXT_ATTACK_BONUS: &str = "stone.next_attack_bonus";
34
35/// How many more attacks [`NEXT_ATTACK_BONUS`] applies to. `EF-C05` arms one,
36/// `EF-R06` three, `EF-E07` five.
37pub const NEXT_ATTACK_BONUS_CHARGES: &str = "stone.na_bonus_n";
38
39/// Crit-chance bonus armed for the caster's next attacks, in permyriad, with
40/// its own charge count. Consumed by [`crate::mechanics::fight::attack`].
41pub const NEXT_ATTACK_CRIT: &str = "stone.na_crit";
42pub const NEXT_ATTACK_CRIT_CHARGES: &str = "stone.na_crit_n";
43
44/// Extra swings armed for the caster's next cast. Consumed by
45/// [`crate::mechanics::fight::cast`], which queues them through a `Proc` sink.
46pub const NEXT_ATTACK_EXTRA_HITS: &str = "stone.next_attack_extra_hits";
47
48/// How many critical hits the player has landed in an unbroken row.
49pub const CRIT_STREAK: &str = "stone.crit_streak";
50
51// ---- Trigger memory -------------------------------------------------------
52
53/// Sequence counters, one per condition family — the common tier counts events
54/// ("every 5th attack") rather than reacting to each. A counter that completes
55/// while on cooldown restarts from zero rather than banking its activation.
56pub const SEQ_BASIC_ATTACK: &str = "stone.n.atk";
57pub const SEQ_HIT_TAKEN: &str = "stone.n.hit";
58pub const SEQ_SKILL_CAST: &str = "stone.n.skill";
59pub const SEQ_DEFEAT: &str = "stone.n.kill";
60
61/// `1` while the previous Core hit the player landed was a critical one —
62/// `TR-R06` Corrected Aim needs "crit straight after non-crit".
63pub const PREV_HIT_CRIT: &str = "stone.m.prev_crit";
64
65/// `1` between an original Skill Cast and the next Basic Attack (`TR-C06`).
66pub const AFTER_SKILL: &str = "stone.m.after_skill";
67
68/// Slot index + 1 of the previous original Skill; `0` = none yet (`TR-R05`).
69pub const LAST_SKILL: &str = "stone.m.last_skill";
70
71/// Original Skills cast in a row without taking Core damage (`TR-L05`).
72pub const SKILLS_WITHOUT_DAMAGE: &str = "stone.m.no_dmg_skills";
73
74/// Lowest HP the hero has been at this phase, in permyriad of Max HP
75/// (`TR-E03`). Absent reads as full health — see [`hp_low_water`].
76///
77/// A low-water mark, not a per-threshold latch: it only ever falls, so every
78/// threshold is crossed exactly once per phase however much the hero heals.
79pub const HP_LOW_WATER: &str = "stone.m.hp_low";
80
81/// How many recent Core Dodges the runtime remembers, i.e. the largest `N` a
82/// "N dodges within a window" condition can ask for.
83pub const DODGE_RING: usize = 4;
84
85/// Tick + 1 of the `index`-th most recent Core Dodge (`0` = the current one);
86/// `0` means "no such dodge yet" (`TR-L02`).
87///
88/// A ring of ticks rather than a tumbling-window counter: the condition is "N
89/// dodges inside the last W ticks", which the actual times answer without an
90/// edge case at the window boundary.
91pub fn dodge_tick_key(index: usize) -> String {
92 format!("stone.m.dg{index}")
93}
94
95/// The hero's HP as permyriad of Max HP.
96pub fn hp_permyriad(entity: &Entity) -> i64 {
97 let max_hp = entity.max_hp.max(1) as i128;
98 (entity.hp as i128 * 10_000 / max_hp) as i64
99}
100
101/// The phase's low-water HP mark. An absent key means the mark has never been
102/// written, which is full health — not zero, which would read as "already dead"
103/// and disarm every HP threshold for the whole fight.
104pub fn hp_low_water(entity: &Entity) -> i64 {
105 match attr(entity, HP_LOW_WATER) {
106 0 => 10_000,
107 recorded => recorded,
108 }
109}
110
111/// What the player is currently swinging: [`CAST_KIND_BASIC`] or
112/// [`CAST_KIND_SKILL`], `0` before the first cast.
113///
114/// A `Damage` event carries no "which ability produced me" field, but the
115/// `CastAbility` that produced it arrives first, so the runtime remembers the
116/// kind and reads it back when the hit lands.
117pub const CAST_KIND: &str = "stone.m.cast_kind";
118pub const CAST_KIND_BASIC: i64 = 1;
119pub const CAST_KIND_SKILL: i64 = 2;
120
121/// Mana the resolving cast actually paid, in x100 fixed point PLUS ONE — `0`
122/// means "no payment recorded", so a genuinely free cast (`1`) is still
123/// distinguishable from a cast that never went through the pool at all.
124///
125/// Written at the mana gate (`handle_start_cast_ability`), where the one true
126/// cost — after stone rescaling and the `Budget Plan` discount, without the
127/// `Open Tab` surcharge — is decided. The mana conditions of laws
128/// (`SkillManaAtMost` / `SkillManaAtLeast`) and trigger stones (`TR-C03` /
129/// `TR-E02`) read it back when the cast resolves, so both systems judge the
130/// price actually paid instead of re-deriving it and drifting.
131pub const PAID_MANA: &str = "stone.m.paid_mana";
132
133/// `1` once every equipped Skill has been cast at least once this phase
134/// (`TR-L03`). A latch, so the condition fires on the cast that completed the
135/// spellbook and not on every cast afterwards.
136pub const SPELLBOOK_DONE: &str = "stone.m.spellbook";
137
138/// `TR-R04` Wide Cast counts how many targets one original Skill reached.
139///
140/// A multi-target cast arrives as one `CastAbility` per target, all inside the
141/// cast's own animation window, so "reached N targets" is "the N-th event of the
142/// same Skill inside [`CAST_RUN_WINDOW_TICKS`]": `CAST_RUN_TICK` is when the run
143/// opened, `CAST_RUN_SLOT` which Skill it is, `CAST_RUN_COUNT` how many events
144/// have arrived.
145pub const CAST_RUN_TICK: &str = "stone.m.crun_t";
146pub const CAST_RUN_SLOT: &str = "stone.m.crun_s";
147pub const CAST_RUN_COUNT: &str = "stone.m.crun_n";
148
149/// How long one cast's per-target events may take to arrive. Half a second —
150/// the shipped cast animation. Two separate casts of the same Skill are always
151/// further apart (ability cooldowns are tens of seconds), so the window cannot
152/// merge them into one oversized "wide" cast.
153pub const CAST_RUN_WINDOW_TICKS: i64 = 500;
154
155/// `TR-L04` Perfect Sequence is a three-step state machine: Skill → Basic
156/// Attack → *different* Skill, all inside one window. `SEQ_STAGE` is how far it
157/// has come (`0`/`1`/`2`), `SEQ_STAGE_TICK` when it started, `SEQ_STAGE_SKILL`
158/// which Skill opened it (so "another" can be checked).
159pub const SEQ_STAGE: &str = "stone.m.seq_stage";
160pub const SEQ_STAGE_TICK: &str = "stone.m.seq_t";
161pub const SEQ_STAGE_SKILL: &str = "stone.m.seq_skill";
162
163// ---- Effect state ---------------------------------------------------------
164//
165// Armed by an Effect Stone fire, spent by whoever the effect acts on. Charge
166// keys pair with a magnitude key; both are cleared together when the last
167// charge is spent, so a stale magnitude can never apply for free.
168
169/// Incoming-damage reduction armed for the next hits taken, permyriad + charges
170/// (`EF-C04`).
171pub const HIT_REDUCTION: &str = "stone.dr_pct";
172pub const HIT_REDUCTION_CHARGES: &str = "stone.dr_n";
173
174/// Timed incoming-damage reduction, permyriad (`EF-R04`). Expiry is the
175/// scheduled inverse, like every other timed stone attribute.
176pub const INCOMING_REDUCTION: &str = "stone.dr_timed";
177
178/// Fully blocked hits with a retaliation behind them (`EF-E06`): charges, plus
179/// the retaliation damage precomputed at arm time so the block site needs no
180/// lookup of the attacker.
181pub const GUARD_CHARGES: &str = "stone.guard_n";
182pub const GUARD_RETALIATION: &str = "stone.guard_dmg";
183
184/// While positive, Core damage may not take the player below 1 HP (`EF-L05`).
185pub const DAMAGE_FLOOR: &str = "stone.floor";
186
187/// Share of landed Core damage healed back, permyriad, timed (`EF-R05`).
188pub const LIFESTEAL: &str = "stone.lifesteal";
189
190/// Timed outgoing bonuses, permyriad: all damage (`EF-L06`) and original Skills
191/// only (`EF-R03`). Both land as derived damage on top of the real hit, so they
192/// can never re-enter a trigger.
193pub const DAMAGE_BUFF: &str = "stone.dmg_buff";
194pub const SKILL_BUFF: &str = "stone.skill_buff";
195
196/// One-shot original-Skill payload bonus, permyriad + charges (`EF-C08`,
197/// `EF-L08`).
198pub const NEXT_SKILL_BONUS: &str = "stone.ns_bonus";
199pub const NEXT_SKILL_BONUS_CHARGES: &str = "stone.ns_bonus_n";
200
201/// Splash armed on the next original Skills (`EF-R08`) and on the next Basic
202/// Attacks (`EF-L03`): permyriad of Attack dealt to every living enemy.
203pub const NEXT_SKILL_SPLASH: &str = "stone.ns_splash";
204pub const NEXT_SKILL_SPLASH_CHARGES: &str = "stone.ns_splash_n";
205
206/// Derived copies spread off the next original Skill onto a **bounded** number
207/// of extra targets (`EF-E08`): the share of that Skill's own landed payload
208/// each copy carries, and how many additional targets it may reach.
209///
210/// Its own key pair, not the splash pair: splash is a flat share of Attack
211/// against the whole enemy line, a split copy is a share of *this* hit against
212/// a capped number of targets, and sharing keys would let one overwrite the
213/// other when both are socketed.
214pub const NEXT_SKILL_SPLIT: &str = "stone.ns_split";
215pub const NEXT_SKILL_SPLIT_TARGETS: &str = "stone.ns_split_n";
216pub const NEXT_ATTACK_SPLASH: &str = "stone.na_splash";
217pub const NEXT_ATTACK_SPLASH_CHARGES: &str = "stone.na_splash_n";
218
219/// Derived copies of the next original Skill (`EF-E01`, `EF-L02`): the strong
220/// copy, the weak one, and how many copies are owed.
221pub const NEXT_SKILL_ECHO: &str = "stone.ns_echo";
222pub const NEXT_SKILL_ECHO_WEAK: &str = "stone.ns_echo2";
223pub const NEXT_SKILL_ECHO_CHARGES: &str = "stone.ns_echo_n";
224
225/// Derived hits added to the next Basic Attack (`EF-E02`): permyriad of Attack
226/// per hit, and how many hits.
227pub const NEXT_ATTACK_DERIVED: &str = "stone.na_derived";
228pub const NEXT_ATTACK_DERIVED_CHARGES: &str = "stone.na_derived_n";
229
230/// Every sequence counter, for the "an activation lost on cooldown restarts the
231/// count" rule (acceptance criterion #5).
232pub const SEQUENCE_COUNTERS: [&str; 4] =
233 [SEQ_BASIC_ATTACK, SEQ_HIT_TAKEN, SEQ_SKILL_CAST, SEQ_DEFEAT];
234
235/// How many equipped Skill slots the runtime remembers per phase. A build with
236/// fewer equipped Skills leaves the tail keys at zero.
237pub const SKILL_SLOTS: usize = 5;
238
239/// Tick + 1 of the last cast of the player's equipped Skill in slot `index`;
240/// `0` = not cast this phase.
241///
242/// Separate keys rather than a packed bitmask: `TR-E05` asks how many slots
243/// were used inside a window, which needs the individual ticks.
244pub fn skill_last_cast_key(index: usize) -> String {
245 format!("stone.m.sk{index}")
246}
247
248/// Raw attribute read; a missing key is zero.
249pub fn attr(entity: &Entity, key: &str) -> i64 {
250 entity.attributes.0.get(key).copied().unwrap_or(0)
251}
252
253/// Zeroes every sequence counter, called when a condition came true while the
254/// global cooldown was running.
255///
256/// All-or-nothing rather than per-stone: the counters are per event family and
257/// shared by every stone counting that family, so leaving one alive would let a
258/// second stone inherit a count the player already spent.
259pub fn reset_sequence_counters(entity: &mut Entity) {
260 for key in SEQUENCE_COUNTERS {
261 entity.attributes.set(key, 0);
262 }
263}
264
265/// The magnitude of a `(magnitude, charges)` pair, or `None` when it is spent.
266/// Reading is separate from spending because the fight primitives only see
267/// `&Entity`, while the stone runtime holds it mutably.
268pub fn armed_magnitude(entity: &Entity, magnitude_key: &str, charges_key: &str) -> Option<i64> {
269 let magnitude = attr(entity, magnitude_key);
270 (attr(entity, charges_key) > 0 && magnitude != 0).then_some(magnitude)
271}
272
273/// Effect strength for one fire: the template's own scaling by upgrade level,
274/// times the `(trigger tier × effect tier)` coefficient. Every shipped cell of
275/// that matrix is `1.0`, so tier interaction stays a config edit.
276pub fn effect_magnitude(
277 settings: &StonesSettings,
278 trigger: &TriggerStoneTemplate,
279 effect: &EffectStoneTemplate,
280 effect_level: i64,
281) -> f64 {
282 let levels_above_first = (effect_level - 1).max(0) as f64;
283 let base = effect.magnitude + effect.magnitude_per_level * levels_above_first;
284 base * settings.tier_coefficient(trigger.tier, effect.tier)
285}
286
287/// Every passive stat the character's **socketed** stones grant, as
288/// `(attribute, value)` pairs ready to fold into an attribute delta map.
289///
290/// Three properties of this function are the whole feature, and each has a test
291/// in `tests/test_stone_stats.rs`:
292///
293/// * an unsocketed stone contributes nothing — a stone is paid for by putting
294/// it in a slot, not by owning it;
295/// * `active_side` is never read, so a slot's Real and Fantasy effect stones
296/// both contribute at all times and a flip cannot move stats or Power;
297/// * a stone's upgrade level scales its stats
298/// ([`configs::stones::StoneStat::value_at_level`]), the same shape
299/// [`effect_magnitude`] uses.
300///
301/// Locked sockets need no check here: `StoneInventory::insert` refuses a locked
302/// socket, so a socketed stone is by construction in an open one.
303///
304/// A stone whose template is missing from the config (content pulled from under
305/// a live inventory) is skipped and logged, never fatal — same rule as
306/// [`crate::game_config_helpers::GameConfigLookup::trigger_stone_template`].
307pub fn socketed_stat_bonuses(
308 config: &configs::game_config::GameConfig,
309 inventory: &StoneInventory,
310) -> Vec<(AttributeId, i64)> {
311 use crate::game_config_helpers::GameConfigLookup;
312
313 let mut bonuses = Vec::new();
314 for (kind, stone) in inventory.all() {
315 if !stone.is_socketed() {
316 continue;
317 }
318 let stats = match kind {
319 StoneKind::Trigger => config
320 .trigger_stone_template(stone.template_id)
321 .map(|t| &t.stats),
322 StoneKind::Effect => config
323 .effect_stone_template(stone.template_id)
324 .map(|t| &t.stats),
325 };
326 let Some(stats) = stats else {
327 tracing::warn!(
328 "Socketed stone has no {kind} template {} in config — its stats are skipped",
329 stone.template_id
330 );
331 continue;
332 };
333 for stat in stats {
334 bonuses.push((stat.attribute_id, stat.value_at_level(stone.level)));
335 }
336 }
337 bonuses
338}
339
340/// Whether a crit streak of `streak` satisfies "`required` crits in a row".
341///
342/// Fires on every `required`-th crit (2, 4, 6 … for `TR-E04`) rather than
343/// resetting a counter, because one counter is shared by every socketed slot:
344/// a reset by whichever slot happened to match first would silently starve the
345/// others — `TR-L01` (three in a row) would never see a third crit if `TR-E04`
346/// zeroed the streak on the second. The global cooldown is what bounds the rate.
347pub fn crit_streak_met(streak: i64, required: i64) -> bool {
348 let required = required.max(1);
349 streak >= required && streak % required == 0
350}
351
352/// Whether an event counter of `count` satisfies "every `period`-th".
353///
354/// Same modulo shape as [`crit_streak_met`] and for the same reason: `TR-C01`
355/// (every 5th attack) and a hypothetical every-3rd trigger share one counter,
356/// so neither may zero it. What *does* zero it is the rule in acceptance
357/// criterion #5 — an activation lost to the global cooldown restarts the
358/// sequence, which is [`reset_sequence_counters`]'s job.
359pub fn counter_met(count: i64, period: i64) -> bool {
360 let period = period.max(1);
361 count > 0 && count % period == 0
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use configs::stones::{EffectStoneAction, TriggerCondition};
368 use essences::stones::StoneTier;
369
370 fn trigger(tier: StoneTier) -> TriggerStoneTemplate {
371 TriggerStoneTemplate {
372 id: uuid::Uuid::from_u128(1),
373 name: i18n::I18nString::Translated(String::new()),
374 description: i18n::I18nString::Translated(String::new()),
375 tier,
376 condition: TriggerCondition::OnCrit,
377 condition_value: 0,
378 condition_window_ticks: 0,
379 gauge_gain: 1.0,
380 power_proc_rate: 0.75,
381 active: true,
382 stats: Vec::new(),
383 icon_path: String::new(),
384 }
385 }
386
387 fn effect(tier: StoneTier) -> EffectStoneTemplate {
388 EffectStoneTemplate {
389 id: uuid::Uuid::from_u128(2),
390 name: i18n::I18nString::Translated(String::new()),
391 description: i18n::I18nString::Translated(String::new()),
392 tier,
393 action: EffectStoneAction::InstantDamage,
394 magnitude: 30.0,
395 magnitude_per_level: 10.0,
396 secondary_magnitude: 0.0,
397 charges: 0,
398 duration_ticks: 0,
399 power_q_base_first_rank: 1.1,
400 power_q_base_max_rank: 1.3,
401 stats: Vec::new(),
402 icon_path: String::new(),
403 }
404 }
405
406 fn settings(multiplier: f64) -> StonesSettings {
407 use configs::stones::{
408 StoneKillDropSettings, StoneRarityWeight, StoneTierCoefficient, StoneUpgradeStep,
409 };
410 use strum::IntoEnumIterator;
411
412 StonesSettings {
413 global_trigger_cooldown_ticks: 1_000,
414 // Not exercised here (these tests cover the tier-coefficient
415 // matrix), but the field is required — no serde default.
416 rarity_weights: StoneTier::iter()
417 .map(|tier| StoneRarityWeight { tier, weight: 1.0 })
418 .collect(),
419 tier_coefficients: StoneTier::iter()
420 .flat_map(|trigger_tier| {
421 StoneTier::iter().map(move |effect_tier| StoneTierCoefficient {
422 trigger_tier,
423 effect_tier,
424 multiplier,
425 })
426 })
427 .collect(),
428 socket_unlocks: Vec::new(),
429 upgrade_ladder: vec![StoneUpgradeStep {
430 level: 2,
431 copies: 2,
432 }],
433 max_stone_level: 10,
434 kill_drop: StoneKillDropSettings {
435 chance: 0.0,
436 trigger_share: 0.5,
437 },
438 milestone_grants: vec![],
439 }
440 }
441
442 /// Acceptance criterion #5, half one: at 1.0 the matrix is the identity, so
443 /// strength is exactly the template's own level scaling.
444 #[test]
445 fn an_all_ones_matrix_is_indistinguishable_from_no_matrix() {
446 let settings = settings(1.0);
447 let trigger = trigger(StoneTier::Common);
448 let effect = effect(StoneTier::Epic);
449
450 assert_eq!(effect_magnitude(&settings, &trigger, &effect, 1), 30.0);
451 assert_eq!(effect_magnitude(&settings, &trigger, &effect, 3), 50.0);
452 }
453
454 /// Half two: the matrix is genuinely wired to strength, so the chat's
455 /// version is a config edit away.
456 #[test]
457 fn the_matrix_scales_effect_strength() {
458 let halved = settings(0.5);
459 let trebled = settings(3.0);
460 let trigger = trigger(StoneTier::Common);
461 let effect = effect(StoneTier::Epic);
462
463 assert_eq!(effect_magnitude(&halved, &trigger, &effect, 1), 15.0);
464 assert_eq!(effect_magnitude(&trebled, &trigger, &effect, 1), 90.0);
465 }
466
467 /// A missing cell falls back to 1.0 rather than to zero strength — an
468 /// incomplete matrix must not silently disable every effect.
469 #[test]
470 fn a_missing_cell_reads_as_no_modifier() {
471 let mut settings = settings(1.0);
472 settings.tier_coefficients.clear();
473 let trigger = trigger(StoneTier::Rare);
474 let effect = effect(StoneTier::Rare);
475
476 assert_eq!(effect_magnitude(&settings, &trigger, &effect, 1), 30.0);
477 }
478
479 #[test]
480 fn a_streak_trigger_fires_every_nth_crit() {
481 let met: Vec<i64> = (0..=6).filter(|n| crit_streak_met(*n, 2)).collect();
482 assert_eq!(
483 met,
484 vec![2, 4, 6],
485 "every second crit, not every crit after"
486 );
487
488 // A zero/negative `condition_value` must not divide by zero.
489 assert!(crit_streak_met(1, 0));
490 }
491
492 #[test]
493 fn a_counter_trigger_fires_every_nth_event() {
494 let met: Vec<i64> = (0..=11).filter(|n| counter_met(*n, 5)).collect();
495 assert_eq!(
496 met,
497 vec![5, 10],
498 "every fifth attack, not every attack after"
499 );
500 assert!(!counter_met(0, 5), "no events is not a fired condition");
501 // A zero/negative period must not divide by zero.
502 assert!(counter_met(1, 0));
503 }
504
505 /// The cooldown is ONE key for the whole build (design v0.2 §2) — there is
506 /// no per-slot variant left to accidentally reintroduce.
507 #[test]
508 fn bookkeeping_keys_are_namespaced_and_the_cooldown_is_global() {
509 assert_eq!(GLOBAL_COOLDOWN, "stone.cd");
510
511 let mut keys = vec![
512 GLOBAL_COOLDOWN.to_string(),
513 CRIT_STREAK.to_string(),
514 NEXT_ATTACK_BONUS.to_string(),
515 NEXT_ATTACK_BONUS_CHARGES.to_string(),
516 NEXT_ATTACK_CRIT.to_string(),
517 NEXT_ATTACK_CRIT_CHARGES.to_string(),
518 NEXT_ATTACK_EXTRA_HITS.to_string(),
519 PREV_HIT_CRIT.to_string(),
520 AFTER_SKILL.to_string(),
521 LAST_SKILL.to_string(),
522 SKILLS_WITHOUT_DAMAGE.to_string(),
523 HP_LOW_WATER.to_string(),
524 CAST_KIND.to_string(),
525 SEQ_STAGE.to_string(),
526 SEQ_STAGE_TICK.to_string(),
527 SEQ_STAGE_SKILL.to_string(),
528 SPELLBOOK_DONE.to_string(),
529 CAST_RUN_TICK.to_string(),
530 CAST_RUN_SLOT.to_string(),
531 CAST_RUN_COUNT.to_string(),
532 HIT_REDUCTION.to_string(),
533 HIT_REDUCTION_CHARGES.to_string(),
534 INCOMING_REDUCTION.to_string(),
535 GUARD_CHARGES.to_string(),
536 GUARD_RETALIATION.to_string(),
537 DAMAGE_FLOOR.to_string(),
538 LIFESTEAL.to_string(),
539 DAMAGE_BUFF.to_string(),
540 SKILL_BUFF.to_string(),
541 NEXT_SKILL_BONUS.to_string(),
542 NEXT_SKILL_BONUS_CHARGES.to_string(),
543 NEXT_SKILL_SPLASH.to_string(),
544 NEXT_SKILL_SPLASH_CHARGES.to_string(),
545 NEXT_SKILL_SPLIT.to_string(),
546 NEXT_SKILL_SPLIT_TARGETS.to_string(),
547 NEXT_ATTACK_SPLASH.to_string(),
548 NEXT_ATTACK_SPLASH_CHARGES.to_string(),
549 NEXT_SKILL_ECHO.to_string(),
550 NEXT_SKILL_ECHO_WEAK.to_string(),
551 NEXT_SKILL_ECHO_CHARGES.to_string(),
552 NEXT_ATTACK_DERIVED.to_string(),
553 NEXT_ATTACK_DERIVED_CHARGES.to_string(),
554 ];
555 keys.extend(SEQUENCE_COUNTERS.iter().map(|k| k.to_string()));
556 keys.extend((0..5).map(skill_last_cast_key));
557 keys.extend((0..DODGE_RING).map(dodge_tick_key));
558
559 for key in &keys {
560 assert!(key.starts_with("stone."), "{key} may collide with a stat");
561 }
562 let unique: std::collections::HashSet<&String> = keys.iter().collect();
563 assert_eq!(unique.len(), keys.len(), "two pieces of state share a key");
564 }
565
566 #[test]
567 fn an_armed_magnitude_needs_both_a_value_and_a_charge() {
568 let mut entity = Entity::default();
569 entity.attributes.set(NEXT_ATTACK_BONUS, 6_000);
570 assert_eq!(
571 armed_magnitude(&entity, NEXT_ATTACK_BONUS, NEXT_ATTACK_BONUS_CHARGES),
572 None,
573 "a magnitude with no charges is spent"
574 );
575 entity.attributes.set(NEXT_ATTACK_BONUS_CHARGES, 3);
576 assert_eq!(
577 armed_magnitude(&entity, NEXT_ATTACK_BONUS, NEXT_ATTACK_BONUS_CHARGES),
578 Some(6_000)
579 );
580 }
581}