overlord_event_system/behaviors/
opponents.rs

1//! Native port of the `opponent_generation` category — the single
2//! `bots_settings.bots_generation_script` run via [`crate::BehaviorRegistry::generate_opponent`]
3//! to build a PvP bot from an expected arena rating.
4//!
5//! all the data it needs is now in the typed `GameConfig`:
6//! - `available_to_bots` and item-rarity `code` were promoted onto
7//!   `AbilityTemplate` / `ItemRarity` (they previously lived only in the
8//!   admin-generated `content_raw`).
9//! - ability-rarity `eff` is `lookups.ability_rarity_eff` (which
10//!   `content_raw_extract` itself sources from the same `eff`).
11//! - `character_level.ability_slots` is the base `ability_slots_levels` lookup
12//!   (`from_chapter_level <= level`, highest match) — same rule the real-player
13//!   path (`ability_slots_for_chapter_level`) uses.
14//!
15//! uuid-string) order, so the native iterates config Vecs sorted by uuid string
16//! — this matters for `drain_random` (picks by index) and `find` order.
17//!
18//! RNG: the script draws in this order — level (1), then `item_q` once per
19//! inventory item-type, then `drain_random` once per equipped ability.
20//! `rand_round`/`drain_random` reuse the shared native logic
21//! (`balance::rand_round_f64`; the drain index formula).
22
23use configs::game_config::GameConfig;
24use essences::abilities::AbilityTemplate;
25use essences::item_case::InventoryLevel;
26use essences::items::{EquipmentSlotKey, ItemRarity, ItemTemplate, ItemType};
27use event_system::script::random::GameRng;
28use serde::Serialize;
29use uuid::Uuid;
30
31use crate::mechanics::balance;
32use crate::mechanics::content_lookups::ContentLookups;
33
34/// Inputs for the opponent-generation slot — mirrors the `generate_opponent`
35/// scope (`ExpectedRating`, `Random`) plus config/lookups the content reads.
36pub struct OpponentGenCtx<'a> {
37    pub expected_rating: i64,
38    pub rng: &'a GameRng,
39    pub config: &'a GameConfig,
40    pub lookups: &'a ContentLookups,
41}
42
43/// Signature of an opponent-generation native fn.
44pub type OpponentGenFn = fn(&OpponentGenCtx) -> anyhow::Result<OpponentGenerationResult>;
45
46const MEAN_RATING_FOR_WIN: f64 = 10.0;
47const MEAN_RATING_FOR_LOSS: f64 = -4.0;
48const MEAN_WINS_PER_DAY: f64 = 4.0;
49const MEAN_LOSSES_PER_DAY: f64 = 1.0;
50const START_RATING: f64 = 1000.0;
51/// `uuid("0194d64e-20f2-75e5-89c8-4cb812672485")` — the basic ability every bot gets.
52const BASIC_ABILITY: u128 = 0x0194d64e_20f2_75e5_89c8_4cb812672485;
53/// `uuid("0195c7de-144f-7b53-b370-468e9ae8f744")` — the bot class.
54const BOT_CLASS: u128 = 0x0195c7de_144f_7b53_b370_468e9ae8f744;
55
56/// Port of the shipped `bots_generation_script`.
57pub fn default_opponent_generation(
58    ctx: &OpponentGenCtx,
59) -> anyhow::Result<OpponentGenerationResult> {
60    let cfg = ctx.config;
61    let mut result = OpponentGenerationResult::default();
62
63    let mean_rating_per_day =
64        MEAN_WINS_PER_DAY * MEAN_RATING_FOR_WIN + MEAN_LOSSES_PER_DAY * MEAN_RATING_FOR_LOSS;
65    let rating_delta = ctx.expected_rating as f64 - START_RATING;
66    let day = (rating_delta / mean_rating_per_day).max(0.0);
67    // Level is CLAMPED to the highest authored character_level: the planning
68    // curve is unbounded in rating, but `character_power` (the honest power
69    // recompute in bot generation) hard-errors on a missing character_level —
70    // which killed the CONNECT of any player whose matchmaking asked for a
71    // high-rating bot (level_by_day exceeds the table past rating ~2350).
72    let max_level = ctx
73        .config
74        .character_levels
75        .iter()
76        .map(|c| c.level)
77        .max()
78        .unwrap_or(1);
79    let level = balance::rand_round_f64(balance::level_by_day(day), ctx.rng).min(max_level); // RNG #1
80    let item_q = balance::item_q_by_day(day);
81
82    // inventory_levels sorted DESC by from_chapter_level; first with
83    // from_chapter_level <= level.
84    let mut inv: Vec<&InventoryLevel> = cfg.inventory_levels.iter().collect();
85    inv.sort_by_key(|l| std::cmp::Reverse(l.from_chapter_level));
86    let Some(current_inventory_level) = inv.into_iter().find(|l| l.from_chapter_level <= level)
87    else {
88        anyhow::bail!("opponent_generation: no inventory level for level {level}");
89    };
90
91    // content_raw `.values()` order = sort by uuid string.
92    let mut items: Vec<&ItemTemplate> = cfg.items.iter().collect();
93    items.sort_by_key(|a| a.id.to_string());
94    let mut item_rarities: Vec<&ItemRarity> = cfg.item_rarities.iter().collect();
95    item_rarities.sort_by_key(|a| a.id.to_string());
96
97    let mut rarity_code_by_item_type = std::collections::HashMap::<ItemType, i64>::new();
98    for slot in &current_inventory_level.slots {
99        let q_code = *rarity_code_by_item_type
100            .entry(slot.item_type)
101            .or_insert_with(|| balance::rand_round_f64(item_q, ctx.rng));
102        if let Some(item) =
103            select_item_for_logical_slot(&items, &item_rarities, slot.equipment_slot_key(), q_code)
104        {
105            result.push_item(UuidIntPair {
106                id: item.id,
107                value: level,
108            });
109        }
110    }
111
112    // character level (`content::get_character_level(level)`): the level whose
113    // `.level == level`, else the highest-level one (the high-rating fallback).
114    // `ability_slots` is read straight off that character level — it is authored
115    // per level (NOT the `ability_slots_levels` chapter lookup).
116    let character_level = cfg
117        .character_levels
118        .iter()
119        .find(|c| c.level == level)
120        .or_else(|| cfg.character_levels.iter().max_by_key(|c| c.level));
121    let mut ability_slots = character_level.map(|c| c.ability_slots as i64).unwrap_or(0);
122
123    result.push_ability(UuidIntPair {
124        id: Uuid::from_u128(BASIC_ABILITY),
125        value: 1,
126    });
127    let spell_power = balance::eff_spell_by_level(level as f64);
128
129    if ability_slots > 0 {
130        let mut abilities: Vec<&AbilityTemplate> = cfg
131            .abilities
132            .iter()
133            .filter(|a| a.available_to_bots)
134            .collect();
135        abilities.sort_by_key(|a| a.id.to_string());
136        while ability_slots > 0 && !abilities.is_empty() {
137            // drain_random: idx = floor(random_f64() * len), clamped.
138            let len = abilities.len();
139            let idx = ((ctx.rng.random_f64() * len as f64).floor() as usize).min(len - 1);
140            let ability = abilities.remove(idx);
141            ability_slots -= 1;
142            let rarity_eff = ctx
143                .lookups
144                .ability_rarity_eff
145                .get(&ability.rarity_id)
146                .copied()
147                .unwrap_or(1.0);
148            let level_dps = (spell_power / rarity_eff - 0.4).max(1.0);
149            let ability_level = ((level_dps - 1.0) * 100.0).floor() as i64 + 1;
150            result.push_ability(UuidIntPair {
151                id: ability.id,
152                value: ability_level,
153            });
154        }
155    }
156
157    let unlocked_item_type_count = current_inventory_level
158        .slots
159        .iter()
160        .map(|slot| slot.item_type)
161        .collect::<std::collections::HashSet<_>>()
162        .len();
163    let power_dec_factor = (unlocked_item_type_count as f64 / 10.0).powf(2.0);
164    let power = 2.0_f64.powf(item_q - 1.0)
165        * balance::eff_by_level(level as f64)
166        * (ability_slots as f64 + 1.0)
167        * spell_power
168        * balance::BASE_POWER as f64
169        * power_dec_factor;
170    result.set_level(level);
171    result.set_class_id(Uuid::from_u128(BOT_CLASS));
172    result.set_power(power.floor() as i64);
173    Ok(result)
174}
175
176/// Selects an authored template without crossing the requested logical slot.
177///
178/// The exact-rarity path intentionally preserves the previous UUID-sorted
179/// lookup semantics. If that rarity has no template for this logical slot, the
180/// closest authored rarity code is used; equal distances are resolved by UUID.
181fn select_item_for_logical_slot<'a>(
182    items: &[&'a ItemTemplate],
183    item_rarities: &[&ItemRarity],
184    slot_key: EquipmentSlotKey,
185    q_code: i64,
186) -> Option<&'a ItemTemplate> {
187    if let Some(item_rarity) = item_rarities.iter().find(|rarity| rarity.code == q_code)
188        && let Some(item) = items
189            .iter()
190            .copied()
191            .find(|item| item.equipment_slot_key() == slot_key && item.rarity_id == item_rarity.id)
192    {
193        return Some(item);
194    }
195
196    items
197        .iter()
198        .copied()
199        .filter(|item| item.equipment_slot_key() == slot_key)
200        .filter_map(|item| {
201            let rarity_code = item_rarities
202                .iter()
203                .find(|rarity| rarity.id == item.rarity_id)?
204                .code;
205            Some((rarity_code.abs_diff(q_code), item.id, item))
206        })
207        .min_by_key(|(distance, item_id, _)| (*distance, *item_id))
208        .map(|(_, _, item)| item)
209}
210
211#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
212pub struct UuidIntPair {
213    pub id: Uuid,
214    pub value: i64,
215}
216
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
218pub struct OpponentGenerationResult {
219    pub items: Vec<UuidIntPair>,
220    pub abilities: Vec<UuidIntPair>,
221    pub power: i64,
222    pub level: i64,
223    pub class_id: Uuid,
224}
225
226impl OpponentGenerationResult {
227    pub fn push_item(&mut self, item: UuidIntPair) {
228        self.items.push(item);
229    }
230
231    pub fn push_ability(&mut self, ability: UuidIntPair) {
232        self.abilities.push(ability);
233    }
234
235    pub fn set_power(&mut self, power: i64) {
236        self.power = power;
237    }
238
239    pub fn set_level(&mut self, level: i64) {
240        self.level = level;
241    }
242
243    pub fn set_class_id(&mut self, class_id: Uuid) {
244        self.class_id = class_id;
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use configs::tests_game_config::generate_game_config_for_tests;
251    use essences::{
252        flip::WorldSide,
253        items::{EquipmentSlotKey, ItemTemplate, ItemType},
254    };
255    use uuid::Uuid;
256
257    use super::select_item_for_logical_slot;
258
259    fn template_for(
260        base: &ItemTemplate,
261        id: u128,
262        world_side: WorldSide,
263        rarity_id: Uuid,
264    ) -> ItemTemplate {
265        let mut template = base.clone();
266        template.id = Uuid::from_u128(id);
267        template.world_side = Some(world_side);
268        template.rarity_id = rarity_id;
269        template
270    }
271
272    #[test]
273    fn missing_exact_rarity_uses_nearest_rarity_in_the_same_logical_slot() {
274        let config = generate_game_config_for_tests();
275        let base = config
276            .items
277            .iter()
278            .find(|item| {
279                item.item_type == ItemType::Weapon && item.world_side == Some(WorldSide::Fantasy)
280            })
281            .expect("test config must contain a fantasy weapon");
282        let rarity_id = |code| {
283            config
284                .item_rarities
285                .iter()
286                .find(|rarity| rarity.code == code)
287                .unwrap_or_else(|| panic!("test config must contain rarity code {code}"))
288                .id
289        };
290
291        let farther_same_slot = template_for(base, 30, WorldSide::Fantasy, rarity_id(2));
292        let nearest_same_slot = template_for(base, 20, WorldSide::Fantasy, rarity_id(4));
293        let exact_other_side = template_for(base, 1, WorldSide::Real, rarity_id(5));
294        let items = vec![&exact_other_side, &farther_same_slot, &nearest_same_slot];
295        let rarities = config.item_rarities.iter().collect::<Vec<_>>();
296
297        let selected = select_item_for_logical_slot(
298            &items,
299            &rarities,
300            EquipmentSlotKey::new(ItemType::Weapon, Some(WorldSide::Fantasy)),
301            5,
302        )
303        .expect("nearest same-slot rarity must be selected");
304
305        assert_eq!(selected.id, nearest_same_slot.id);
306    }
307
308    #[test]
309    fn equal_rarity_distance_is_resolved_by_lowest_uuid() {
310        let config = generate_game_config_for_tests();
311        let base = config
312            .items
313            .iter()
314            .find(|item| {
315                item.item_type == ItemType::Weapon && item.world_side == Some(WorldSide::Fantasy)
316            })
317            .expect("test config must contain a fantasy weapon");
318        let rarity_id = |code| {
319            config
320                .item_rarities
321                .iter()
322                .find(|rarity| rarity.code == code)
323                .unwrap_or_else(|| panic!("test config must contain rarity code {code}"))
324                .id
325        };
326
327        let higher_uuid = template_for(base, 200, WorldSide::Fantasy, rarity_id(4));
328        let lower_uuid = template_for(base, 100, WorldSide::Fantasy, rarity_id(6));
329        let items = vec![&higher_uuid, &lower_uuid];
330        let rarities = config.item_rarities.iter().collect::<Vec<_>>();
331
332        let selected = select_item_for_logical_slot(
333            &items,
334            &rarities,
335            EquipmentSlotKey::new(ItemType::Weapon, Some(WorldSide::Fantasy)),
336            5,
337        )
338        .expect("one of the equally near rarities must be selected");
339
340        assert_eq!(selected.id, lower_uuid.id);
341    }
342}