overlord_event_system/behaviors/
opponents.rs1use 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
34pub 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
43pub 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;
51const BASIC_ABILITY: u128 = 0x0194d64e_20f2_75e5_89c8_4cb812672485;
53const BOT_CLASS: u128 = 0x0195c7de_144f_7b53_b370_468e9ae8f744;
55
56pub 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 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); let item_q = balance::item_q_by_day(day);
81
82 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 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 ¤t_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 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 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
176fn 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}