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}