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}