Skip to main content

wowlab_common/sim/
derive.rs

1// #t(file: rust_inline_test_module_size) tests exercise private derivation helpers and legacy equivalence fixtures
2
3//! Single source of truth for the `(base, payload, kind)` work-expansion mapping called by sentinel and node.
4
5use 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/// Errors from deriving a work-unit sim config.
17#[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    /// Return the unknown slot slug, when this error describes an unsupported slot.
71    #[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/// Decode a permutation index into per-slot item picks `(slot_idx, item_idx)`; mixed-radix, least-significant slot last.
87#[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        // BOUNDS: i from enumerate() matches picks indices (same length)
98        picks[i] = (i, choice);
99    }
100
101    picks
102}
103
104/// Derive the concrete sim config for one work item; only Tournament items apply gear overrides, the rest run the base config.
105///
106/// # Errors
107///
108/// Returns [`DeriveError`] for an unknown tournament slot or a serialization failure.
109pub 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    // #t(fn: rust_clone_in_loop) owned GearEntry requires owned bonus_ids/gem_ids per iteration
146
147    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}