1use wowlab_types::{
6 game::{GearEntry, GearSlot},
7 proto::TournamentPayload,
8};
9
10use crate::{
11 runtime::RuntimeWorkItemKind,
12 sim::intent::{IntentConfigError, SimConfigIntent, serialize_sim_config},
13};
14
15wowlab_engine_macros::define_error! {
16#[derive(Debug)]
18#[non_exhaustive]
19pub struct DeriveError {
20 #[source]
21 kind: DeriveErrorKind,
22}
23
24#[derive(Debug, thiserror::Error)]
25enum DeriveErrorKind {
26 #[error("slot index {slot_idx} out of range ({len} slots)")]
27 SlotOutOfRange { slot_idx: usize, len: usize },
28 #[error("item index {item_idx} out of range in slot {slot_idx} ({len} items)")]
29 ItemOutOfRange {
30 slot_idx: usize,
31 item_idx: usize,
32 len: usize,
33 },
34 #[error("unknown gear slot slug: {0}")]
35 UnknownSlot(String),
36 #[error("sim config serialization failed: {0}")]
37 Serialize(#[source] IntentConfigError),
38}
39}
40
41impl DeriveError {
42 const fn slot_out_of_range(slot_idx: usize, len: usize) -> Self {
43 Self {
44 kind: DeriveErrorKind::SlotOutOfRange { slot_idx, len },
45 }
46 }
47
48 const fn item_out_of_range(slot_idx: usize, item_idx: usize, len: usize) -> Self {
49 Self {
50 kind: DeriveErrorKind::ItemOutOfRange {
51 slot_idx,
52 item_idx,
53 len,
54 },
55 }
56 }
57
58 fn unknown_slot(slot: String) -> Self {
59 Self {
60 kind: DeriveErrorKind::UnknownSlot(slot),
61 }
62 }
63
64 fn serialize(error: IntentConfigError) -> Self {
65 Self {
66 kind: DeriveErrorKind::Serialize(error),
67 }
68 }
69
70 #[must_use]
72 pub fn unknown_slot_slug(&self) -> Option<&str> {
73 match &self.kind {
74 DeriveErrorKind::UnknownSlot(slot) => Some(slot),
75 _ => None,
76 }
77 }
78}
79
80impl From<IntentConfigError> for DeriveError {
81 fn from(source: IntentConfigError) -> Self {
82 Self::serialize(source)
83 }
84}
85
86#[must_use]
88pub fn permutation_picks(payload: &TournamentPayload, perm_idx: u32) -> Vec<(usize, usize)> {
89 let mut remaining = u64::from(perm_idx);
90 let mut picks: Vec<(usize, usize)> = vec![(0, 0); payload.slot_candidates.len()];
91
92 for (i, sc) in payload.slot_candidates.iter().enumerate().rev() {
93 let radix = sc.items.len().max(1) as u64;
94 let choice = usize::try_from(remaining % radix).unwrap_or(usize::MAX);
95
96 remaining /= radix;
97 picks[i] = (i, choice);
99 }
100
101 picks
102}
103
104pub fn derive_work_unit_config(
110 base_intent: &SimConfigIntent,
111 payload: &TournamentPayload,
112 kind: &RuntimeWorkItemKind,
113) -> Result<String, DeriveError> {
114 match kind {
115 RuntimeWorkItemKind::Tournament { perm_idx } => {
116 derive_tournament_config(base_intent, payload, *perm_idx)
117 }
118 RuntimeWorkItemKind::Base
119 | RuntimeWorkItemKind::FactorialMain { .. }
120 | RuntimeWorkItemKind::FactorialInteraction { .. } => {
121 Ok(serialize_sim_config(base_intent)?)
122 }
123 }
124}
125
126fn derive_tournament_config(
127 base_intent: &SimConfigIntent,
128 payload: &TournamentPayload,
129 perm_idx: u32,
130) -> Result<String, DeriveError> {
131 let picks = permutation_picks(payload, perm_idx);
132 let mut intent = SimConfigIntent {
133 intent_version: base_intent.intent_version.clone(),
134 spec: base_intent.spec,
135 rotation_id: base_intent.rotation_id.clone(),
136 loadout: base_intent.loadout.clone(),
137 expansion_talents: base_intent.expansion_talents.clone(),
138 player_level: base_intent.player_level,
139 player_expansion_id: base_intent.player_expansion_id,
140 encounter: base_intent.encounter.clone(),
141 settings: base_intent.settings.clone(),
142 gear: base_intent.gear.clone(),
143 };
144
145 for (slot_idx, item_idx) in picks {
148 let sc = payload.slot_candidates.get(slot_idx).ok_or_else(|| {
149 DeriveError::slot_out_of_range(slot_idx, payload.slot_candidates.len())
150 })?;
151 let item = sc
152 .items
153 .get(item_idx)
154 .ok_or_else(|| DeriveError::item_out_of_range(slot_idx, item_idx, sc.items.len()))?;
155 let gear_slot = GearSlot::from_slug(&sc.slot)
156 .ok_or_else(|| DeriveError::unknown_slot(sc.slot.clone()))?;
157 let entry = GearEntry {
158 slot: gear_slot,
159 id: item.item_id,
160 bonus_ids: if item.bonus_ids.is_empty() {
161 None
162 } else {
163 Some(item.bonus_ids.clone())
164 },
165 enchant_id: if item.enchant_id == 0 {
166 None
167 } else {
168 Some(item.enchant_id)
169 },
170 gem_ids: if item.gem_ids.is_empty() {
171 None
172 } else {
173 Some(item.gem_ids.clone())
174 },
175 crafted_stats: None,
176 crafting_quality: None,
177 drop_level: None,
178 ilevel: None,
179 };
180
181 if let Some(existing) = intent.gear.iter_mut().find(|g| g.slot == gear_slot) {
182 *existing = entry;
183 } else {
184 intent.gear.push(entry);
185 }
186 }
187
188 Ok(serialize_sim_config(&intent)?)
189}
190
191#[cfg(test)]
192mod tests {
193 use std::{collections::BTreeMap, error::Error as _};
194
195 use googletest::prelude::*;
196 use wowlab_types::proto::{CandidateItem, SlotCandidates};
197
198 use super::*;
199
200 fn legacy_apply_gear_overrides(
201 base_intent: &SimConfigIntent,
202 payload: &TournamentPayload,
203 perm_idx: u32,
204 ) -> Option<String> {
205 let picks = permutation_picks(payload, perm_idx);
206 let mut intent = SimConfigIntent {
207 intent_version: base_intent.intent_version.clone(),
208 spec: base_intent.spec,
209 rotation_id: base_intent.rotation_id.clone(),
210 loadout: base_intent.loadout.clone(),
211 expansion_talents: base_intent.expansion_talents.clone(),
212 player_level: base_intent.player_level,
213 player_expansion_id: base_intent.player_expansion_id,
214 encounter: base_intent.encounter.clone(),
215 settings: base_intent.settings.clone(),
216 gear: base_intent.gear.clone(),
217 };
218
219 for (slot_idx, item_idx) in picks {
220 let sc = payload.slot_candidates.get(slot_idx)?;
221 let item = sc.items.get(item_idx)?;
222 let gear_slot = GearSlot::from_slug(&sc.slot)?;
223 let entry = GearEntry {
224 slot: gear_slot,
225 id: item.item_id,
226 bonus_ids: if item.bonus_ids.is_empty() {
227 None
228 } else {
229 Some(item.bonus_ids.clone())
230 },
231 enchant_id: if item.enchant_id == 0 {
232 None
233 } else {
234 Some(item.enchant_id)
235 },
236 gem_ids: if item.gem_ids.is_empty() {
237 None
238 } else {
239 Some(item.gem_ids.clone())
240 },
241 crafted_stats: None,
242 crafting_quality: None,
243 drop_level: None,
244 ilevel: None,
245 };
246
247 if let Some(existing) = intent.gear.iter_mut().find(|g| g.slot == gear_slot) {
248 *existing = entry;
249 } else {
250 intent.gear.push(entry);
251 }
252 }
253
254 serialize_sim_config(&intent).ok()
255 }
256
257 fn base_intent() -> SimConfigIntent {
258 let settings = BTreeMap::new();
259
260 SimConfigIntent {
261 intent_version: "v2".into(),
262 spec: 253,
263 rotation_id: "rot-1".into(),
264 loadout: None,
265 expansion_talents: BTreeMap::new(),
266 player_level: 80,
267 player_expansion_id: 11,
268 encounter: wowlab_types::sim::EncounterDefinition::patchwerk(300.0, 80, 11)
269 .expect("valid patchwerk"),
270 settings,
271 gear: vec![GearEntry {
272 slot: GearSlot::Head,
273 id: 1,
274 bonus_ids: None,
275 enchant_id: None,
276 gem_ids: None,
277 crafted_stats: None,
278 crafting_quality: None,
279 drop_level: None,
280 ilevel: None,
281 }],
282 }
283 }
284
285 fn mixed_radix_payload() -> TournamentPayload {
286 let mk = |slot: &str, ids: &[u32]| SlotCandidates {
287 slot: slot.into(),
288 items: ids
289 .iter()
290 .map(|&item_id| CandidateItem {
291 item_id,
292 bonus_ids: vec![],
293 enchant_id: 0,
294 gem_ids: vec![],
295 })
296 .collect(),
297 };
298
299 TournamentPayload {
300 slot_candidates: vec![
301 mk("head", &[100, 101, 102]),
302 mk("trinket1", &[200, 201]),
303 mk("chest", &[300, 301, 302, 303]),
304 ],
305 }
306 }
307
308 #[gtest]
309 fn permutation_picks_mixed_radix() -> Result<()> {
310 let payload = mixed_radix_payload();
311
312 verify_that!(
313 permutation_picks(&payload, 0),
314 container_eq([(0, 0), (1, 0), (2, 0)])
315 )?;
316 verify_that!(
317 permutation_picks(&payload, 1),
318 container_eq([(0, 0), (1, 0), (2, 1)])
319 )?;
320 verify_that!(
321 permutation_picks(&payload, 4),
322 container_eq([(0, 0), (1, 1), (2, 0)])
323 )?;
324
325 verify_that!(
326 permutation_picks(&payload, 8),
327 container_eq([(0, 1), (1, 0), (2, 0)])
328 )
329 }
330
331 #[gtest]
332 fn derive_work_unit_config_tournament_byte_identical_to_legacy() -> Result<()> {
333 let intent = base_intent();
334 let payload = mixed_radix_payload();
335 let total = 3 * 2 * 4;
336
337 for perm_idx in 0..total {
338 let derived = derive_work_unit_config(
339 &intent,
340 &payload,
341 &RuntimeWorkItemKind::Tournament { perm_idx },
342 )
343 .or_fail()?;
344 let legacy = legacy_apply_gear_overrides(&intent, &payload, perm_idx).or_fail()?;
345
346 scoped_trace!("permutation index: {perm_idx}");
347 verify_that!(derived, eq(&legacy))?;
348 }
349
350 Ok(())
351 }
352
353 #[gtest]
354 fn derive_work_unit_config_tournament_overrides_slots() -> Result<()> {
355 let intent = base_intent();
356 let payload = mixed_radix_payload();
357 let config = derive_work_unit_config(
358 &intent,
359 &payload,
360 &RuntimeWorkItemKind::Tournament { perm_idx: 8 },
361 )
362 .or_fail()?;
363 let parsed = crate::sim::intent::parse_sim_config(&config).or_fail()?;
364 let head = parsed
365 .gear
366 .iter()
367 .find(|g| g.slot == GearSlot::Head)
368 .or_fail()?;
369
370 verify_that!(head.id, eq(101))?;
371 let chest = parsed
372 .gear
373 .iter()
374 .find(|g| g.slot == GearSlot::Chest)
375 .or_fail()?;
376
377 verify_that!(chest.id, eq(300))
378 }
379
380 #[gtest]
381 fn derive_work_unit_config_factorial_returns_base_config() -> Result<()> {
382 let intent = base_intent();
383 let payload = mixed_radix_payload();
384 let base = serialize_sim_config(&intent).or_fail()?;
385
386 let main = derive_work_unit_config(
387 &intent,
388 &payload,
389 &RuntimeWorkItemKind::FactorialMain {
390 slot_index: 1,
391 item_idx: 1,
392 },
393 )
394 .or_fail()?;
395
396 verify_that!(main, eq(&base))?;
397
398 let interaction = derive_work_unit_config(
399 &intent,
400 &payload,
401 &RuntimeWorkItemKind::FactorialInteraction {
402 slot_a: 0,
403 item_a: 1,
404 slot_b: 2,
405 item_b: 1,
406 },
407 )
408 .or_fail()?;
409
410 verify_that!(interaction, eq(&base))?;
411
412 let tournament = derive_work_unit_config(
413 &intent,
414 &payload,
415 &RuntimeWorkItemKind::Tournament { perm_idx: 5 },
416 )
417 .or_fail()?;
418
419 verify_that!(tournament, not(eq(&base)))
420 }
421
422 #[gtest]
423 fn derive_work_unit_config_invalid_slot_errors() -> Result<()> {
424 let intent = base_intent();
425 let payload = TournamentPayload {
426 slot_candidates: vec![SlotCandidates {
427 slot: "not_a_slot".into(),
428 items: vec![CandidateItem {
429 item_id: 1,
430 bonus_ids: vec![],
431 enchant_id: 0,
432 gem_ids: vec![],
433 }],
434 }],
435 };
436 let err = derive_work_unit_config(
437 &intent,
438 &payload,
439 &RuntimeWorkItemKind::Tournament { perm_idx: 0 },
440 )
441 .err()
442 .or_fail()?;
443
444 verify_that!(err.unknown_slot_slug(), some(eq("not_a_slot")))
445 }
446
447 #[gtest]
448 fn derive_serialization_error_preserves_intent_config_source() -> Result<()> {
449 let mut intent = base_intent();
450
451 intent.intent_version = "v1".to_string();
452
453 let error = derive_work_unit_config(
454 &intent,
455 &TournamentPayload::default(),
456 &RuntimeWorkItemKind::Base,
457 )
458 .err()
459 .or_fail()?;
460
461 verify_true!(
462 error
463 .source()
464 .and_then(std::error::Error::source)
465 .is_some_and(<dyn std::error::Error>::is::<IntentConfigError>)
466 )?;
467
468 verify_that!(
469 error.to_string(),
470 starts_with("sim config serialization failed: unsupported intent version \"v1\"")
471 )
472 }
473
474 #[gtest]
475 fn derive_work_unit_config_is_deterministic() -> Result<()> {
476 let intent = base_intent();
477 let payload = mixed_radix_payload();
478 let kind = RuntimeWorkItemKind::Tournament { perm_idx: 7 };
479 let first = derive_work_unit_config(&intent, &payload, &kind).or_fail()?;
480 let second = derive_work_unit_config(&intent, &payload, &kind).or_fail()?;
481
482 verify_that!(first, eq(&second))
483 }
484}