overlord_event_system/behaviors/
fast_equip.rs

1//! Native functions for the `ability_ids` category (`run_abilities_ids_script`)
2//! `fast_equip_abilities_script` does (copy `Abilities`, sort by
3//! `balance::ability_damage` descending with a `floor`ed comparator, take the
4//! top `Slots`) and then call the already-native
5//! [`crate::mechanics::balance::ability_damage_from_id`] for the core damage
6//! formula, so this fn only handles the copy/sort/take marshalling.
7//!
8//! Follows the [`super::power`] reference shape: a typed `*Ctx`, a `*Fn` alias, a
9//! native impl, and a `register`.
10
11use std::cmp::Ordering;
12
13use configs::game_config::GameConfig;
14use essences::abilities::Ability;
15use uuid::Uuid;
16
17use crate::mechanics::balance;
18use crate::mechanics::content_lookups::ContentLookups;
19
20/// `fast_equip_abilities_script` sees (`Abilities`, `Slots`) plus the config /
21/// content lookups the `balance` module carries (needed by
22/// `balance::ability_damage`).
23///
24/// `abilities` mirrors the `Abilities` const (the non-class abilities the
25/// handler hands in); `slots` mirrors the `Slots` const (a `u64`, the script
26/// uses `signed(Slots)`).
27pub struct AbilityIdsCtx<'a> {
28    pub abilities: &'a [Ability],
29    pub slots: u64,
30    pub config: &'a GameConfig,
31    pub lookups: &'a ContentLookups,
32}
33
34/// Signature of an `ability_ids` native fn. Free `fn` (no captured state) so it
35/// is `Copy` and trivially stored in the registry; runtime context arrives via
36/// [`AbilityIdsCtx`].
37pub type AbilityIdsFn = fn(&AbilityIdsCtx) -> anyhow::Result<Vec<Uuid>>;
38
39/// Native port of the live `fast_equip_abilities_script`:
40///
41///
42/// Mirrored exactly:
43/// - The comparator is `floor(dmg_b - dmg_a)` cast to an integer ordering, the
44///   `Less`, `0` => `Equal`, `> 0` => `Greater`). Differences in the open
45///   interval `(-1, 1)` floor to `0` and are treated as **equal** — this is a
46///   quirk of the live script and is preserved deliberately.
47///   abilities keep their input order.
48/// - The slice is `min(len, Slots)`; `signed(Slots)` is `slots as i64`, but the
49///   clamp to `len` makes it safe to compute as `min` over `usize`.
50///
51/// A failed `balance::ability_damage` lookup surfaces here as `Err`
52pub fn fast_equip_abilities(ctx: &AbilityIdsCtx) -> anyhow::Result<Vec<Uuid>> {
53    // Mirror `let abilities = []; for a in Abilities { abilities.push(a); }`,
54    // pairing each ability with its precomputed damage. `sort_by`'s comparator
55    // calls `ability_damage` for both sides on every comparison; computing it up
56    // front is equivalent (the helper is pure) and lets us fail fast on an
57    // unknown id, exactly as the script would on its first evaluation of it.
58    let mut scored: Vec<(f64, &Ability)> = Vec::with_capacity(ctx.abilities.len());
59    for ability in ctx.abilities {
60        let dmg = balance::ability_damage_from_id(
61            ctx.config,
62            ctx.lookups,
63            ability.template_id,
64            ability.level,
65        )
66        .map_err(|err| anyhow::anyhow!("balance::ability_damage: {err}"))?;
67        scored.push((dmg, ability));
68    }
69
70    // Stable sort by `floor(dmg_b - dmg_a)` ordering (descending damage with the
71    scored.sort_by(|&(dmg_a, _), &(dmg_b, _)| {
72        // `floor(dmg_b - dmg_a)`: positive => b ranks first (descending).
73        let cmp = (dmg_b - dmg_a).floor();
74        if cmp < 0.0 {
75            Ordering::Less
76        } else if cmp > 0.0 {
77            Ordering::Greater
78        } else {
79            Ordering::Equal
80        }
81    });
82
83    // `0..min(abilities.len, signed(Slots))` — clamp to available abilities.
84    let take = std::cmp::min(scored.len(), ctx.slots as usize);
85    let result: Vec<Uuid> = scored
86        .iter()
87        .take(take)
88        .map(|&(_, ability)| ability.template_id)
89        .collect();
90
91    Ok(result)
92}
93// Native function(s) for the `item_ids` category — script slots that return a
94// `Vec<Uuid>` of template ids (`run_item_ids_script`).
95//
96// The concrete, stable slot ported here is `fast_equip_pets_script`
97// (`game_settings.fast_equip_pets_script`): given the player's `all_pets` and
98// the available pet `Slots`, it picks the strongest pets to auto-equip and
99//
100//
101// carried no `template_id`, so the ability factor of
102// `balance::character_power` could never resolve and the ranking never
103// worked. This native port implements the *intended* ranking instead: the
104// attribute power of a level-1 character carrying just the candidate pet
105// ([`crate::mechanics::balance::character_attrs_power`] — see
106// `pet_power` below for why dropping the ability factor is order-preserving),
107// so this fn only handles the sort / selection / marshalling.
108//
109// NOTE (wiring): at runtime `fast_equip_pets_script` is dispatched through
110// `run_abilities_ids_script` (see `pets.rs`), even though its output shape is
111// `Vec<Uuid>` — the same shape `run_item_ids_script` produces. This fn is
112// therefore wired at the pets-equip callsite (which has `Pets`/`Slots` in
113// scope), not inside `run_item_ids_script` (whose scope is `CharacterState`
114// and which evaluates arbitrary bundle-step scripts).
115
116use essences::pets::Pet;
117
118/// Inputs available to an `item_ids` native fn for the `fast_equip_pets_script`
119/// / content lookups the `balance` module carries.
120pub struct ItemIdsCtx<'a> {
121    /// `Pets` const — the player's full pet roster (`all_pets`).
122    pub pets: &'a [Pet],
123    /// `Slots` const — number of available pet slots.
124    pub slots: i64,
125    pub config: &'a GameConfig,
126    pub lookups: &'a ContentLookups,
127}
128
129/// Signature of an `item_ids` native fn. Free `fn` (no captured state) so it is
130/// `Copy` and trivially stored in the registry; runtime context arrives via
131/// [`ItemIdsCtx`].
132pub type ItemIdsFn = fn(&ItemIdsCtx) -> anyhow::Result<Vec<Uuid>>;
133
134/// `balance::character_power(1, [], abilities, [pet])` — i.e. score a pet by
135/// the power of a level-1 character carrying just that pet.
136///
137/// level: 1 }] }` literal — but `balance::character_power` reads abilities by
138/// **`template_id`**, which this inline map does **not** carry, so the ability
139/// factor could never resolve and the intended ranking never ran (the original
140/// script erred; a first byte-faithful port scored every pet
141/// `floor(attrs_power * 0) == 0`, turning the fast-equip sort into roster
142/// order). The ability literal is the same hard-coded constant for every pet,
143/// so it cannot affect the *ordering* this fn exists to produce — we therefore
144/// rank by [`balance::character_attrs_power`] (the `attrs_power` term alone)
145/// and intentionally drop the constant ability multiplier.
146fn pet_power(ctx: &ItemIdsCtx, pet: &Pet) -> anyhow::Result<i64> {
147    balance::character_attrs_power(ctx.config, 1, &[], std::slice::from_ref(pet))
148        .map_err(|err| anyhow::anyhow!("balance::character_attrs_power: {err}"))
149}
150
151/// `sort_by(|a, b| floor((pet_power(b) - pet_power(a)) * 1000.0))`) and return
152/// the `template_id`s of the top `min(pets.len, Slots)`.
153pub fn fast_equip_pets(ctx: &ItemIdsCtx) -> anyhow::Result<Vec<Uuid>> {
154    // comparator; the resulting order is identical for a stable input).
155    let mut scored: Vec<(&Pet, i64)> = Vec::with_capacity(ctx.pets.len());
156    for pet in ctx.pets {
157        let power = pet_power(ctx, pet)?;
158        scored.push((pet, power));
159    }
160
161    // Mirror `floor((pet_power(b) - pet_power(a)) * 1000.0)`: descending by
162    // power. `pet_power` returns an integer (`balance::character_power` floors to
163    // i64), so `(pb - pa) * 1000.0` is an exact multiple of 1000 and `floor`
164    // reduces to the sign of `pb - pa` — i.e. a plain descending sort by power.
165    // stable sort here too.
166    scored.sort_by(|(_, pa), (_, pb)| pb.cmp(pa));
167
168    let take = std::cmp::min(scored.len(), ctx.slots.max(0) as usize);
169    Ok(scored
170        .into_iter()
171        .take(take)
172        .map(|(pet, _)| pet.template_id)
173        .collect())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use essences::items::Attribute;
180    use essences::pets::{PetComputedSecondaryStat, PetRarity};
181
182    fn test_attr(id: Uuid, code: &str, db_code: u8) -> Attribute {
183        Attribute {
184            id,
185            code: code.to_string(),
186            db_code,
187            ..Default::default()
188        }
189    }
190
191    fn test_pet(template_id: Uuid, stats: &[(Uuid, i64)]) -> Pet {
192        Pet {
193            template_id,
194            name: Default::default(),
195            icon_path: String::new(),
196            rarity: PetRarity::default(),
197            level: 1,
198            shards_amount: 0,
199            active_ability_id: None,
200            passive_ability_id: None,
201            stats: stats
202                .iter()
203                .map(|(attribute_id, value)| PetComputedSecondaryStat {
204                    attribute_id: *attribute_id,
205                    value: *value,
206                })
207                .collect(),
208        }
209    }
210
211    /// port multiplied every score by an unresolvable ability factor of 0, so
212    /// every pet scored 0 and fast-equip returned roster order instead of the
213    /// strongest pets.
214    #[test]
215    fn fast_equip_pets_picks_strongest_not_roster_order() {
216        let mut config = configs::tests_game_config::generate_game_config_for_tests();
217
218        // The shared test fixture has no `attack` / `speed` attribute codes,
219        // and `power_from_attrs` multiplies by both — add them so pet power is
220        // non-zero. `hp` already exists in the fixture.
221        let hp_id = Uuid::parse_str("45eca0a7-7430-487b-bd65-b796c6d88c08").unwrap();
222        let attack_id = Uuid::from_u128(0xA77AC4);
223        let speed_id = Uuid::from_u128(0x59EED);
224        config.attributes.push(test_attr(attack_id, "attack", 60));
225        config.attributes.push(test_attr(speed_id, "speed", 61));
226
227        let base_stats = [(attack_id, 600), (speed_id, 10000)];
228        let with_hp = |hp: i64| {
229            let mut stats = vec![(hp_id, hp)];
230            stats.extend_from_slice(&base_stats);
231            stats
232        };
233        let weak = test_pet(Uuid::from_u128(1), &with_hp(3_000));
234        let strong = test_pet(Uuid::from_u128(2), &with_hp(12_000));
235        let medium = test_pet(Uuid::from_u128(3), &with_hp(6_000));
236
237        let pets = vec![weak, strong, medium];
238        let lookups = ContentLookups::default();
239        let ctx = ItemIdsCtx {
240            pets: &pets,
241            slots: 2,
242            config: &config,
243            lookups: &lookups,
244        };
245
246        // Every pet must get a strictly positive, stat-dependent score.
247        let powers: Vec<i64> = pets
248            .iter()
249            .map(|pet| pet_power(&ctx, pet).unwrap())
250            .collect();
251        assert!(
252            powers.iter().all(|p| *p > 0),
253            "pet powers must be non-zero: {powers:?}"
254        );
255        assert!(
256            powers[1] > powers[2] && powers[2] > powers[0],
257            "pet powers must follow stats: {powers:?}"
258        );
259
260        // The two strongest pets win, not the first two in roster order.
261        let ids = fast_equip_pets(&ctx).unwrap();
262        assert_eq!(ids, vec![Uuid::from_u128(2), Uuid::from_u128(3)]);
263    }
264}