overlord_event_system/mechanics/
class_passives.rs1use essences::class::{Class, ClassPassive};
15use essences::entity::Entity;
16
17pub const PASSIVE_KIND: &str = "class.passive";
20
21pub const PASSIVE_PCT: &str = "class.passive.pct";
24
25pub 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
44pub 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
53pub 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 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
82pub 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
91pub 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
100pub const REPEAT_MARK: &str = "class.passive.repeat";
104
105pub fn mark_repeat(entity: &mut Entity) {
107 entity.attributes.set(REPEAT_MARK, 1);
108}
109
110pub 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
119pub const MULTICAST_MARK: &str = "class.passive.multicast";
127
128pub fn mark_multicast(entity: &mut Entity) {
130 entity.attributes.set(MULTICAST_MARK, 1);
131}
132
133pub 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
149pub fn periodic_heal_share(entity: &Entity) -> Option<f64> {
151 (marker(entity) == KIND_LOWEST_ALLY_HEAL).then(|| share(entity))
152}
153
154pub 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
160pub 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 assert_eq!(magnitude_permyriad(&rogue, 10), 1068);
186 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}