overlord_event_system/mechanics/
class_passives.rs

1//! The always-on rule of a class (BAL-033/BAL-034).
2//!
3//! A passive belongs to the COMBATANT, not to the session: the hero, a party
4//! ally and a human PvP opponent each play their own class's rule at their own
5//! Class Level. So the resolved numbers are baked onto the entity when it is
6//! built — the same shape the stone and artifact runtimes use — and every hook
7//! afterwards reads them off whoever is acting.
8//!
9//! Baking rather than looking the class up per hook is what keeps the rule
10//! honest in PvP: the opponent's entity carries the opponent's magnitudes even
11//! though the session's config lookups would happily answer with the hero's
12//! class.
13
14use essences::class::{Class, ClassPassive};
15use essences::entity::Entity;
16
17/// Which passive this combatant plays, as the enum's ordinal plus one (`0` =
18/// none, so an untouched attribute reads as "no passive").
19pub const PASSIVE_KIND: &str = "class.passive";
20
21/// The passive's magnitude at this combatant's Class Level, in permyriad
22/// (`200` = `2%`).
23pub const PASSIVE_PCT: &str = "class.passive.pct";
24
25/// Fight tick the periodic passive fires next, plus one — `0` means "not
26/// scheduled", which is what an entity with no periodic passive reads as.
27pub const PASSIVE_DUE: &str = "class.passive.due";
28
29const KIND_BLOCK_HEAL: i64 = 1;
30const KIND_DOUBLE_STRIKE: i64 = 2;
31const KIND_MULTICAST: i64 = 3;
32const KIND_LOWEST_ALLY_HEAL: i64 = 4;
33
34fn kind_marker(passive: ClassPassive) -> i64 {
35    match passive {
36        ClassPassive::None => 0,
37        ClassPassive::BlockHeal => KIND_BLOCK_HEAL,
38        ClassPassive::DoubleStrike => KIND_DOUBLE_STRIKE,
39        ClassPassive::Multicast => KIND_MULTICAST,
40        ClassPassive::LowestAllyHeal => KIND_LOWEST_ALLY_HEAL,
41    }
42}
43
44/// The magnitude at `class_level`, interpolated linearly between the authored
45/// L1 and L20 endpoints and expressed in permyriad.
46pub fn magnitude_permyriad(class: &Class, class_level: i64) -> i64 {
47    let t = ((class_level.max(1) - 1) as f64 / 19.0).clamp(0.0, 1.0);
48    let percent =
49        class.passive_percent_l1 + (class.passive_percent_l20 - class.passive_percent_l1) * t;
50    (percent * 100.0).round() as i64
51}
52
53/// Bakes `class`'s passive onto a freshly built combatant. A class with no
54/// passive writes nothing, so an entity that never had one stays clean.
55pub fn seed(entity: &mut Entity, class: &Class, class_level: i64) {
56    if class.passive == ClassPassive::None {
57        return;
58    }
59    entity
60        .attributes
61        .set(PASSIVE_KIND, kind_marker(class.passive));
62    entity
63        .attributes
64        .set(PASSIVE_PCT, magnitude_permyriad(class, class_level));
65    if class.passive.is_periodic() {
66        // The first tick is one whole period into the fight, and the timer is
67        // fight-local: it starts here and dies with the entity.
68        entity
69            .attributes
70            .set(PASSIVE_DUE, class.passive_period_ticks as i64 + 1);
71    }
72}
73
74fn marker(entity: &Entity) -> i64 {
75    entity.attributes.0.get(PASSIVE_KIND).copied().unwrap_or(0)
76}
77
78fn share(entity: &Entity) -> f64 {
79    entity.attributes.0.get(PASSIVE_PCT).copied().unwrap_or(0) as f64 / 10_000.0
80}
81
82/// How much this combatant heals on a successful Block, or `0.0` when Block
83/// heals them nothing.
84pub fn block_heal(entity: &Entity) -> f64 {
85    if marker(entity) != KIND_BLOCK_HEAL {
86        return 0.0;
87    }
88    share(entity) * entity.max_hp as f64
89}
90
91/// The chance this combatant's original Basic Attack repeats itself, in
92/// `0.0..=1.0`.
93pub fn double_strike_chance(entity: &Entity) -> f64 {
94    if marker(entity) != KIND_DOUBLE_STRIKE {
95        return 0.0;
96    }
97    share(entity).clamp(0.0, 1.0)
98}
99
100/// Marks the NEXT Core basic cast of this entity as the repeat produced by
101/// Double Strike, so it does not roll another one. A repeat is otherwise a
102/// completely ordinary Core swing.
103pub const REPEAT_MARK: &str = "class.passive.repeat";
104
105/// Sets the one-shot repeat mark.
106pub fn mark_repeat(entity: &mut Entity) {
107    entity.attributes.set(REPEAT_MARK, 1);
108}
109
110/// Consumes the repeat mark, answering whether this cast IS the repeat.
111pub fn take_repeat_mark(entity: &mut Entity) -> bool {
112    if entity.attributes.0.get(REPEAT_MARK).copied().unwrap_or(0) == 0 {
113        return false;
114    }
115    entity.attributes.0.remove(REPEAT_MARK);
116    true
117}
118
119/// Marks the NEXT Core skill cast of this entity as the copy produced by
120/// Multicast, so it does not roll another one.
121///
122/// Multicast is deliberately NOT read off `PASSIVE_KIND`: for the Mage the
123/// passive and the `multicast_chance` specialization stat are the same thing
124/// (BAL-034), so the roll stays on the stat — which also lets gear and facets
125/// grant Multicast to any class without a second, parallel rule.
126pub const MULTICAST_MARK: &str = "class.passive.multicast";
127
128/// Sets the one-shot Multicast mark.
129pub fn mark_multicast(entity: &mut Entity) {
130    entity.attributes.set(MULTICAST_MARK, 1);
131}
132
133/// Consumes the Multicast mark, answering whether this cast IS the copy.
134pub fn take_multicast_mark(entity: &mut Entity) -> bool {
135    if entity
136        .attributes
137        .0
138        .get(MULTICAST_MARK)
139        .copied()
140        .unwrap_or(0)
141        == 0
142    {
143        return false;
144    }
145    entity.attributes.0.remove(MULTICAST_MARK);
146    true
147}
148
149/// `(heal amount share, period)` of a periodic ally heal, or `None`.
150pub fn periodic_heal_share(entity: &Entity) -> Option<f64> {
151    (marker(entity) == KIND_LOWEST_ALLY_HEAL).then(|| share(entity))
152}
153
154/// Whether the periodic passive is due at `tick`.
155pub fn periodic_is_due(entity: &Entity, tick: u64) -> bool {
156    let due = entity.attributes.0.get(PASSIVE_DUE).copied().unwrap_or(0);
157    due > 0 && tick + 1 >= due as u64
158}
159
160/// The tick the periodic passive should next fire at, given it just fired at
161/// `tick`.
162pub fn next_due(period_ticks: u64, tick: u64) -> i64 {
163    (tick + period_ticks.max(1)) as i64 + 1
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn class(passive: ClassPassive, l1: f64, l20: f64) -> Class {
171        Class {
172            passive,
173            passive_percent_l1: l1,
174            passive_percent_l20: l20,
175            ..Default::default()
176        }
177    }
178
179    #[test]
180    fn the_magnitude_walks_from_l1_to_l20() {
181        let rogue = class(ClassPassive::DoubleStrike, 5.0, 17.0);
182        assert_eq!(magnitude_permyriad(&rogue, 1), 500);
183        assert_eq!(magnitude_permyriad(&rogue, 20), 1700);
184        // Halfway up the ladder is halfway between the endpoints.
185        assert_eq!(magnitude_permyriad(&rogue, 10), 1068);
186        // Out-of-range levels clamp instead of extrapolating.
187        assert_eq!(magnitude_permyriad(&rogue, 0), 500);
188        assert_eq!(magnitude_permyriad(&rogue, 99), 1700);
189    }
190
191    #[test]
192    fn a_flat_passive_stays_flat() {
193        let warrior = class(ClassPassive::BlockHeal, 2.0, 2.0);
194        for level in [1, 7, 20] {
195            assert_eq!(magnitude_permyriad(&warrior, level), 200);
196        }
197    }
198
199    #[test]
200    fn a_class_without_a_passive_bakes_nothing() {
201        let mut entity = Entity::default();
202        seed(&mut entity, &class(ClassPassive::None, 0.0, 0.0), 20);
203        assert!(!entity.attributes.0.contains_key(PASSIVE_KIND));
204        assert_eq!(block_heal(&entity), 0.0);
205        assert_eq!(double_strike_chance(&entity), 0.0);
206        assert!(periodic_heal_share(&entity).is_none());
207    }
208
209    #[test]
210    fn each_passive_answers_only_its_own_hook() {
211        let mut warrior = Entity {
212            max_hp: 1_000,
213            ..Default::default()
214        };
215        seed(&mut warrior, &class(ClassPassive::BlockHeal, 2.0, 2.0), 1);
216        assert_eq!(block_heal(&warrior), 20.0);
217        assert_eq!(double_strike_chance(&warrior), 0.0);
218
219        let mut rogue = Entity::default();
220        seed(
221            &mut rogue,
222            &class(ClassPassive::DoubleStrike, 5.0, 17.0),
223            20,
224        );
225        assert_eq!(block_heal(&rogue), 0.0);
226        assert!((double_strike_chance(&rogue) - 0.17).abs() < 1e-9);
227    }
228
229    #[test]
230    fn the_periodic_passive_starts_one_period_into_the_fight() {
231        let mut priest = Entity::default();
232        let mut template = class(ClassPassive::LowestAllyHeal, 10.0, 10.0);
233        template.passive_period_ticks = 10_000;
234        seed(&mut priest, &template, 5);
235
236        assert!(!periodic_is_due(&priest, 0));
237        assert!(!periodic_is_due(&priest, 9_999));
238        assert!(periodic_is_due(&priest, 10_000));
239        assert_eq!(next_due(10_000, 10_000), 20_001);
240    }
241}