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::{ItemRarity, ItemTemplate};
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    let level = balance::rand_round_f64(balance::level_by_day(day), ctx.rng); // RNG #1
68    let item_q = balance::item_q_by_day(day);
69
70    // inventory_levels sorted DESC by from_chapter_level; first with
71    // from_chapter_level <= level.
72    let mut inv: Vec<&InventoryLevel> = cfg.inventory_levels.iter().collect();
73    inv.sort_by_key(|l| std::cmp::Reverse(l.from_chapter_level));
74    let Some(current_inventory_level) = inv.into_iter().find(|l| l.from_chapter_level <= level)
75    else {
76        anyhow::bail!("opponent_generation: no inventory level for level {level}");
77    };
78
79    // content_raw `.values()` order = sort by uuid string.
80    let mut items: Vec<&ItemTemplate> = cfg.items.iter().collect();
81    items.sort_by_key(|a| a.id.to_string());
82    let mut item_rarities: Vec<&ItemRarity> = cfg.item_rarities.iter().collect();
83    item_rarities.sort_by_key(|a| a.id.to_string());
84
85    for item_type in &current_inventory_level.item_types {
86        let q_code = balance::rand_round_f64(item_q, ctx.rng); // RNG per item-type
87        let Some(item_rarity) = item_rarities.iter().find(|q| q.code == q_code) else {
88            continue;
89        };
90        if let Some(item) = items
91            .iter()
92            .find(|i| i.item_type == *item_type && i.rarity_id == item_rarity.id)
93        {
94            result.push_item(UuidIntPair {
95                id: item.id,
96                value: level,
97            });
98        }
99    }
100
101    // character level (`content::get_character_level(level)`): the level whose
102    // `.level == level`, else the highest-level one (the high-rating fallback).
103    // `ability_slots` is read straight off that character level — it is authored
104    // per level (NOT the `ability_slots_levels` chapter lookup).
105    let character_level = cfg
106        .character_levels
107        .iter()
108        .find(|c| c.level == level)
109        .or_else(|| cfg.character_levels.iter().max_by_key(|c| c.level));
110    let mut ability_slots = character_level.map(|c| c.ability_slots as i64).unwrap_or(0);
111
112    result.push_ability(UuidIntPair {
113        id: Uuid::from_u128(BASIC_ABILITY),
114        value: 1,
115    });
116    let spell_power = balance::eff_spell_by_level(level as f64);
117
118    if ability_slots > 0 {
119        let mut abilities: Vec<&AbilityTemplate> = cfg
120            .abilities
121            .iter()
122            .filter(|a| a.available_to_bots)
123            .collect();
124        abilities.sort_by_key(|a| a.id.to_string());
125        while ability_slots > 0 && !abilities.is_empty() {
126            // drain_random: idx = floor(random_f64() * len), clamped.
127            let len = abilities.len();
128            let idx = ((ctx.rng.random_f64() * len as f64).floor() as usize).min(len - 1);
129            let ability = abilities.remove(idx);
130            ability_slots -= 1;
131            let rarity_eff = ctx
132                .lookups
133                .ability_rarity_eff
134                .get(&ability.rarity_id)
135                .copied()
136                .unwrap_or(1.0);
137            let level_dps = (spell_power / rarity_eff - 0.4).max(1.0);
138            let ability_level = ((level_dps - 1.0) * 100.0).floor() as i64 + 1;
139            result.push_ability(UuidIntPair {
140                id: ability.id,
141                value: ability_level,
142            });
143        }
144    }
145
146    let power_dec_factor = (current_inventory_level.item_types.len() as f64 / 10.0).powf(2.0);
147    let power = 2.0_f64.powf(item_q - 1.0)
148        * balance::eff_by_level(level as f64)
149        * (ability_slots as f64 + 1.0)
150        * spell_power
151        * balance::BASE_POWER as f64
152        * power_dec_factor;
153    result.set_level(level);
154    result.set_class_id(Uuid::from_u128(BOT_CLASS));
155    result.set_power(power.floor() as i64);
156    Ok(result)
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
160pub struct UuidIntPair {
161    pub id: Uuid,
162    pub value: i64,
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
166pub struct OpponentGenerationResult {
167    pub items: Vec<UuidIntPair>,
168    pub abilities: Vec<UuidIntPair>,
169    pub power: i64,
170    pub level: i64,
171    pub class_id: Uuid,
172}
173
174impl OpponentGenerationResult {
175    pub fn push_item(&mut self, item: UuidIntPair) {
176        self.items.push(item);
177    }
178
179    pub fn push_ability(&mut self, ability: UuidIntPair) {
180        self.abilities.push(ability);
181    }
182
183    pub fn set_power(&mut self, power: i64) {
184        self.power = power;
185    }
186
187    pub fn set_level(&mut self, level: i64) {
188        self.level = level;
189    }
190
191    pub fn set_class_id(&mut self, class_id: Uuid) {
192        self.class_id = class_id;
193    }
194}