overlord_event_system/mechanics/
pet_facets.rs1use essences::entity::Entity;
11use essences::flip::WorldSide;
12use essences::items::ItemType;
13use essences::pet_facets::PetFacet;
14use essences::pets::EquippedPets;
15
16use configs::pet_facets::PetFacetSettings;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct RolledFacet {
24 pub facet: PetFacet,
25 pub pet_level: i64,
26}
27
28pub fn eligible_facets(
34 game_config: &configs::game_config::GameConfig,
35 equipped: &EquippedPets,
36 side: WorldSide,
37) -> Vec<RolledFacet> {
38 use crate::game_config_helpers::GameConfigLookup;
39
40 equipped
41 .slotted
42 .values()
43 .filter_map(|pet| {
44 let template = game_config.pet_template(pet.template_id)?;
45 let facet = match side {
46 WorldSide::Real => template.real_facet,
47 WorldSide::Fantasy => template.fantasy_facet,
48 };
49 if facet.side() != side {
53 tracing::warn!(
54 pet = %pet.template_id,
55 ?facet,
56 ?side,
57 "Pet facet is authored on the wrong side — excluded from the Team Die"
58 );
59 return None;
60 }
61 Some(RolledFacet {
62 facet,
63 pet_level: pet.level.max(1),
64 })
65 })
66 .collect()
67}
68
69pub fn roll_team_die(rng: &mut rand::rngs::StdRng, faces: &[RolledFacet]) -> Option<RolledFacet> {
80 if faces.is_empty() {
81 return None;
82 }
83 let index = rand::RngExt::random_range(rng, 0..faces.len());
84 faces.get(index).copied()
85}
86
87pub const LAST_ROLL: &str = "pet.roll";
93
94pub const ROLL_REVISION: &str = "pet.roll_rev";
97
98pub const APPLYING: &str = "pet.applying";
101
102pub const BUDGET_CASTS: &str = "pet.budget_n";
105pub const BUDGET_MULT: &str = "pet.budget_mult";
106
107pub const OPEN_TAB_CHARGES: &str = "pet.tab_n";
111pub const OPEN_TAB_SURCHARGE: &str = "pet.tab_pct";
112pub const OPEN_TAB_PAYLOAD_SHARE: &str = "pet.tab_share";
113
114pub const NEXT_SKILL_BONUS: &str = "pet.ns_bonus";
117pub const NEXT_SKILL_BONUS_CHARGES: &str = "pet.ns_bonus_n";
118
119pub const NEXT_SKILL_ECHO: &str = "pet.ns_echo";
122pub const NEXT_SKILL_ECHO_CHARGES: &str = "pet.ns_echo_n";
123
124pub const RETALIATION: &str = "pet.retaliate";
127pub const RETALIATION_CHARGES: &str = "pet.retaliate_n";
128
129pub const LUCKY_STAR: &str = "pet.lucky";
132pub const LUCKY_STAR_CHARGES: &str = "pet.lucky_n";
133
134pub const LEAD_READING_CHARGES: &str = "pet.lead_n";
137pub const LEAD_READING_BONUS: &str = "pet.lead_pct";
138
139pub const WILD_READING_UNTIL: &str = "pet.wild_until";
143pub const WILD_READING_BONUS: &str = "pet.wild_pct";
144
145pub const RISING_GATE_PROCS: &str = "pet.gate_n";
149pub const RISING_GATE_MULT: &str = "pet.gate_mult";
150
151pub const PREPARED_SLOTS: &str = "pet.slots_pct";
154
155pub const FIRST_SPELL: &str = "pet.first_spell";
158pub const FIRST_SPELL_CHARGES: &str = "pet.first_spell_n";
159
160pub const SOUVENIR_CHARGES: &str = "pet.souvenir_n";
163pub const SOUVENIR_SHARE: &str = "pet.souvenir_pct";
164
165pub const LIFE_BLOOM: &str = "pet.bloom";
172
173pub const DREAM_READER: &str = "pet.dream";
176pub const DREAM_READER_CHARGES: &str = "pet.dream_n";
177
178pub fn prepared_slot_key(item_type: ItemType) -> String {
182 format!(
183 "pet.slot.{}",
184 crate::mechanics::artifacts::slot_order_marker(item_type)
185 )
186}
187
188pub const PHASE_SCOPED_KEYS: [&str; 6] = [
192 RISING_GATE_PROCS,
193 RISING_GATE_MULT,
194 PREPARED_SLOTS,
195 FIRST_SPELL,
196 FIRST_SPELL_CHARGES,
197 WILD_READING_UNTIL,
198];
199
200pub fn attr(entity: &Entity, key: &str) -> i64 {
202 entity.attributes.0.get(key).copied().unwrap_or(0)
203}
204
205pub fn armed(entity: &Entity, magnitude_key: &str, charges_key: &str) -> Option<i64> {
207 let magnitude = attr(entity, magnitude_key);
208 (attr(entity, charges_key) > 0 && magnitude != 0).then_some(magnitude)
209}
210
211pub fn spend_charge(entity: &mut Entity, magnitude_key: &str, charges_key: &str) {
214 let left = attr(entity, charges_key) - 1;
215 entity.attributes.set(charges_key, left.max(0));
216 if left <= 0 {
217 entity.attributes.set(magnitude_key, 0);
218 }
219}
220
221pub fn clear_phase_state(entity: &mut Entity) {
225 for key in PHASE_SCOPED_KEYS {
226 entity.attributes.set(key, 0);
227 }
228 entity
229 .attributes
230 .0
231 .retain(|key, _| !key.starts_with("pet.slot."));
232}
233
234pub fn permyriad(settings: &PetFacetSettings, percent: f64, pet_level: i64) -> i64 {
239 (percent * settings.rank_multiplier(pet_level) * 100.0).round() as i64
240}
241
242pub fn ticks(settings: &PetFacetSettings, base: u64, pet_level: i64) -> u64 {
245 (base as f64 * settings.rank_multiplier(pet_level)).round() as u64
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use essences::pets::{Pet, PetRarity};
252 use strum::IntoEnumIterator;
253
254 fn settings(growth: i64) -> PetFacetSettings {
255 let mut settings =
256 configs::tests_game_config::generate_game_config_for_tests().pet_facet_settings;
257 settings.rank_growth_permyriad = growth;
258 settings
259 }
260
261 #[test]
262 fn bookkeeping_keys_are_namespaced_and_unique() {
263 let mut keys: Vec<String> = vec![
264 LAST_ROLL,
265 ROLL_REVISION,
266 APPLYING,
267 BUDGET_CASTS,
268 BUDGET_MULT,
269 OPEN_TAB_CHARGES,
270 OPEN_TAB_SURCHARGE,
271 OPEN_TAB_PAYLOAD_SHARE,
272 NEXT_SKILL_BONUS,
273 NEXT_SKILL_BONUS_CHARGES,
274 NEXT_SKILL_ECHO,
275 NEXT_SKILL_ECHO_CHARGES,
276 RETALIATION,
277 RETALIATION_CHARGES,
278 LUCKY_STAR,
279 LUCKY_STAR_CHARGES,
280 LEAD_READING_CHARGES,
281 LEAD_READING_BONUS,
282 WILD_READING_UNTIL,
283 WILD_READING_BONUS,
284 RISING_GATE_PROCS,
285 RISING_GATE_MULT,
286 PREPARED_SLOTS,
287 FIRST_SPELL,
288 FIRST_SPELL_CHARGES,
289 SOUVENIR_CHARGES,
290 SOUVENIR_SHARE,
291 DREAM_READER,
292 DREAM_READER_CHARGES,
293 LIFE_BLOOM,
294 ]
295 .into_iter()
296 .map(str::to_string)
297 .collect();
298 keys.extend(
299 ItemType::iter()
300 .filter(|t| t.supports_world_side())
301 .map(prepared_slot_key),
302 );
303
304 for key in &keys {
305 assert!(key.starts_with("pet."), "{key} may collide with a stat");
306 }
307 let unique: std::collections::HashSet<&String> = keys.iter().collect();
308 assert_eq!(unique.len(), keys.len(), "two pieces of state share a key");
309 }
310
311 #[test]
312 fn rank_scales_magnitudes_from_the_authored_number_at_level_one() {
313 let flat = settings(0);
314 assert_eq!(permyriad(&flat, 35.0, 1), 3_500);
315 assert_eq!(permyriad(&flat, 35.0, 9), 3_500, "no growth configured");
316
317 let growing = settings(500); assert_eq!(permyriad(&growing, 35.0, 1), 3_500, "rank 1 is authored");
319 assert_eq!(permyriad(&growing, 35.0, 3), 3_850); assert_eq!(ticks(&growing, 1_200, 3), 1_320);
321 }
322
323 fn pet(template_id: uuid::Uuid, level: i64) -> Pet {
324 Pet {
325 template_id,
326 name: Default::default(),
327 icon_path: String::new(),
328 rarity: PetRarity::default(),
329 level,
330 shards_amount: 0,
331 stats: Vec::new(),
332 }
333 }
334
335 #[test]
339 fn eligible_faces_are_one_per_pet_of_the_asked_side() {
340 let config = configs::tests_game_config::generate_game_config_for_tests();
341 let mut equipped = EquippedPets::new();
342 for (slot, template) in config.pet_templates.iter().take(3).enumerate() {
343 equipped.slotted.insert(slot, pet(template.id, 1));
344 }
345
346 let real = eligible_facets(&config, &equipped, WorldSide::Real);
347 let fantasy = eligible_facets(&config, &equipped, WorldSide::Fantasy);
348 assert_eq!(real.len(), 3);
349 assert_eq!(fantasy.len(), 3);
350 assert!(real.iter().all(|f| f.facet.side() == WorldSide::Real));
351 assert!(fantasy.iter().all(|f| f.facet.side() == WorldSide::Fantasy));
352 }
353
354 #[test]
355 fn an_empty_team_rolls_nothing_rather_than_panicking() {
356 use rand::SeedableRng;
357 let mut rng = rand::rngs::StdRng::seed_from_u64(1);
358 assert_eq!(roll_team_die(&mut rng, &[]), None);
359 }
360}