Skip to main content

wowlab_common/sim/permutations/
space.rs

1use std::{collections::HashMap, fmt, hash::BuildHasher, num::TryFromIntError};
2
3use serde::{
4    Deserialize, Deserializer, Serialize,
5    de::{Error as _, MapAccess, Visitor},
6};
7#[cfg(feature = "wasm")]
8use tsify::Tsify;
9use wowlab_parsers::{Item, Profile};
10use wowlab_types::{
11    game::GearSlot,
12    sim::{FastMap, FastSet},
13};
14
15use crate::sim::sentinel_config::{CandidateItemConfig, SlotCandidatesConfig};
16
17/// Where a candidate came from.
18#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[cfg_attr(feature = "wasm", derive(Tsify))]
20#[serde(rename_all = "camelCase")]
21// #t(rust_non_exhaustive_on_public) source list is fixed
22pub enum CandidateSource {
23    Equipped,
24    Bag,
25    Weekly,
26}
27
28/// Candidate item for a slot.
29#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
30#[cfg_attr(feature = "wasm", derive(Tsify))]
31#[serde(rename_all = "camelCase")]
32pub struct Candidate {
33    pub item: Item,
34    pub source: CandidateSource,
35}
36
37/// One slot with its candidates.
38#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
39#[cfg_attr(feature = "wasm", derive(Tsify))]
40#[serde(rename_all = "camelCase")]
41pub struct SlotCandidates {
42    pub slot: GearSlot,
43    pub candidates: Vec<Candidate>,
44}
45
46impl SlotCandidates {
47    #[must_use]
48    pub fn count(&self) -> usize {
49        self.candidates.len()
50    }
51
52    /// True when more than one candidate.
53    #[must_use]
54    pub fn is_contested(&self) -> bool {
55        self.candidates.len() > 1
56    }
57}
58
59/// Cartesian product space.
60#[derive(Clone, Debug, PartialEq, Serialize)]
61#[cfg_attr(feature = "wasm", derive(Tsify))]
62#[serde(rename_all = "camelCase")]
63pub struct PermutationSpace {
64    slots: Vec<SlotCandidates>,
65    contested_indices: Vec<usize>,
66    total: u64,
67    contested_count: u32,
68    total_candidates: u32,
69}
70
71const PERMUTATION_SPACE_FIELDS: &[&str] = &[
72    "slots",
73    "contestedIndices",
74    "total",
75    "contestedCount",
76    "totalCandidates",
77];
78
79#[derive(Clone, Copy, Deserialize)]
80#[serde(field_identifier, rename_all = "camelCase")]
81enum PermutationSpaceField {
82    Slots,
83    ContestedIndices,
84    Total,
85    ContestedCount,
86    TotalCandidates,
87}
88
89#[derive(Default)]
90struct PendingPermutationSpace {
91    slots: Option<Vec<SlotCandidates>>,
92    contested_indices: Option<Vec<usize>>,
93    total: Option<u64>,
94    contested_count: Option<u32>,
95    total_candidates: Option<u32>,
96}
97
98impl PendingPermutationSpace {
99    fn deserialize_field<'de, A>(
100        &mut self,
101        field: PermutationSpaceField,
102        map: &mut A,
103    ) -> Result<(), A::Error>
104    where
105        A: MapAccess<'de>,
106    {
107        match field {
108            PermutationSpaceField::Slots => {
109                deserialize_required_field(&mut self.slots, map, "slots")
110            }
111            PermutationSpaceField::ContestedIndices => {
112                deserialize_required_field(&mut self.contested_indices, map, "contestedIndices")
113            }
114            PermutationSpaceField::Total => {
115                deserialize_required_field(&mut self.total, map, "total")
116            }
117            PermutationSpaceField::ContestedCount => {
118                deserialize_required_field(&mut self.contested_count, map, "contestedCount")
119            }
120            PermutationSpaceField::TotalCandidates => {
121                deserialize_required_field(&mut self.total_candidates, map, "totalCandidates")
122            }
123        }
124    }
125
126    fn validate<E>(self) -> Result<PermutationSpace, E>
127    where
128        E: serde::de::Error,
129    {
130        let space = PermutationSpace {
131            slots: self.slots.ok_or_else(|| E::missing_field("slots"))?,
132            contested_indices: self
133                .contested_indices
134                .ok_or_else(|| E::missing_field("contestedIndices"))?,
135            total: self.total.ok_or_else(|| E::missing_field("total"))?,
136            contested_count: self
137                .contested_count
138                .ok_or_else(|| E::missing_field("contestedCount"))?,
139            total_candidates: self
140                .total_candidates
141                .ok_or_else(|| E::missing_field("totalCandidates"))?,
142        };
143
144        if let Err(error) = space.validate() {
145            return Err(E::custom(error));
146        }
147
148        Ok(space)
149    }
150}
151
152struct PermutationSpaceVisitor;
153
154impl<'de> Visitor<'de> for PermutationSpaceVisitor {
155    type Value = PermutationSpace;
156
157    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        formatter.write_str("a permutation space")
159    }
160
161    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
162    where
163        A: MapAccess<'de>,
164    {
165        let mut pending = PendingPermutationSpace::default();
166
167        while let Some(field) = map.next_key()? {
168            pending.deserialize_field(field, &mut map)?;
169        }
170
171        pending.validate()
172    }
173}
174
175fn deserialize_required_field<'de, T, A>(
176    destination: &mut Option<T>,
177    map: &mut A,
178    field: &'static str,
179) -> Result<(), A::Error>
180where
181    T: Deserialize<'de>,
182    A: MapAccess<'de>,
183{
184    if destination.is_some() {
185        return Err(A::Error::duplicate_field(field));
186    }
187
188    *destination = Some(map.next_value()?);
189
190    Ok(())
191}
192
193/// Largest exactly representable integer in JavaScript's `number` type.
194pub const MAX_PERMUTATION_TOTAL: u64 = 9_007_199_254_740_991;
195
196/// A mixed-radix choice is stored as `u8`, so one slot can expose at most 256 candidates.
197pub const MAX_CANDIDATES_PER_SLOT: usize = u8::MAX as usize + 1;
198
199wowlab_engine_macros::define_error! {
200/// Invalid or unsupported permutation-space construction.
201#[derive(Debug, Eq, PartialEq)]
202pub struct PermutationError {
203    #[source]
204    kind: PermutationErrorKind,
205}
206
207#[derive(Debug, thiserror::Error, Eq, PartialEq)]
208enum PermutationErrorKind {
209    #[error("slot {slot_index} has no candidates")]
210    EmptySlot { slot_index: usize },
211    #[error(
212        "slot {slot_index} has {count} candidates; maximum supported is {MAX_CANDIDATES_PER_SLOT}"
213    )]
214    TooManyCandidates { slot_index: usize, count: usize },
215    #[error("permutation cardinality exceeds the JavaScript-safe maximum {MAX_PERMUTATION_TOTAL}")]
216    CardinalityTooLarge,
217    #[error("contestedIndices does not match the contested slots derived from slots")]
218    ContestedIndicesMismatch,
219    #[error("{field} is {actual}, but the value derived from slots is {expected}")]
220    DerivedFieldMismatch {
221        field: &'static str,
222        expected: u64,
223        actual: u64,
224    },
225    #[error("permutation cost overflow while computing {operation}")]
226    CostOverflow { operation: &'static str },
227    #[error("{field} cannot be represented by the permutation-space metadata: {source}")]
228    IntegerConversion {
229        field: &'static str,
230        #[source]
231        source: TryFromIntError,
232    },
233}
234}
235
236impl PermutationError {
237    const fn new(kind: PermutationErrorKind) -> Self {
238        Self { kind }
239    }
240
241    const fn empty_slot(slot_index: usize) -> Self {
242        Self::new(PermutationErrorKind::EmptySlot { slot_index })
243    }
244
245    const fn too_many_candidates(slot_index: usize, count: usize) -> Self {
246        Self::new(PermutationErrorKind::TooManyCandidates { slot_index, count })
247    }
248
249    const fn cardinality_too_large() -> Self {
250        Self::new(PermutationErrorKind::CardinalityTooLarge)
251    }
252
253    const fn contested_indices_mismatch() -> Self {
254        Self::new(PermutationErrorKind::ContestedIndicesMismatch)
255    }
256
257    const fn derived_field_mismatch(field: &'static str, expected: u64, actual: u64) -> Self {
258        Self::new(PermutationErrorKind::DerivedFieldMismatch {
259            field,
260            expected,
261            actual,
262        })
263    }
264
265    pub(super) const fn cost_overflow(operation: &'static str) -> Self {
266        Self::new(PermutationErrorKind::CostOverflow { operation })
267    }
268
269    fn integer_conversion(field: &'static str, source: TryFromIntError) -> Self {
270        Self::new(PermutationErrorKind::IntegerConversion { field, source })
271    }
272}
273
274impl PermutationSpace {
275    /// Validate all structural and derived invariants.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error for empty or oversized candidate sets and inconsistent derived fields.
280    /// Cardinalities not exactly representable by the JavaScript API are also rejected.
281    pub fn validate(&self) -> Result<(), PermutationError> {
282        let (contested_indices, total, contested_count, total_candidates) =
283            derive_space_metadata(&self.slots)?;
284
285        if self.contested_indices != contested_indices {
286            return Err(PermutationError::contested_indices_mismatch());
287        }
288
289        validate_derived_field("total", total, self.total)?;
290        validate_derived_field(
291            "contestedCount",
292            u64::from(contested_count),
293            u64::from(self.contested_count),
294        )?;
295
296        validate_derived_field(
297            "totalCandidates",
298            u64::from(total_candidates),
299            u64::from(self.total_candidates),
300        )
301    }
302
303    #[must_use]
304    pub fn slots(&self) -> &[SlotCandidates] {
305        &self.slots
306    }
307
308    #[must_use]
309    pub fn contested_indices(&self) -> &[usize] {
310        &self.contested_indices
311    }
312
313    #[must_use]
314    pub const fn total(&self) -> u64 {
315        self.total
316    }
317
318    #[must_use]
319    pub const fn contested_count(&self) -> u32 {
320        self.contested_count
321    }
322
323    #[must_use]
324    pub const fn total_candidates(&self) -> u32 {
325        self.total_candidates
326    }
327
328    /// Mixed-radix decode of a combination index.
329    #[must_use]
330    pub fn decode(&self, mut index: u64) -> Vec<u8> {
331        let mut choices = vec![0u8; self.contested_indices.len()];
332
333        // #t(block: rust_unchecked_indexing) indices bounded by construction
334        for i in (0..self.contested_indices.len()).rev() {
335            let slot_idx = self.contested_indices[i];
336            let radix = self.slots[slot_idx].count() as u64;
337
338            // BOUNDS: i is produced by the contested_indices range used to size choices.
339
340            choices[i] = u8::try_from(index % radix).unwrap_or(u8::MAX);
341            index /= radix;
342        }
343
344        choices
345    }
346
347    /// Encode per-slot choices to a combination index.
348    #[must_use]
349    pub fn encode(&self, choices: &[u8]) -> u64 {
350        let mut index = 0u64;
351
352        // #t(block: rust_unchecked_indexing) indices bounded by construction
353        for (i, &choice) in choices.iter().enumerate() {
354            let slot_idx = self.contested_indices[i];
355            let radix = self.slots[slot_idx].count() as u64;
356
357            index = index * radix + u64::from(choice);
358        }
359
360        index
361    }
362
363    /// Full item picks for a combination index.
364    #[must_use]
365    pub fn picks(&self, index: u64) -> Vec<(GearSlot, &Candidate)> {
366        let choices = self.decode(index);
367        let mut choice_map = FastMap::default();
368
369        choice_map.reserve(self.contested_indices.len());
370
371        // #t(block: rust_unchecked_indexing) ci bounded by enumerate
372        for (ci, &slot_idx) in self.contested_indices.iter().enumerate() {
373            choice_map.insert(slot_idx, choices[ci]);
374        }
375
376        self.slots
377            .iter()
378            .enumerate()
379            .filter_map(|(i, sc)| {
380                let pick_idx = *choice_map.get(&i).unwrap_or(&0) as usize;
381
382                sc.candidates.get(pick_idx).map(|c| (sc.slot, c))
383            })
384            .collect()
385    }
386
387    #[must_use]
388    pub fn contested_slots(&self) -> Vec<&SlotCandidates> {
389        self.contested_indices
390            .iter()
391            // BOUNDS: contested_indices contains valid slot indices by construction
392            .map(|&i| &self.slots[i])
393            .collect()
394    }
395}
396
397impl<'de> Deserialize<'de> for PermutationSpace {
398    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
399    where
400        D: Deserializer<'de>,
401    {
402        deserializer.deserialize_struct(
403            "PermutationSpace",
404            PERMUTATION_SPACE_FIELDS,
405            PermutationSpaceVisitor,
406        )
407    }
408}
409
410/// Build a permutation space from a parsed `SimC` profile.
411///
412/// # Errors
413///
414/// Returns an error when a slot has too many candidates.
415/// Cartesian products not exactly representable by the JavaScript API are also rejected.
416pub fn build_space(profile: &Profile) -> Result<PermutationSpace, PermutationError> {
417    let candidate_count =
418        profile.equipment.len() + profile.bag_items.len() + profile.weekly_rewards.len();
419    let mut slot_map: FastMap<GearSlot, Vec<Candidate>> = FastMap::default();
420
421    slot_map.reserve(candidate_count);
422
423    // #t(block: rust_clone_in_loop) construction-time, not hot path
424    for item in &profile.equipment {
425        slot_map.entry(item.gear.slot).or_default().push(Candidate {
426            item: item.clone(),
427            source: CandidateSource::Equipped,
428        });
429    }
430
431    for item in &profile.bag_items {
432        let candidates = slot_map.entry(item.gear.slot).or_default();
433
434        if !has_dup(candidates, item) {
435            candidates.push(Candidate {
436                // #t(rust_clone_in_loop) candidate storage owns the selected profile item
437                item: item.clone(),
438                source: CandidateSource::Bag,
439            });
440        }
441    }
442
443    for item in &profile.weekly_rewards {
444        let candidates = slot_map.entry(item.gear.slot).or_default();
445
446        if !has_dup(candidates, item) {
447            candidates.push(Candidate {
448                // #t(rust_clone_in_loop) candidate storage owns the selected profile item
449                item: item.clone(),
450                source: CandidateSource::Weekly,
451            });
452        }
453    }
454
455    finalize_space(collect_slots_in_order(profile, &mut slot_map))
456}
457
458/// Build a permutation space restricted to user-selected bag/weekly items.
459///
460/// # Errors
461///
462/// Returns an error when a slot has too many candidates.
463/// Cartesian products not exactly representable by the JavaScript API are also rejected.
464// #t(fn: rust_clone_in_loop) construction-time, not hot path
465pub fn build_space_for_selection<S>(
466    profile: &Profile,
467    selections: &HashMap<GearSlot, Vec<u32>, S>,
468) -> Result<PermutationSpace, PermutationError>
469where
470    S: BuildHasher,
471{
472    let candidate_count =
473        profile.equipment.len() + profile.bag_items.len() + profile.weekly_rewards.len();
474    let mut slot_map: FastMap<GearSlot, Vec<Candidate>> = FastMap::default();
475
476    slot_map.reserve(candidate_count);
477
478    for item in &profile.equipment {
479        slot_map.entry(item.gear.slot).or_default().push(Candidate {
480            item: item.clone(),
481            source: CandidateSource::Equipped,
482        });
483    }
484
485    for (&slot, indices) in selections {
486        if indices.is_empty() {
487            continue;
488        }
489
490        let bag: Vec<&Item> = profile
491            .bag_items
492            .iter()
493            .filter(|i| i.gear.slot == slot)
494            .collect();
495        let weekly: Vec<&Item> = profile
496            .weekly_rewards
497            .iter()
498            .filter(|i| i.gear.slot == slot)
499            .collect();
500        let candidates = slot_map.entry(slot).or_default();
501
502        for &idx in indices {
503            let i = idx as usize;
504            let (item, source) = if i < bag.len() {
505                // BOUNDS: i < bag.len() checked above
506                (bag[i], CandidateSource::Bag)
507            } else if i - bag.len() < weekly.len() {
508                // BOUNDS: i - bag.len() < weekly.len() checked above
509                (weekly[i - bag.len()], CandidateSource::Weekly)
510            } else {
511                continue;
512            };
513
514            if !has_dup(candidates, item) {
515                candidates.push(Candidate {
516                    item: item.clone(),
517                    source,
518                });
519            }
520        }
521    }
522
523    finalize_space(collect_slots_in_order(profile, &mut slot_map))
524}
525
526/// Tournament `slot_candidates` payload; contested slots only.
527#[must_use]
528pub fn tournament_payload(space: &PermutationSpace) -> Vec<SlotCandidatesConfig> {
529    space
530        .slots()
531        .iter()
532        .filter(|sc| sc.is_contested())
533        .map(|sc| SlotCandidatesConfig {
534            slot: sc.slot.to_string(),
535            items: sc
536                .candidates
537                .iter()
538                .map(|c| CandidateItemConfig {
539                    item_id: c.item.gear.id,
540                    bonus_ids: c.item.gear.bonus_ids.clone().unwrap_or_default(),
541                    enchant_id: c.item.gear.enchant_id.unwrap_or(0),
542                    gem_ids: c.item.gear.gem_ids.clone().unwrap_or_default(),
543                })
544                .collect(),
545        })
546        .collect()
547}
548
549fn collect_slots_in_order(
550    profile: &Profile,
551    slot_map: &mut FastMap<GearSlot, Vec<Candidate>>,
552) -> Vec<SlotCandidates> {
553    let mut seen = FastSet::default();
554
555    seen.reserve(profile.equipment.len());
556    let mut slots = Vec::with_capacity(slot_map.len());
557
558    for item in &profile.equipment {
559        if seen.insert(item.gear.slot) {
560            if let Some(candidates) = slot_map.remove(&item.gear.slot) {
561                slots.push(SlotCandidates {
562                    slot: item.gear.slot,
563                    candidates,
564                });
565            }
566        }
567    }
568
569    for (slot, candidates) in slot_map.drain() {
570        slots.push(SlotCandidates { slot, candidates });
571    }
572
573    slots
574}
575
576fn finalize_space(slots: Vec<SlotCandidates>) -> Result<PermutationSpace, PermutationError> {
577    let (contested_indices, total, contested_count, total_candidates) =
578        derive_space_metadata(&slots)?;
579
580    Ok(PermutationSpace {
581        slots,
582        contested_indices,
583        total,
584        contested_count,
585        total_candidates,
586    })
587}
588
589fn derive_space_metadata(
590    slots: &[SlotCandidates],
591) -> Result<(Vec<usize>, u64, u32, u32), PermutationError> {
592    let mut contested_indices = Vec::new();
593    let mut total = 1_u64;
594    let mut total_candidates = 0_u32;
595
596    for (slot_index, slot) in slots.iter().enumerate() {
597        let count = slot.count();
598
599        if count == 0 {
600            return Err(PermutationError::empty_slot(slot_index));
601        }
602
603        if count > MAX_CANDIDATES_PER_SLOT {
604            return Err(PermutationError::too_many_candidates(slot_index, count));
605        }
606
607        let count_u32 = u32::try_from(count).map_err(|source| {
608            PermutationError::integer_conversion("slot candidate count", source)
609        })?;
610
611        total_candidates = total_candidates
612            .checked_add(count_u32)
613            .ok_or_else(PermutationError::cardinality_too_large)?;
614
615        if slot.is_contested() {
616            contested_indices.push(slot_index);
617            total = total
618                .checked_mul(u64::from(count_u32))
619                .filter(|&value| value <= MAX_PERMUTATION_TOTAL)
620                .ok_or_else(PermutationError::cardinality_too_large)?;
621        }
622    }
623
624    let contested_count = u32::try_from(contested_indices.len())
625        .map_err(|source| PermutationError::integer_conversion("contested slot count", source))?;
626
627    Ok((contested_indices, total, contested_count, total_candidates))
628}
629
630fn validate_derived_field(
631    field: &'static str,
632    expected: u64,
633    actual: u64,
634) -> Result<(), PermutationError> {
635    if expected == actual {
636        Ok(())
637    } else {
638        Err(PermutationError::derived_field_mismatch(
639            field, expected, actual,
640        ))
641    }
642}
643
644fn has_dup(candidates: &[Candidate], item: &Item) -> bool {
645    candidates
646        .iter()
647        .any(|c| c.item.gear.id == item.gear.id && c.item.gear.bonus_ids == item.gear.bonus_ids)
648}