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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[cfg_attr(feature = "wasm", derive(Tsify))]
20#[serde(rename_all = "camelCase")]
21pub enum CandidateSource {
23 Equipped,
24 Bag,
25 Weekly,
26}
27
28#[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#[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 #[must_use]
54 pub fn is_contested(&self) -> bool {
55 self.candidates.len() > 1
56 }
57}
58
59#[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
193pub const MAX_PERMUTATION_TOTAL: u64 = 9_007_199_254_740_991;
195
196pub const MAX_CANDIDATES_PER_SLOT: usize = u8::MAX as usize + 1;
198
199wowlab_engine_macros::define_error! {
200#[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 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 #[must_use]
330 pub fn decode(&self, mut index: u64) -> Vec<u8> {
331 let mut choices = vec![0u8; self.contested_indices.len()];
332
333 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 choices[i] = u8::try_from(index % radix).unwrap_or(u8::MAX);
341 index /= radix;
342 }
343
344 choices
345 }
346
347 #[must_use]
349 pub fn encode(&self, choices: &[u8]) -> u64 {
350 let mut index = 0u64;
351
352 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 #[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 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 .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
410pub 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 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 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 item: item.clone(),
450 source: CandidateSource::Weekly,
451 });
452 }
453 }
454
455 finalize_space(collect_slots_in_order(profile, &mut slot_map))
456}
457
458pub 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 (bag[i], CandidateSource::Bag)
507 } else if i - bag.len() < weekly.len() {
508 (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#[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}