Skip to main content

gin_rummy_engine/
mc.rs

1//! [`MonteCarloBot`]: determinized Monte Carlo move selection
2
3use crate::heuristic::greedy_layoff;
4use crate::sim::{Sim, SimPhase};
5use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
6use gin_rummy::deck::Deck;
7use gin_rummy::{Card, Hand, Player, RoundResult, Rules, best_melds, deadwood};
8use rand::Rng;
9
10/// One determinized world: a concrete opponent hand and stock order
11/// consistent with a [`View`]
12struct World {
13    opponent: Hand,
14    /// Face-down draw order: the last element is drawn first
15    stock: Vec<Card>,
16}
17
18/// A determinized Monte Carlo player
19///
20/// At every decision the bot samples hidden worlds consistent with its
21/// [`View`] — the opponent holds every card they are known to have taken,
22/// and the remaining unseen cards are distributed between their hand and
23/// the stock, biased toward the meld-rich hands a real opponent collects —
24/// plays each world out with the greedy policy on both seats, and picks
25/// the action with the best expected value *for the game*: each rollout's
26/// result lands on the running [`game scores`](View::game_scores), a
27/// result that reaches [`game_target`](Rules::game_target) counts as the
28/// win or loss of the game it is, and anything short of one counts its
29/// round points.  The same worlds are reused across candidate actions
30/// (common random numbers), and the bot deviates from the greedy baseline
31/// only when the paired samples show a statistically clear gain.
32///
33/// The bot owns its random number generator, so a seeded generator makes
34/// its play reproducible.
35pub struct MonteCarloBot<R: Rng> {
36    rng: R,
37    samples: u32,
38    max_candidates: usize,
39}
40
41impl<R: Rng> MonteCarloBot<R> {
42    /// A bot with default strength: 128 worlds per decision, 4 candidate
43    /// discards
44    pub const fn new(rng: R) -> Self {
45        Self {
46            rng,
47            samples: 128,
48            max_candidates: 4,
49        }
50    }
51
52    /// Set how many worlds each decision samples
53    ///
54    /// More samples play stronger and slower.  At the default of 128 the
55    /// bot wins about 65% of decisive rounds against the default
56    /// [`HeuristicBot`] — which is tuned for whole-game play and so concedes
57    /// single rounds — at ~10 ms per turn in release builds; 32 keeps a
58    /// smaller edge at a quarter of the cost.
59    ///
60    /// [`HeuristicBot`]: crate::HeuristicBot
61    #[must_use]
62    pub const fn samples(mut self, samples: u32) -> Self {
63        self.samples = samples;
64        self
65    }
66
67    /// Set how many candidate discards `play_turn` evaluates
68    #[must_use]
69    pub const fn max_candidates(mut self, max_candidates: usize) -> Self {
70        self.max_candidates = max_candidates;
71        self
72    }
73
74    /// Sample determinized worlds consistent with the view
75    ///
76    /// The opponent's hidden cards are not sampled uniformly: a real
77    /// opponent has been collecting melds since the deal, so a uniform
78    /// hand would be far too weak and the rollouts would recommend
79    /// hunting gin against an opponent who never knocks.  Each world
80    /// instead keeps the lowest-deadwood of several uniform draws, more
81    /// of them the longer the round has run.
82    fn sample_worlds(&mut self, view: &View<'_>) -> Vec<World> {
83        let unseen = view.unseen();
84        let known = view.opponent_known();
85        let missing = view.opponent_hand_len() - known.len();
86        // The pile grows by one card per turn played.
87        let strength = (view.discard_pile().len() / 2).clamp(1, 6);
88
89        (0..self.samples)
90            .map(|_| {
91                let hidden = (0..strength)
92                    .map(|_| {
93                        let mut pool = Deck::EMPTY;
94                        for card in unseen {
95                            pool.insert(card);
96                        }
97                        pool.draw(&mut self.rng, missing)
98                    })
99                    .min_by_key(|&hidden| deadwood(known | hidden))
100                    .expect("at least one draw is always sampled");
101
102                let mut pool = Deck::EMPTY;
103                for card in unseen - hidden {
104                    pool.insert(card);
105                }
106                let mut stock = Vec::with_capacity(pool.len());
107                while let Some(card) = pool.pop(&mut self.rng) {
108                    stock.push(card);
109                }
110                World {
111                    opponent: known | hidden,
112                    stock,
113                }
114            })
115            .collect()
116    }
117
118    /// Per-world game-winning equities of `rollout` (common random numbers:
119    /// every candidate sees the same worlds, so paired comparisons cancel
120    /// most of the rollout noise)
121    fn equities(
122        view: &View<'_>,
123        worlds: &[World],
124        phase: SimPhase,
125        rollout: impl Fn(Sim) -> RoundResult,
126    ) -> Vec<f64> {
127        let me = view.seat();
128        let rules = view.rules();
129        let standing = view.game_scores();
130        worlds
131            .iter()
132            .map(|world| equity(rollout(Self::sim(view, world, phase)), me, standing, rules))
133            .collect()
134    }
135
136    /// Instantiate one world as a rollout state, to act at `phase`
137    fn sim(view: &View<'_>, world: &World, phase: SimPhase) -> Sim {
138        let seat = view.seat();
139        let mut hands = [Hand::EMPTY; 2];
140        hands[seat as usize] = view.hand();
141        hands[seat.opponent() as usize] = world.opponent;
142        Sim {
143            rules: *view.rules(),
144            knock_limit: view.knock_limit(),
145            hands,
146            stock: world.stock.clone(),
147            pile: view.discard_pile().to_vec(),
148            turn: seat,
149            phase,
150            taken: view.taken_discard(),
151            // In the upcard phase, the dealer decides second.
152            passes: u8::from(seat == view.dealer()),
153            forced_stock: false,
154        }
155    }
156}
157
158/// Whether the challenger's paired advantage over the incumbent is large
159/// enough to trust
160///
161/// The true value difference between most candidate actions is well below
162/// the rollout noise floor, and deviating from the solid greedy baseline on
163/// noise alone plays *worse* than the baseline.  A one-sided paired test —
164/// the mean difference at least two standard errors above zero, since
165/// several challengers get tested per decision — keeps only the deviations
166/// the samples actually support.
167fn beats(challenger: &[f64], incumbent: &[f64]) -> bool {
168    let n = challenger.len() as f64;
169    let mean = challenger
170        .iter()
171        .zip(incumbent)
172        .map(|(c, i)| c - i)
173        .sum::<f64>()
174        / n;
175    if mean <= 0.0 {
176        return false;
177    }
178    let var = challenger
179        .iter()
180        .zip(incumbent)
181        .map(|(c, i)| (c - i - mean).powi(2))
182        .sum::<f64>()
183        / n;
184    mean > 2.0 * (var / n).sqrt()
185}
186
187/// The value of `result` to `me` in the game standing at the `standing`
188/// totals (`[mine, theirs]`): 1 for a result that wins the game, 0 for
189/// one that loses it, otherwise affine in the signed round points
190///
191/// The result lands on the standing exactly as [`gin_rummy::Game::record`]
192/// applies it: the winner banks [`RoundResult::points`] plus an immediate
193/// box where [`Rules::immediate_boxes`] grants one.  Deferred boxes, the
194/// game bonus, and shutout doubling only inflate the final tally — they
195/// never decide who reaches [`Rules::game_target`] first — so they are
196/// correctly absent.
197///
198/// Short of a clinch the value stays affine in round points, so `beats`
199/// makes exactly the decisions the round-point objective made and the
200/// bot deviates from its round game only when a rollout can actually end
201/// the game: it takes the knock that clinches instead of milking a
202/// bigger score, and it defends the round when losing it hands the
203/// opponent the game.  Shaped utilities that also bend mid-game play — a
204/// win-probability race over the points still needed — measured slightly
205/// *weaker* over whole games (their distortion at level scores buys
206/// nothing), and rolling whole games out instead would drown the
207/// significance gate in cross-round variance.  A non-clinch gain is less
208/// than the target by definition, so scaling by four targets pins every
209/// mid-game value inside (¼, ¾), a guaranteed gap below a clinch and
210/// above a loss.
211fn equity(result: RoundResult, me: Player, standing: [u16; 2], rules: &Rules) -> f64 {
212    let mut scores = standing;
213    let mut points = 0.0;
214    if let Some(winner) = result.winner() {
215        let immediate = if rules.immediate_boxes {
216            rules.box_bonus
217        } else {
218            0
219        };
220        let gain = result.points(rules).saturating_add(immediate);
221        let side = usize::from(winner != me);
222        scores[side] = scores[side].saturating_add(gain);
223        points = if winner == me {
224            f64::from(gain)
225        } else {
226            -f64::from(gain)
227        };
228    }
229    // Mine first: both seats over the target is unreachable in a game,
230    // where only one seat scores per round.
231    if scores[0] >= rules.game_target {
232        1.0
233    } else if scores[1] >= rules.game_target {
234        0.0
235    } else {
236        0.5 + points / (4.0 * f64::from(rules.game_target))
237    }
238}
239
240impl<R: Rng> Strategy for MonteCarloBot<R> {
241    fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
242        let top = view.upcard().expect("the upcard offer has an upcard");
243        let incumbent = if crate::heuristic::improves(view.hand(), top) {
244            UpcardAction::Take
245        } else {
246            UpcardAction::Pass
247        };
248
249        let worlds = self.sample_worlds(view);
250        let take = Self::equities(view, &worlds, SimPhase::Upcard, |mut sim| {
251            sim.take_discard();
252            sim.rollout()
253        });
254        let pass = Self::equities(view, &worlds, SimPhase::Upcard, |mut sim| {
255            sim.pass();
256            sim.rollout()
257        });
258
259        let (defend, challenge, challenger) = match incumbent {
260            UpcardAction::Take => (take, pass, UpcardAction::Pass),
261            UpcardAction::Pass => (pass, take, UpcardAction::Take),
262        };
263        if beats(&challenge, &defend) {
264            challenger
265        } else {
266            incumbent
267        }
268    }
269
270    fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
271        let top = view.upcard().expect("the pile is never empty on a draw");
272        let incumbent = if crate::heuristic::improves(view.hand(), top) {
273            DrawAction::TakeDiscard
274        } else {
275            DrawAction::Stock
276        };
277
278        let worlds = self.sample_worlds(view);
279        let stock = Self::equities(view, &worlds, SimPhase::Draw, |mut sim| {
280            sim.draw_stock();
281            sim.rollout()
282        });
283        let pile = Self::equities(view, &worlds, SimPhase::Draw, |mut sim| {
284            sim.take_discard();
285            sim.rollout()
286        });
287
288        let (defend, challenge, challenger) = match incumbent {
289            DrawAction::TakeDiscard => (pile, stock, DrawAction::Stock),
290            DrawAction::Stock => (stock, pile, DrawAction::TakeDiscard),
291        };
292        if beats(&challenge, &defend) {
293            challenger
294        } else {
295            incumbent
296        }
297    }
298
299    fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
300        let hand = view.hand();
301        if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
302            // Big gin scores at least as much as gin under every ruleset.
303            return TurnAction::BigGin(best_melds(hand));
304        }
305
306        // Rank legal sheds greedily and keep the most promising few; the
307        // first candidate's knock-if-legal action is the greedy incumbent.
308        let mut candidates: Vec<(Card, u8)> = hand
309            .iter()
310            .filter(|&card| Some(card) != view.taken_discard())
311            .map(|card| (card, deadwood(hand - card.into())))
312            .collect();
313        candidates.sort_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()));
314        candidates.truncate(self.max_candidates.max(1));
315
316        let worlds = self.sample_worlds(view);
317        let limit = view.knock_limit();
318        let actions: Vec<(TurnAction, Vec<f64>)> = candidates
319            .iter()
320            .flat_map(|&(card, rest)| {
321                let melds = best_melds(hand - card.into());
322                let knock = (rest <= limit).then(|| {
323                    let scores =
324                        Self::equities(view, &worlds, SimPhase::Shed, |sim| sim.knock(card, melds));
325                    (
326                        TurnAction::Knock {
327                            discard: card,
328                            melds,
329                        },
330                        scores,
331                    )
332                });
333                let discard = Self::equities(view, &worlds, SimPhase::Shed, |mut sim| {
334                    sim.discard(card).unwrap_or_else(|| sim.rollout())
335                });
336                knock
337                    .into_iter()
338                    .chain(std::iter::once((TurnAction::Discard(card), discard)))
339            })
340            .collect();
341
342        // Deviate from the incumbent only on statistically clear gains,
343        // taking the largest such gain.
344        let (incumbent, defend) = &actions[0];
345        actions[1..]
346            .iter()
347            .filter(|(_, challenge)| beats(challenge, defend))
348            .max_by(|(_, a), (_, b)| {
349                let mean = |s: &[f64]| s.iter().sum::<f64>() / s.len() as f64;
350                mean(a).total_cmp(&mean(b))
351            })
352            .map_or(*incumbent, |(action, _)| *action)
353    }
354
355    fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
356        // The round is over bar settlement; the greedy layoff is
357        // near-exact and simulation adds nothing.
358        greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
359    }
360
361    fn name(&self) -> &str {
362        "mc"
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::Table;
370    use gin_rummy::{Round, Rules};
371    use rand::SeedableRng as _;
372    use rand::rngs::StdRng;
373
374    fn fixed_table() -> Table {
375        let deck: Vec<_> = Hand::ALL.iter().collect();
376        let hands = [
377            deck.iter().step_by(2).take(10).copied().collect::<Hand>(),
378            deck.iter().skip(1).step_by(2).take(10).copied().collect(),
379        ];
380        let round = Round::from_deal(
381            Rules::default(),
382            Player::One,
383            hands,
384            deck[20],
385            deck[21..].to_vec(),
386        )
387        .expect("a partitioned deck");
388        Table::new(round)
389    }
390
391    #[test]
392    fn sampled_worlds_are_consistent_with_the_view() {
393        let table = fixed_table();
394        let view = table.view(Player::Two);
395        let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(1)).samples(32);
396
397        for world in bot.sample_worlds(&view) {
398            // Right sizes: a full opponent hand and the whole stock.
399            assert_eq!(world.opponent.len(), view.opponent_hand_len());
400            assert_eq!(world.stock.len(), view.stock_len());
401
402            // Placement is a partition of the unseen cards...
403            let stock: Hand = world.stock.iter().copied().collect();
404            assert!((world.opponent & stock).is_empty());
405            assert_eq!(
406                world.opponent | stock,
407                view.unseen() | view.opponent_known()
408            );
409
410            // ...that never touches what this seat can see.
411            assert!((world.opponent & view.hand()).is_empty());
412            assert!((stock & view.hand()).is_empty());
413            assert_eq!(
414                world.opponent & view.opponent_known(),
415                view.opponent_known()
416            );
417        }
418    }
419
420    #[test]
421    fn seeded_bots_repeat_their_decisions() {
422        let table = fixed_table();
423        let decide = |seed| {
424            let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(seed)).samples(16);
425            bot.offer_upcard(&table.view(Player::Two))
426        };
427        assert_eq!(decide(3), decide(3));
428    }
429
430    #[test]
431    fn equity_is_terminal_at_the_target() {
432        let rules = Rules::default();
433        let me = Player::One;
434        let win = RoundResult::Knock {
435            winner: me,
436            margin: 15,
437        };
438        assert_eq!(equity(win, me, [90, 50], &rules), 1.0);
439
440        let loss = RoundResult::Knock {
441            winner: me.opponent(),
442            margin: 15,
443        };
444        assert_eq!(equity(loss, me, [50, 90], &rules), 0.0);
445    }
446
447    #[test]
448    fn equity_prices_immediate_boxes() {
449        // 95 + 3 crosses 100 only with the palace box of 10.
450        let me = Player::One;
451        let result = RoundResult::Knock {
452            winner: me,
453            margin: 3,
454        };
455        assert_eq!(equity(result, me, [95, 95], &Rules::palace()), 1.0);
456
457        let deferred = equity(result, me, [95, 95], &Rules::default());
458        assert!(deferred > 0.5 && deferred < 1.0);
459    }
460
461    #[test]
462    fn equity_orders_results_at_level_scores() {
463        let rules = Rules::default();
464        let me = Player::One;
465        let gin = equity(
466            RoundResult::Gin {
467                winner: me,
468                deadwood: 30,
469            },
470            me,
471            [0, 0],
472            &rules,
473        );
474        let knock = equity(
475            RoundResult::Knock {
476                winner: me,
477                margin: 10,
478            },
479            me,
480            [0, 0],
481            &rules,
482        );
483        let dead = equity(RoundResult::Dead, me, [0, 0], &rules);
484        let loss = equity(
485            RoundResult::Knock {
486                winner: me.opponent(),
487                margin: 10,
488            },
489            me,
490            [0, 0],
491            &rules,
492        );
493        assert!(gin > knock && knock > dead && dead > loss);
494        assert_eq!(dead, 0.5);
495    }
496
497    #[test]
498    fn mid_game_equity_is_affine_in_round_points() {
499        // Short of a clinch the standing shifts nothing: a dead round is
500        // worth exactly 1/2, and a win is worth the same premium over it
501        // from any standing — so mid-game decisions reduce to the
502        // round-point objective.
503        let rules = Rules::default();
504        let me = Player::One;
505        let win = RoundResult::Knock {
506            winner: me,
507            margin: 10,
508        };
509        assert_eq!(equity(RoundResult::Dead, me, [60, 20], &rules), 0.5);
510        assert_eq!(
511            equity(win, me, [60, 20], &rules),
512            equity(win, me, [0, 0], &rules),
513        );
514    }
515
516    #[test]
517    fn beats_requires_a_clear_margin() {
518        // A small mean edge buried in noise is not enough: the paired
519        // differences swing ±1 around a +0.05 mean.
520        let base: Vec<f64> = (0..32).map(|i| f64::from(i % 5)).collect();
521        let noisy: Vec<f64> = base
522            .iter()
523            .enumerate()
524            .map(|(i, x)| x + if i % 2 == 0 { 1.05 } else { -0.95 })
525            .collect();
526        assert!(!beats(&noisy, &base));
527
528        // A consistent advantage is.
529        let better: Vec<f64> = base.iter().map(|x| x + 1.0).collect();
530        assert!(beats(&better, &base));
531        assert!(!beats(&base, &better));
532        // Equality never beats.
533        assert!(!beats(&base, &base));
534    }
535}