Skip to main content

mtgjson_sdk/booster/
simulator.rs

1//! Booster pack simulator.
2//!
3//! Simulates opening MTG booster packs by querying the booster configuration
4//! tables and performing weighted random card selection, mirroring the
5//! distribution rules defined by MTGJSON.
6
7use crate::connection::Connection;
8use crate::error::{MtgjsonError, Result};
9use rand::{Rng, RngExt};
10use std::collections::HashMap;
11
12/// Simulates opening MTG booster packs using the MTGJSON booster configuration data.
13///
14/// The simulator reads from the set booster parquet tables (content weights,
15/// contents, sheet cards, and sheet metadata) to faithfully reproduce the
16/// distribution of cards in sealed product.
17pub struct BoosterSimulator<'a> {
18    conn: &'a Connection,
19}
20
21impl<'a> BoosterSimulator<'a> {
22    /// Create a new `BoosterSimulator` bound to the given connection.
23    pub fn new(conn: &'a Connection) -> Self {
24        Self { conn }
25    }
26
27    /// Return the available booster type names for a set (e.g. `["draft", "collector"]`).
28    ///
29    /// Returns an empty vector if the set has no booster configuration data.
30    pub fn available_types(&self, set_code: &str) -> Result<Vec<String>> {
31        self.conn.ensure_views(&["set_booster_content_weights"])?;
32
33        let upper = set_code.to_uppercase();
34        let sql = r#"
35            SELECT DISTINCT "boosterName"
36            FROM set_booster_content_weights
37            WHERE "setCode" = ?
38            ORDER BY "boosterName"
39        "#;
40
41        let rows = self.conn.execute(sql, &[upper.clone()])?;
42
43        let types: Vec<String> = rows
44            .into_iter()
45            .filter_map(|r| {
46                r.get("boosterName")
47                    .and_then(|v| v.as_str())
48                    .map(|s| s.to_string())
49            })
50            .collect();
51
52        Ok(types)
53    }
54
55    /// Open a single booster pack and return the card data for every card in the pack.
56    ///
57    /// Performs weighted random selection of a pack template and then weighted
58    /// random selection of cards from each sheet in the template. Returns a
59    /// vector of card JSON objects fetched from the `cards` view.
60    pub fn open_pack(
61        &self,
62        set_code: &str,
63        booster_type: &str,
64    ) -> Result<Vec<serde_json::Value>> {
65        let upper = set_code.to_uppercase();
66
67        // 1. Get all pack templates with their weights
68        let pack_templates = self.get_pack_templates(&upper, booster_type)?;
69        if pack_templates.is_empty() {
70            return Err(MtgjsonError::NotFound(format!(
71                "No booster configuration found for set '{}' type '{}'",
72                set_code, booster_type
73            )));
74        }
75
76        // 2. Pick a random pack template (weighted by pack weight)
77        let template = pick_pack(&pack_templates);
78
79        // 3. For each sheet in the template, pick cards
80        let mut all_uuids: Vec<String> = Vec::new();
81
82        if let Some(sheets) = template.get("sheets") {
83            if let Some(sheets_map) = sheets.as_object() {
84                for (sheet_name, pick_count_val) in sheets_map {
85                    let pick_count = pick_count_val.as_u64().unwrap_or(0) as usize;
86                    if pick_count == 0 {
87                        continue;
88                    }
89
90                    // Get sheet data (card weights and sheet properties)
91                    let sheet = self.get_sheet_data(&upper, booster_type, sheet_name)?;
92                    if let Some(ref sheet_data) = sheet {
93                        let uuids = pick_from_sheet(sheet_data, pick_count);
94                        all_uuids.extend(uuids);
95                    }
96                }
97            }
98        }
99
100        // 4. Fetch card data by UUIDs
101        if all_uuids.is_empty() {
102            return Ok(Vec::new());
103        }
104
105        self.fetch_cards_by_uuids(&all_uuids)
106    }
107
108    /// Open a box containing `packs` booster packs.
109    ///
110    /// Returns a vector of packs, where each pack is a vector of card JSON objects.
111    pub fn open_box(
112        &self,
113        set_code: &str,
114        booster_type: &str,
115        packs: usize,
116    ) -> Result<Vec<Vec<serde_json::Value>>> {
117        let mut box_contents = Vec::with_capacity(packs);
118        for _ in 0..packs {
119            let pack = self.open_pack(set_code, booster_type)?;
120            box_contents.push(pack);
121        }
122        Ok(box_contents)
123    }
124
125    /// Get the contents of a specific sheet as a `{uuid: weight}` map.
126    ///
127    /// Returns `None` if the sheet does not exist for the given set/booster type.
128    pub fn sheet_contents(
129        &self,
130        set_code: &str,
131        booster_type: &str,
132        sheet_name: &str,
133    ) -> Result<Option<HashMap<String, i64>>> {
134        self.conn.ensure_views(&["set_booster_sheet_cards"])?;
135
136        let upper = set_code.to_uppercase();
137        let sql = r#"
138            SELECT "cardUuid", "cardWeight"
139            FROM set_booster_sheet_cards
140            WHERE "setCode" = ?
141              AND "boosterName" = ?
142              AND "sheetName" = ?
143        "#;
144
145        let rows = self.conn.execute(sql, &[upper.clone(), booster_type.to_string(), sheet_name.to_string()])?;
146
147        if rows.is_empty() {
148            return Ok(None);
149        }
150
151        let mut contents: HashMap<String, i64> = HashMap::new();
152        for row in rows {
153            let uuid = row
154                .get("cardUuid")
155                .and_then(|v| v.as_str())
156                .unwrap_or("")
157                .to_string();
158            let weight = row
159                .get("cardWeight")
160                .and_then(|v| v.as_i64())
161                .unwrap_or(1);
162
163            if !uuid.is_empty() {
164                contents.insert(uuid, weight);
165            }
166        }
167
168        Ok(Some(contents))
169    }
170
171    // -----------------------------------------------------------------------
172    // Private helpers
173    // -----------------------------------------------------------------------
174
175    /// Get all pack templates for a set/booster type, each with its weight and sheet layout.
176    fn get_pack_templates(
177        &self,
178        set_code: &str,
179        booster_type: &str,
180    ) -> Result<Vec<serde_json::Value>> {
181        self.conn.ensure_views(&[
182            "set_booster_content_weights",
183            "set_booster_contents",
184        ])?;
185
186        // Get pack indices and weights
187        let weight_sql = r#"
188            SELECT "boosterIndex", "boosterWeight"
189            FROM set_booster_content_weights
190            WHERE "setCode" = ?
191              AND "boosterName" = ?
192            ORDER BY "boosterIndex"
193        "#;
194
195        let weight_rows =
196            self.conn.execute(weight_sql, &[set_code.to_string(), booster_type.to_string()])?;
197
198        if weight_rows.is_empty() {
199            return Ok(Vec::new());
200        }
201
202        // Get sheet picks for each pack template
203        let contents_sql = r#"
204            SELECT "boosterIndex", "sheetName", "sheetPicks"
205            FROM set_booster_contents
206            WHERE "setCode" = ?
207              AND "boosterName" = ?
208            ORDER BY "boosterIndex", "sheetName"
209        "#;
210
211        let contents_rows =
212            self.conn.execute(contents_sql, &[set_code.to_string(), booster_type.to_string()])?;
213
214        // Group contents by booster index
215        let mut contents_map: HashMap<i64, serde_json::Map<String, serde_json::Value>> =
216            HashMap::new();
217        for row in &contents_rows {
218            let idx = row
219                .get("boosterIndex")
220                .and_then(|v| v.as_i64())
221                .unwrap_or(0);
222            let sheet_name = row
223                .get("sheetName")
224                .and_then(|v| v.as_str())
225                .unwrap_or("")
226                .to_string();
227            let picks = row
228                .get("sheetPicks")
229                .and_then(|v| v.as_i64())
230                .unwrap_or(1);
231
232            contents_map
233                .entry(idx)
234                .or_default()
235                .insert(sheet_name, serde_json::Value::Number(picks.into()));
236        }
237
238        // Build template objects
239        let mut templates: Vec<serde_json::Value> = Vec::new();
240        for row in &weight_rows {
241            let idx = row
242                .get("boosterIndex")
243                .and_then(|v| v.as_i64())
244                .unwrap_or(0);
245            let weight = row
246                .get("boosterWeight")
247                .and_then(|v| v.as_i64())
248                .unwrap_or(1);
249
250            let sheets = contents_map
251                .get(&idx)
252                .cloned()
253                .unwrap_or_default();
254
255            templates.push(serde_json::json!({
256                "weight": weight,
257                "sheets": sheets,
258            }));
259        }
260
261        Ok(templates)
262    }
263
264    /// Get the full sheet data (card UUIDs with weights and sheet properties)
265    /// for a specific sheet.
266    fn get_sheet_data(
267        &self,
268        set_code: &str,
269        booster_type: &str,
270        sheet_name: &str,
271    ) -> Result<Option<serde_json::Value>> {
272        self.conn.ensure_views(&[
273            "set_booster_sheet_cards",
274            "set_booster_sheets",
275        ])?;
276
277        // Get sheet properties
278        let props_sql = r#"
279            SELECT "sheetHasBalanceColors", "sheetIsFoil", "sheetIsFixed",
280                   "sheetAllowDuplicates", "totalWeight"
281            FROM set_booster_sheets
282            WHERE "setCode" = ?
283              AND "boosterName" = ?
284              AND "sheetName" = ?
285            LIMIT 1
286        "#;
287
288        let props_rows =
289            self.conn.execute(props_sql, &[set_code.to_string(), booster_type.to_string(), sheet_name.to_string()])?;
290
291        let allow_duplicates = props_rows
292            .first()
293            .and_then(|r| r.get("sheetAllowDuplicates"))
294            .and_then(|v| v.as_bool())
295            .unwrap_or(false);
296
297        let total_weight = props_rows
298            .first()
299            .and_then(|r| r.get("totalWeight"))
300            .and_then(|v| v.as_i64())
301            .unwrap_or(0);
302
303        // Get card UUIDs and weights
304        let cards_sql = r#"
305            SELECT "cardUuid", "cardWeight"
306            FROM set_booster_sheet_cards
307            WHERE "setCode" = ?
308              AND "boosterName" = ?
309              AND "sheetName" = ?
310        "#;
311
312        let card_rows =
313            self.conn.execute(cards_sql, &[set_code.to_string(), booster_type.to_string(), sheet_name.to_string()])?;
314
315        if card_rows.is_empty() {
316            return Ok(None);
317        }
318
319        let mut cards = serde_json::Map::new();
320        for row in &card_rows {
321            let uuid = row
322                .get("cardUuid")
323                .and_then(|v| v.as_str())
324                .unwrap_or("")
325                .to_string();
326            let weight = row
327                .get("cardWeight")
328                .and_then(|v| v.as_i64())
329                .unwrap_or(1);
330
331            if !uuid.is_empty() {
332                cards.insert(uuid, serde_json::Value::Number(weight.into()));
333            }
334        }
335
336        Ok(Some(serde_json::json!({
337            "allowDuplicates": allow_duplicates,
338            "totalWeight": total_weight,
339            "cards": cards,
340        })))
341    }
342
343    /// Fetch full card data for a list of UUIDs from the `cards` view.
344    fn fetch_cards_by_uuids(&self, uuids: &[String]) -> Result<Vec<serde_json::Value>> {
345        if uuids.is_empty() {
346            return Ok(Vec::new());
347        }
348
349        self.conn.ensure_views(&["cards"])?;
350
351        // Build IN clause with positional params
352        let placeholders: Vec<&str> = uuids.iter().map(|_| "?").collect();
353
354        let sql = format!(
355            "SELECT * FROM cards WHERE uuid IN ({})",
356            placeholders.join(", ")
357        );
358
359        let rows = self.conn.execute(&sql, uuids)?;
360
361        // Build a lookup map for ordering
362        let mut card_map: HashMap<String, serde_json::Value> = HashMap::new();
363        for row in rows {
364            if let Some(uuid) = row.get("uuid").and_then(|v| v.as_str()) {
365                let val = serde_json::to_value(&row).unwrap_or(serde_json::Value::Null);
366                card_map.insert(uuid.to_string(), val);
367            }
368        }
369
370        // Return cards in the same order as the UUIDs (preserving duplicates)
371        let mut result = Vec::with_capacity(uuids.len());
372        for uuid in uuids {
373            if let Some(card) = card_map.get(uuid) {
374                result.push(card.clone());
375            }
376        }
377
378        Ok(result)
379    }
380}
381
382// ---------------------------------------------------------------------------
383// Free-standing helpers
384// ---------------------------------------------------------------------------
385
386/// Weighted random pick of a pack template.
387///
388/// Each template is expected to have a `"weight"` integer field. Returns a
389/// reference to the chosen template.
390fn pick_pack(boosters: &[serde_json::Value]) -> &serde_json::Value {
391    let mut rng = rand::rng();
392
393    let total_weight: i64 = boosters
394        .iter()
395        .map(|b| b.get("weight").and_then(|w| w.as_i64()).unwrap_or(1))
396        .sum();
397
398    if total_weight <= 0 {
399        return &boosters[rng.random_range(0..boosters.len())];
400    }
401
402    let mut roll = rng.random_range(0..total_weight);
403
404    for booster in boosters {
405        let w = booster
406            .get("weight")
407            .and_then(|w| w.as_i64())
408            .unwrap_or(1);
409        roll -= w;
410        if roll < 0 {
411            return booster;
412        }
413    }
414
415    // Fallback (should not happen with valid weights)
416    boosters.last().unwrap()
417}
418
419/// Pick `count` card UUIDs from a sheet using weighted random selection.
420///
421/// If the sheet's `allowDuplicates` field is `true`, cards are sampled with
422/// replacement. Otherwise, cards are sampled without replacement (each card
423/// can appear at most once).
424fn pick_from_sheet(sheet: &serde_json::Value, count: usize) -> Vec<String> {
425    let mut rng = rand::rng();
426
427    let allow_duplicates = sheet
428        .get("allowDuplicates")
429        .and_then(|v| v.as_bool())
430        .unwrap_or(false);
431
432    let cards = match sheet.get("cards").and_then(|c| c.as_object()) {
433        Some(c) => c,
434        None => return Vec::new(),
435    };
436
437    if cards.is_empty() {
438        return Vec::new();
439    }
440
441    // Build parallel vectors of UUIDs and weights
442    let mut uuids: Vec<String> = Vec::with_capacity(cards.len());
443    let mut weights: Vec<i64> = Vec::with_capacity(cards.len());
444
445    for (uuid, weight_val) in cards {
446        uuids.push(uuid.clone());
447        weights.push(weight_val.as_i64().unwrap_or(1));
448    }
449
450    if allow_duplicates {
451        // Sample with replacement
452        weighted_choices_with_replacement(&uuids, &weights, count, &mut rng)
453    } else {
454        // Sample without replacement
455        weighted_choices_without_replacement(&uuids, &weights, count, &mut rng)
456    }
457}
458
459/// Weighted random sampling with replacement.
460fn weighted_choices_with_replacement(
461    uuids: &[String],
462    weights: &[i64],
463    count: usize,
464    rng: &mut impl Rng,
465) -> Vec<String> {
466    let total_weight: i64 = weights.iter().sum();
467    if total_weight <= 0 {
468        return Vec::new();
469    }
470
471    let mut results = Vec::with_capacity(count);
472    for _ in 0..count {
473        let mut roll = rng.random_range(0..total_weight);
474        for (i, &w) in weights.iter().enumerate() {
475            roll -= w;
476            if roll < 0 {
477                results.push(uuids[i].clone());
478                break;
479            }
480        }
481    }
482    results
483}
484
485/// Weighted random sampling without replacement.
486fn weighted_choices_without_replacement(
487    uuids: &[String],
488    weights: &[i64],
489    count: usize,
490    rng: &mut impl Rng,
491) -> Vec<String> {
492    let actual_count = count.min(uuids.len());
493    let mut remaining_uuids: Vec<String> = uuids.to_vec();
494    let mut remaining_weights: Vec<i64> = weights.to_vec();
495    let mut results = Vec::with_capacity(actual_count);
496
497    for _ in 0..actual_count {
498        if remaining_uuids.is_empty() {
499            break;
500        }
501
502        let total_weight: i64 = remaining_weights.iter().sum();
503        if total_weight <= 0 {
504            break;
505        }
506
507        let mut roll = rng.random_range(0..total_weight);
508        let mut picked_idx = remaining_uuids.len() - 1;
509
510        for (i, &w) in remaining_weights.iter().enumerate() {
511            roll -= w;
512            if roll < 0 {
513                picked_idx = i;
514                break;
515            }
516        }
517
518        results.push(remaining_uuids.remove(picked_idx));
519        remaining_weights.remove(picked_idx);
520    }
521
522    results
523}