overlord_event_system/mechanics/
pet_facets.rs

1//! Pet Facets / Team Die v0 — the die, the bookkeeping keys and the arithmetic.
2//! The orchestration is [`crate::logic::pet_facets`].
3//!
4//! Per-fight facet state lives in the player entity's `attributes`, the same
5//! idiom the stones and laws runtimes use. Keys are namespaced `pet.*` so they
6//! cannot collide with a stat code
7//! ([`crate::mechanics::fight::get_entity_stat`] composes `<code>`,
8//! `<code>.bonus` and `<code>.mod`) nor with `stone.*` / `law.*`.
9
10use 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// ---- Team Die ---------------------------------------------------------------
19
20/// One rolled outcome: the facet the die landed on and the level of the pet it
21/// belongs to (rank scales the numbers of that facet and nothing else).
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct RolledFacet {
24    pub facet: PetFacet,
25    pub pet_level: i64,
26}
27
28/// The eligible face set of one side: one facet per equipped pet, in slot order.
29///
30/// Slot order — not template order and not a set — is what makes the roll
31/// reproducible from a seed: `BTreeMap<PetSlotId, Pet>` iterates by slot, so two
32/// runs with the same equipped team always index the same three faces.
33pub 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            // A template whose authored facet belongs to the other side is a
50            // content error: it would let a Fantasy face onto a Real roll. Drop
51            // it rather than roll it — the die keeps the faces it can trust.
52            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
69/// Rolls the Team Die: a uniform 1-of-N pick over the incoming side's faces.
70///
71/// N is three for a full team of three pets, which is the `1/3` of the design.
72/// It is deliberately not hard-coded to three: a player with one or two pets
73/// equipped still rolls, uniformly, over the faces they actually own.
74///
75/// Draws from the caller's [`rand::rngs::StdRng`] — the fight's own stream,
76/// seeded `session_seed + logical_timestamp` — so the roll is
77/// server-authoritative and reproducible. There is no manual roll, no hold, no
78/// reroll and no weight modification in v0.
79pub 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
87// ---- Bookkeeping keys -------------------------------------------------------
88
89/// The facet the die last landed on, as `PetFacet` ordinal + 1 (`0` = the die
90/// has not been rolled this fight). Presentation and tests read it; the runtime
91/// arms the outcome directly.
92pub const LAST_ROLL: &str = "pet.roll";
93
94/// Flip revision + 1 the die last rolled on, so one flip rolls exactly once
95/// however many times `GlobalFlip` reaches the hook.
96pub const ROLL_REVISION: &str = "pet.roll_rev";
97
98/// `1` while a facet outcome is being applied. The recursion guard of "a
99/// facet-generated repeat cannot repeat itself".
100pub const APPLYING: &str = "pet.applying";
101
102/// `PET-02 Budget Plan`: remaining original casts that pay the reduced Mana
103/// Cost, and the multiplier they pay it at (permyriad, `7_500` = ×0.75).
104pub const BUDGET_CASTS: &str = "pet.budget_n";
105pub const BUDGET_MULT: &str = "pet.budget_mult";
106
107/// `PET-02 Open Tab`: armed for exactly one original cast. `SURCHARGE` is the
108/// share of the cost it may overpay (permyriad), `PAYLOAD_SHARE` how much of
109/// that surcharge becomes payload (permyriad).
110pub 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
114/// Payload bonus armed by `Open Tab` for the cast it overpaid on, permyriad of
115/// the hit's own damage. Spent by the pet hook on that cast's landed Core hit.
116pub const NEXT_SKILL_BONUS: &str = "pet.ns_bonus";
117pub const NEXT_SKILL_BONUS_CHARGES: &str = "pet.ns_bonus_n";
118
119/// `PET-01 Second Spark`: derived repeat of the next original Skill, permyriad
120/// of Attack, with its charge.
121pub const NEXT_SKILL_ECHO: &str = "pet.ns_echo";
122pub const NEXT_SKILL_ECHO_CHARGES: &str = "pet.ns_echo_n";
123
124/// `PET-04 Retaliation`: derived area burst owed on the next Core hit TAKEN,
125/// permyriad of Attack, with its charge.
126pub const RETALIATION: &str = "pet.retaliate";
127pub const RETALIATION_CHARGES: &str = "pet.retaliate_n";
128
129/// `PET-05 Lucky Star`: derived burst owed on the next Core CRITICAL hit,
130/// permyriad of Attack, with its charge.
131pub const LUCKY_STAR: &str = "pet.lucky";
132pub const LUCKY_STAR_CHARGES: &str = "pet.lucky_n";
133
134/// `PET-06 Lead Reading`: remaining activations of the chosen source Law that
135/// bank extra Resonance, and the bonus they bank (permyriad).
136pub const LEAD_READING_CHARGES: &str = "pet.lead_n";
137pub const LEAD_READING_BONUS: &str = "pet.lead_pct";
138
139/// `PET-06 Wild Reading`: tick + 1 the chosen active Law's Effect bonus expires
140/// at, and the bonus itself (permyriad). A tick rather than a scheduled inverse
141/// because the multiplier is read, not stored on a stat.
142pub const WILD_READING_UNTIL: &str = "pet.wild_until";
143pub const WILD_READING_BONUS: &str = "pet.wild_pct";
144
145/// `PET-08 Rising Gate` (BAL-014): remaining gear Trigger procs of this phase
146/// whose Gauge award is multiplied, and the multiplier itself (hundredths, so
147/// 200 is x2).
148pub const RISING_GATE_PROCS: &str = "pet.gate_n";
149pub const RISING_GATE_MULT: &str = "pet.gate_mult";
150
151/// `PET-09 Prepared Slots`: the strength the first proc of each gear slot runs
152/// at this phase (permyriad, `14_000` = 140%). Per-slot spend marker below.
153pub const PREPARED_SLOTS: &str = "pet.slots_pct";
154
155/// `PET-09 First Spell`: strength of the one repeat of the phase's first
156/// ordinary gear Effect (permyriad), with its single charge.
157pub const FIRST_SPELL: &str = "pet.first_spell";
158pub const FIRST_SPELL_CHARGES: &str = "pet.first_spell_n";
159
160/// `PET-10 Souvenir`: remaining Trigger procs that also run the paired hidden
161/// Effect, and the share they run it at (permyriad).
162pub const SOUVENIR_CHARGES: &str = "pet.souvenir_n";
163pub const SOUVENIR_SHARE: &str = "pet.souvenir_pct";
164
165/// `PET-11 Life Bloom`: share of the player's landed Core damage healed back,
166/// permyriad, for as long as the window is open.
167///
168/// Its own key rather than the stones' `LIFESTEAL`: that one is paid out inside
169/// the stones hook, which leaves through an early return for a player with
170/// nothing socketed — and a Pet Facet has to work for that player.
171pub const LIFE_BLOOM: &str = "pet.bloom";
172
173/// `PET-10 Dream Reader`: share the chosen hidden Law runs at (permyriad) on the
174/// next suitable Core event, with its single charge.
175pub const DREAM_READER: &str = "pet.dream";
176pub const DREAM_READER_CHARGES: &str = "pet.dream_n";
177
178/// Per-slot "this slot has already spent its `Prepared Slots` bonus this phase"
179/// marker. Holds flip revision + 1, so an untouched attribute reads as "not this
180/// phase" even at revision 0 — the same shape `VA-04 Opening Five` uses.
181pub 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
188/// Every key a flip must clear, because it describes ONE phase rather than one
189/// fight. Everything else (the armed "next N casts" charges) belongs to the
190/// player and survives the flip that armed it.
191pub 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
200/// Raw attribute read; a missing key is zero.
201pub fn attr(entity: &Entity, key: &str) -> i64 {
202    entity.attributes.0.get(key).copied().unwrap_or(0)
203}
204
205/// The magnitude of a `(magnitude, charges)` pair, or `None` when it is spent.
206pub 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
211/// Spends one charge of a `(magnitude, charges)` pair, clearing the magnitude
212/// with the last one so a stale value can never apply for free.
213pub 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
221/// Clears every phase-scoped key. Called by the flip, before the incoming
222/// phase's own facet arms anything — a Real phase must not inherit the previous
223/// Real phase's `Prepared Slots`.
224pub 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
234// ---- Arithmetic -------------------------------------------------------------
235
236/// A whole-percent magnitude, rank-scaled, as permyriad (`35.0` at rank 1 →
237/// `3_500`).
238pub fn permyriad(settings: &PetFacetSettings, percent: f64, pet_level: i64) -> i64 {
239    (percent * settings.rank_multiplier(pet_level) * 100.0).round() as i64
240}
241
242/// A tick magnitude, rank-scaled (`Head Start`'s 1.2 s is a number the rank
243/// raises, not a rule it changes).
244pub 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); // +5% per level
318        assert_eq!(permyriad(&growing, 35.0, 1), 3_500, "rank 1 is authored");
319        assert_eq!(permyriad(&growing, 35.0, 3), 3_850); // x1.10
320        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    /// The eligible set is one face per equipped pet, of the asked-for side —
336    /// three pets, three faces, and the Fantasy halves of the same three pets
337    /// are a different three.
338    #[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}