overlord_event_system/behaviors/
fast_equip.rs

1//! Native functions for the `ability_ids` category (`run_abilities_ids_script`).
2//!
3//! Both fns here answer the same question — "which of the things I own should
4//! quick-equip put in my slots?" — and both answer it the same way: **rarity
5//! first, then the system's own power metric inside a rarity band**, take the
6//! top `Slots`. The metric itself is already native
7//! ([`crate::mechanics::balance::ability_damage_from_id`] for abilities,
8//! [`crate::mechanics::balance::character_attrs_power_raw`] for pets), so these
9//! fns only handle the scoring / sort / take marshalling.
10//!
11//! Follows the [`super::power`] reference shape: a typed `*Ctx`, a `*Fn` alias, a
12//! native impl, and a `register`.
13
14use std::cmp::Ordering;
15
16use configs::game_config::GameConfig;
17use essences::abilities::Ability;
18use uuid::Uuid;
19
20use crate::game_config_helpers::GameConfigLookup;
21use crate::mechanics::balance;
22use crate::mechanics::content_lookups::ContentLookups;
23
24/// `fast_equip_abilities_script` sees (`Abilities`, `Slots`) plus the config /
25/// content lookups the `balance` module carries (needed by
26/// `balance::ability_damage`).
27///
28/// `abilities` mirrors the `Abilities` const (the non-class abilities the
29/// handler hands in); `slots` mirrors the `Slots` const (a `u64`, the script
30/// uses `signed(Slots)`).
31pub struct AbilityIdsCtx<'a> {
32    pub abilities: &'a [Ability],
33    pub slots: u64,
34    pub config: &'a GameConfig,
35    pub lookups: &'a ContentLookups,
36}
37
38/// Signature of an `ability_ids` native fn. Free `fn` (no captured state) so it
39/// is `Copy` and trivially stored in the registry; runtime context arrives via
40/// [`AbilityIdsCtx`].
41pub type AbilityIdsFn = fn(&AbilityIdsCtx) -> anyhow::Result<Vec<Uuid>>;
42
43/// The `order` of an ability's rarity, or `0` when the template or its rarity
44/// is missing from config — an unknown ability ranks below every known one
45/// rather than aborting the whole fast-equip.
46fn ability_rarity_order(config: &GameConfig, ability: &Ability) -> u64 {
47    let Some(rarity_id) = config
48        .ability_template(ability.template_id)
49        .map(|template| template.rarity_id)
50    else {
51        return 0;
52    };
53    config
54        .ability_rarity(rarity_id)
55        .map_or(0, |rarity| rarity.order)
56}
57
58/// Ranks the player's non-class abilities by **rarity first, then damage**, and
59/// returns the `template_id`s of the top `min(len, Slots)`.
60///
61/// Rarity leads for the same reason it does in [`fast_equip_pets`]: it is the
62/// axis the player pulled for and the one that sets the ability's ceiling, so a
63/// heavily-levelled Common briefly out-damaging a fresh Legendary should not
64/// make quick-equip bench the Legendary. Damage
65/// ([`crate::mechanics::balance::ability_damage_from_id`]) orders abilities
66/// inside one rarity band.
67///
68/// Within a band the damage comparator is the `floor(dmg_b - dmg_a)` ordering
69/// inherited from the live `fast_equip_abilities_script`: differences in the
70/// open interval `(-1, 1)` floor to `0` and count as **equal**, and the sort is
71/// stable, so genuinely-tied abilities keep their input order. That quirk is
72/// preserved deliberately.
73///
74/// A failed `balance::ability_damage` lookup surfaces here as `Err`.
75pub fn fast_equip_abilities(ctx: &AbilityIdsCtx) -> anyhow::Result<Vec<Uuid>> {
76    // `sort_by`'s comparator would call `ability_damage` for both sides on every
77    // comparison; computing it up front is equivalent (the helper is pure) and
78    // lets us fail fast on an unknown id.
79    let mut scored: Vec<(u64, f64, &Ability)> = Vec::with_capacity(ctx.abilities.len());
80    for ability in ctx.abilities {
81        let dmg = balance::ability_damage_from_id(
82            ctx.config,
83            ctx.lookups,
84            ability.template_id,
85            ability.level,
86        )
87        .map_err(|err| anyhow::anyhow!("balance::ability_damage: {err}"))?;
88        scored.push((ability_rarity_order(ctx.config, ability), dmg, ability));
89    }
90
91    // Stable sort: rarity descending, then the floored damage ordering.
92    scored.sort_by(|&(rarity_a, dmg_a, _), &(rarity_b, dmg_b, _)| {
93        rarity_b.cmp(&rarity_a).then_with(|| {
94            // `floor(dmg_b - dmg_a)`: positive => b ranks first (descending).
95            let cmp = (dmg_b - dmg_a).floor();
96            if cmp < 0.0 {
97                Ordering::Less
98            } else if cmp > 0.0 {
99                Ordering::Greater
100            } else {
101                Ordering::Equal
102            }
103        })
104    });
105
106    // `0..min(abilities.len, signed(Slots))` — clamp to available abilities.
107    let take = std::cmp::min(scored.len(), ctx.slots as usize);
108    let result: Vec<Uuid> = scored
109        .iter()
110        .take(take)
111        .map(|&(_, _, ability)| ability.template_id)
112        .collect();
113
114    Ok(result)
115}
116// Native function(s) for the `item_ids` category — script slots that return a
117// `Vec<Uuid>` of template ids (`run_item_ids_script`).
118//
119// The concrete, stable slot ported here is `fast_equip_pets_script`
120// (`game_settings.fast_equip_pets_script`): given the player's `all_pets` and
121// the available pet `Slots`, it picks the pets to auto-equip. The original
122// script's inline pet literal carried no `template_id`, so the ability factor of
123// `balance::character_power` could never resolve and the ranking never
124// worked. This native port implements the *intended* ranking instead: the
125// attribute power of a level-1 character carrying just the candidate pet
126// ([`crate::mechanics::balance::character_attrs_power`] — see
127// `pet_power` below for why dropping the ability factor is order-preserving),
128// so this fn only handles the sort / selection / marshalling.
129//
130// NOTE (wiring): at runtime `fast_equip_pets_script` is dispatched through
131// `run_abilities_ids_script` (see `pets.rs`), even though its output shape is
132// `Vec<Uuid>` — the same shape `run_item_ids_script` produces. This fn is
133// therefore wired at the pets-equip callsite (which has `Pets`/`Slots` in
134// scope), not inside `run_item_ids_script` (whose scope is `CharacterState`
135// and which evaluates arbitrary bundle-step scripts).
136
137use essences::items::Item;
138use essences::pets::Pet;
139
140/// Inputs available to an `item_ids` native fn for the `fast_equip_pets_script`
141/// / content lookups the `balance` module carries.
142pub struct ItemIdsCtx<'a> {
143    /// `Pets` const — the player's full pet roster (`all_pets`).
144    pub pets: &'a [Pet],
145    /// `Slots` const — number of available pet slots.
146    pub slots: i64,
147    /// The player's character level. A pet is scored as the marginal power it
148    /// adds to THIS character, so the baseline has to be the real one.
149    pub character_level: i64,
150    /// The player's equipped gear, for the same reason: a pet's contribution to
151    /// `DPS × EHP` depends on which side of that product the gear already fills.
152    pub inventory: &'a [Item],
153    pub config: &'a GameConfig,
154    pub lookups: &'a ContentLookups,
155}
156
157/// Signature of an `item_ids` native fn. Free `fn` (no captured state) so it is
158/// `Copy` and trivially stored in the registry; runtime context arrives via
159/// [`ItemIdsCtx`].
160pub type ItemIdsFn = fn(&ItemIdsCtx) -> anyhow::Result<Vec<Uuid>>;
161
162/// `balance::character_power(1, [], abilities, [pet])` — i.e. score a pet by
163/// the power of a level-1 character carrying just that pet.
164///
165/// level: 1 }] }` literal — but `balance::character_power` reads abilities by
166/// **`template_id`**, which this inline map does **not** carry, so the ability
167/// factor could never resolve and the intended ranking never ran (the original
168/// script erred; a first byte-faithful port scored every pet
169/// `floor(attrs_power * 0) == 0`, turning the fast-equip sort into roster
170/// order). The ability literal is the same hard-coded constant for every pet,
171/// so it cannot affect the *ordering* this fn exists to produce — we therefore
172/// rank by [`balance::character_attrs_power`] (the `attrs_power` term alone)
173/// and intentionally drop the constant ability multiplier.
174///
175/// Scored at the player's own level and gear, on the UNFLOORED power. Both
176/// matter: at level 1 with no inventory `DPS × EHP / power_norm` is around
177/// `0.01`, so the displayed-power integer floors every pet to `0`, every
178/// comparison ties, and the stable sort below hands back roster order — the
179/// exact failure this fn was written to fix, reached by a second route.
180fn pet_power(ctx: &ItemIdsCtx, pet: &Pet) -> anyhow::Result<f64> {
181    balance::character_attrs_power_raw(
182        ctx.config,
183        ctx.character_level,
184        ctx.inventory,
185        std::slice::from_ref(pet),
186    )
187    .map_err(|err| anyhow::anyhow!("balance::character_attrs_power_raw: {err}"))
188}
189
190/// Rank the roster by **rarity first, then power**, and return the
191/// `template_id`s of the top `min(pets.len, Slots)`.
192///
193/// Rarity leads because that is what the player is picking for: a Legendary is
194/// the pet they pulled for and the one whose stat ceiling and facet pool are
195/// worth investing in, and a fully-levelled Common briefly out-scoring it is
196/// not a reason for quick-equip to bench it. Power (the marginal contribution
197/// this pet makes to THIS character — see [`pet_power`]) orders pets inside one
198/// rarity band, where it is the honest comparison.
199pub fn fast_equip_pets(ctx: &ItemIdsCtx) -> anyhow::Result<Vec<Uuid>> {
200    let mut scored: Vec<(&Pet, u64, f64)> = Vec::with_capacity(ctx.pets.len());
201    for pet in ctx.pets {
202        let power = pet_power(ctx, pet)?;
203        scored.push((pet, pet.rarity.order, power));
204    }
205
206    // Descending by (rarity, power). Stable, so pets of genuinely equal rank
207    // keep roster order. `total_cmp` rather than `partial_cmp`: a NaN score
208    // would otherwise make the comparator inconsistent and could panic the sort.
209    scored.sort_by(|(_, ra, pa), (_, rb, pb)| rb.cmp(ra).then_with(|| pb.total_cmp(pa)));
210
211    let take = std::cmp::min(scored.len(), ctx.slots.max(0) as usize);
212    Ok(scored
213        .into_iter()
214        .take(take)
215        .map(|(pet, _, _)| pet.template_id)
216        .collect())
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use essences::items::Attribute;
223    use essences::pets::{PetComputedSecondaryStat, PetRarity};
224
225    fn test_attr(id: Uuid, code: &str, db_code: u8) -> Attribute {
226        Attribute {
227            id,
228            code: code.to_string(),
229            db_code,
230            ..Default::default()
231        }
232    }
233
234    fn test_pet(template_id: Uuid, stats: &[(Uuid, i64)]) -> Pet {
235        rare_pet(template_id, 0, stats)
236    }
237
238    fn rare_pet(template_id: Uuid, rarity_order: u64, stats: &[(Uuid, i64)]) -> Pet {
239        Pet {
240            template_id,
241            name: Default::default(),
242            icon_path: String::new(),
243            rarity: PetRarity {
244                order: rarity_order,
245                ..PetRarity::default()
246            },
247            level: 1,
248            shards_amount: 0,
249            stats: stats
250                .iter()
251                .map(|(attribute_id, value)| PetComputedSecondaryStat {
252                    attribute_id: *attribute_id,
253                    value: *value,
254                })
255                .collect(),
256        }
257    }
258
259    /// port multiplied every score by an unresolvable ability factor of 0, so
260    /// every pet scored 0 and fast-equip returned roster order instead of the
261    /// strongest pets.
262    #[test]
263    fn fast_equip_pets_picks_strongest_not_roster_order() {
264        let mut config = configs::tests_game_config::generate_game_config_for_tests();
265
266        // The shared test fixture has no `attack` / `speed` attribute codes,
267        // and `power_from_attrs` multiplies by both — add them so pet power is
268        // non-zero. `hp` already exists in the fixture.
269        let hp_id = Uuid::parse_str("45eca0a7-7430-487b-bd65-b796c6d88c08").unwrap();
270        let attack_id = Uuid::from_u128(0xA77AC4);
271        let speed_id = Uuid::from_u128(0x59EED);
272        config.attributes.push(test_attr(attack_id, "attack", 60));
273        config.attributes.push(test_attr(speed_id, "speed", 61));
274
275        let base_stats = [(attack_id, 600), (speed_id, 10000)];
276        let with_hp = |hp: i64| {
277            let mut stats = vec![(hp_id, hp)];
278            stats.extend_from_slice(&base_stats);
279            stats
280        };
281        let weak = test_pet(Uuid::from_u128(1), &with_hp(3_000));
282        let strong = test_pet(Uuid::from_u128(2), &with_hp(12_000));
283        let medium = test_pet(Uuid::from_u128(3), &with_hp(6_000));
284
285        let pets = vec![weak, strong, medium];
286        let lookups = ContentLookups::default();
287        let ctx = ItemIdsCtx {
288            pets: &pets,
289            slots: 2,
290            character_level: 1,
291            inventory: &[],
292            config: &config,
293            lookups: &lookups,
294        };
295
296        // Every pet must get a strictly positive, stat-dependent score.
297        let powers: Vec<f64> = pets
298            .iter()
299            .map(|pet| pet_power(&ctx, pet).unwrap())
300            .collect();
301        assert!(
302            powers.iter().all(|p| *p > 0.0),
303            "pet powers must be non-zero: {powers:?}"
304        );
305        assert!(
306            powers[1] > powers[2] && powers[2] > powers[0],
307            "pet powers must follow stats: {powers:?}"
308        );
309
310        // The two strongest pets win, not the first two in roster order.
311        let ids = fast_equip_pets(&ctx).unwrap();
312        assert_eq!(ids, vec![Uuid::from_u128(2), Uuid::from_u128(3)]);
313    }
314
315    /// Rarity outranks raw damage: a levelled low-rarity ability must not push a
316    /// higher-rarity one out of a slot, and damage still orders abilities inside
317    /// one rarity band.
318    #[test]
319    fn fast_equip_abilities_ranks_rarity_before_damage() {
320        let config = configs::tests_game_config::generate_game_config_for_tests();
321        let lookups = ContentLookups::default();
322
323        // The rarities the fixture's abilities actually use, lowest and highest.
324        let mut used: Vec<_> = config
325            .ability_rarities
326            .iter()
327            .filter(|rarity| {
328                config
329                    .abilities
330                    .iter()
331                    .any(|template| template.rarity_id == rarity.id)
332            })
333            .collect();
334        used.sort_by_key(|rarity| rarity.order);
335        let (low, high) = (used[0], used[used.len() - 1]);
336        assert!(
337            low.order < high.order,
338            "the fixture must carry abilities of at least two rarities"
339        );
340
341        let template_of = |rarity_id: Uuid| {
342            config
343                .abilities
344                .iter()
345                .find(|template| template.rarity_id == rarity_id)
346                .expect("rarity was chosen from the templates themselves")
347                .id
348        };
349
350        // The low-rarity ability is levelled far past the high-rarity one, so
351        // it wins on damage and must still lose on rank.
352        let levelled_low = Ability {
353            template_id: template_of(low.id),
354            level: 60,
355            shards_amount: 0,
356        };
357        let fresh_high = Ability {
358            template_id: template_of(high.id),
359            level: 1,
360            shards_amount: 0,
361        };
362        let abilities = vec![levelled_low.clone(), fresh_high.clone()];
363
364        let damage = |ability: &Ability| {
365            balance::ability_damage_from_id(&config, &lookups, ability.template_id, ability.level)
366                .unwrap()
367        };
368        assert!(
369            damage(&levelled_low) > damage(&fresh_high),
370            "the fixture only proves anything while the low-rarity ability out-damages the high one"
371        );
372
373        let ctx = AbilityIdsCtx {
374            abilities: &abilities,
375            slots: 1,
376            config: &config,
377            lookups: &lookups,
378        };
379        assert_eq!(
380            fast_equip_abilities(&ctx).unwrap(),
381            vec![fresh_high.template_id],
382            "the one slot went to the rarer ability, not the higher-damage one"
383        );
384    }
385
386    /// Rarity outranks raw power: a heavily-statted Common must not push a
387    /// Legendary out of a slot, and power still orders pets inside one band.
388    #[test]
389    fn fast_equip_pets_ranks_rarity_before_power() {
390        let mut config = configs::tests_game_config::generate_game_config_for_tests();
391
392        let hp_id = Uuid::parse_str("45eca0a7-7430-487b-bd65-b796c6d88c08").unwrap();
393        let attack_id = Uuid::from_u128(0xA77AC4);
394        let speed_id = Uuid::from_u128(0x59EED);
395        config.attributes.push(test_attr(attack_id, "attack", 60));
396        config.attributes.push(test_attr(speed_id, "speed", 61));
397
398        let make = |id: u128, rarity_order: u64, hp: i64| {
399            rare_pet(
400                Uuid::from_u128(id),
401                rarity_order,
402                &[(hp_id, hp), (attack_id, 600), (speed_id, 10_000)],
403            )
404        };
405        // The Common is by far the strongest pet on raw stats.
406        let fat_common = make(1, 0, 90_000);
407        let weak_legendary = make(2, 3, 3_000);
408        let strong_legendary = make(3, 3, 6_000);
409
410        let pets = vec![fat_common, weak_legendary, strong_legendary];
411        let lookups = ContentLookups::default();
412        let ctx = ItemIdsCtx {
413            pets: &pets,
414            slots: 2,
415            character_level: 1,
416            inventory: &[],
417            config: &config,
418            lookups: &lookups,
419        };
420
421        assert!(
422            pet_power(&ctx, &pets[0]).unwrap() > pet_power(&ctx, &pets[2]).unwrap(),
423            "the fixture only proves anything while the Common out-powers both Legendaries"
424        );
425
426        // Both Legendaries first, stronger one leading; the Common is benched.
427        let ids = fast_equip_pets(&ctx).unwrap();
428        assert_eq!(ids, vec![Uuid::from_u128(3), Uuid::from_u128(2)]);
429    }
430
431    /// The scores must be DISTINCT, not merely ordered. Ranking on the
432    /// displayed-power integer made every real pet score `0` — the sort then
433    /// tied on every comparison and, being stable, returned roster order. The
434    /// previous test missed it because its synthetic stats are large enough to
435    /// survive the floor; real pets carry small basis-point stats.
436    #[test]
437    fn pet_scores_separate_on_realistic_stats() {
438        let mut config = configs::tests_game_config::generate_game_config_for_tests();
439
440        let hp_id = Uuid::parse_str("45eca0a7-7430-487b-bd65-b796c6d88c08").unwrap();
441        let attack_id = Uuid::from_u128(0xA77AC4);
442        let speed_id = Uuid::from_u128(0x59EED);
443        config.attributes.push(test_attr(attack_id, "attack", 60));
444        config.attributes.push(test_attr(speed_id, "speed", 61));
445
446        // Small, close-together stats: the kind that floor to a single value.
447        let make = |id: u128, hp: i64| {
448            test_pet(
449                Uuid::from_u128(id),
450                &[(hp_id, hp), (attack_id, 2), (speed_id, 10_000)],
451            )
452        };
453        let pets = vec![make(1, 10), make(2, 12), make(3, 11)];
454
455        let lookups = ContentLookups::default();
456        let ctx = ItemIdsCtx {
457            pets: &pets,
458            slots: 2,
459            character_level: 1,
460            inventory: &[],
461            config: &config,
462            lookups: &lookups,
463        };
464
465        let powers: Vec<f64> = pets
466            .iter()
467            .map(|pet| pet_power(&ctx, pet).unwrap())
468            .collect();
469        assert!(
470            powers[1] > powers[2] && powers[2] > powers[0],
471            "small stat differences must still separate: {powers:?}"
472        );
473        assert!(
474            powers.iter().all(|p| p.floor() as i64 == 0),
475            "this fixture only proves anything while the INTEGER score is 0: {powers:?}"
476        );
477
478        // Strongest first, and specifically not roster order.
479        let ids = fast_equip_pets(&ctx).unwrap();
480        assert_eq!(ids, vec![Uuid::from_u128(2), Uuid::from_u128(3)]);
481    }
482}