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::{Card, Hand, Phase, Player, RoundResult, Rules, best_melds, deadwood};
7use rand::{Rng, RngExt as _};
8
9/// How many candidate discards `play_turn` and `assess` weigh at a discard:
10/// the few lowest-deadwood sheds, the rest never worth a rollout.
11const MAX_CANDIDATES: usize = 4;
12
13/// The world count of the first scoring batch; each later batch doubles the
14/// evaluated total, so elimination checkpoints fall after 32, 64, 128, ...
15/// worlds.  A decision of 32 samples or fewer is a single batch, identical
16/// to an unbatched run.
17const BATCH: usize = 32;
18
19/// One determinized world: a concrete opponent hand and stock order
20/// consistent with a [`View`]
21struct World {
22    opponent: Hand,
23    /// Face-down draw order: the last element is drawn first
24    stock: Vec<Card>,
25}
26
27/// One candidate action to score: the typed move a [`Strategy`] method would
28/// return, paired with its rendered [`Assessment::action`] label
29struct Candidate {
30    /// The rendered [`Assessment::action`] label.
31    label: String,
32    /// The move itself — returned verbatim when this candidate is the pick,
33    /// so the chooser and the solver read agree by construction.
34    choice: Choice,
35}
36
37/// A typed candidate move, tagged by phase so the same value both drives a
38/// rollout and is returned from the matching [`Strategy`] method
39#[derive(Clone, Copy)]
40enum Choice {
41    /// Take or pass the initial upcard.
42    Upcard(UpcardAction),
43    /// Draw the stock or take the pile top.
44    Draw(DrawAction),
45    /// Discard, knock, or declare big gin.
46    Turn(TurnAction),
47}
48
49impl Choice {
50    /// The rollout phase this move acts at, passed to [`MonteCarloBot::sim`].
51    fn phase(self) -> SimPhase {
52        match self {
53            Self::Upcard(_) => SimPhase::Upcard,
54            Self::Draw(_) => SimPhase::Draw,
55            Self::Turn(_) => SimPhase::Shed,
56        }
57    }
58
59    /// Apply the move to a fresh rollout state and play it to a result.
60    fn roll(self, mut sim: Sim) -> RoundResult {
61        match self {
62            Self::Upcard(UpcardAction::Take) | Self::Draw(DrawAction::TakeDiscard) => {
63                sim.take_discard();
64                sim.rollout()
65            }
66            Self::Upcard(UpcardAction::Pass) => {
67                sim.pass();
68                sim.rollout()
69            }
70            Self::Draw(DrawAction::Stock) => {
71                sim.draw_stock();
72                sim.rollout()
73            }
74            Self::Turn(TurnAction::BigGin(_)) => sim.big_gin(),
75            Self::Turn(TurnAction::Knock { discard, melds }) => sim.knock(discard, melds),
76            Self::Turn(TurnAction::Discard(card)) => {
77                sim.discard(card).unwrap_or_else(|| sim.rollout())
78            }
79        }
80    }
81}
82
83/// A determinized Monte Carlo player
84///
85/// At every decision the bot samples hidden worlds consistent with its
86/// [`View`] — the opponent holds every card they are known to have taken,
87/// and the remaining unseen cards are distributed between their hand and
88/// the stock, biased toward the meld-rich hands a real opponent collects —
89/// plays each world out with the greedy policy on both seats, and picks
90/// the action with the best expected value *for the game*: each rollout's
91/// result lands on the running [`game scores`](View::game_scores), a
92/// result that reaches [`game_target`](Rules::game_target) counts as the
93/// win or loss of the game it is, and anything short of one counts its
94/// round points.  The same worlds are reused across candidate actions
95/// (common random numbers), and the bot deviates from the greedy baseline
96/// only when the paired samples show a statistically clear gain.  Worlds
97/// are rolled in growing batches, and a challenger the incumbent already
98/// statistically dominates is dropped at a batch boundary — once none
99/// remain, the remaining worlds are never rolled at all — so an easy
100/// decision costs a fraction of the full sample count.
101///
102/// The bot owns its random number generator, so a seeded generator makes
103/// its play reproducible.
104pub struct MonteCarloBot<R: Rng> {
105    rng: R,
106    samples: u32,
107}
108
109/// One candidate action's Monte Carlo assessment, for a solver or hint view
110///
111/// Produced by [`MonteCarloBot::assess`]: the same rollouts the bot chooses
112/// with, surfaced per candidate instead of collapsed to the single action a
113/// [`Strategy`] method returns.
114#[derive(Debug, Clone)]
115#[non_exhaustive]
116pub struct Assessment {
117    /// A rendered label for the action, e.g. `"discard 4♠"`, `"knock"`,
118    /// `"take 4♠"`, `"pass"`, `"draw stock"`, `"big gin"`.
119    pub action: String,
120    /// Mean game-winning equity in `[0, 1]` — the quantity the bot
121    /// maximizes, so candidates rank by it.  A candidate the bot eliminated
122    /// early averages over the worlds it saw before elimination rather than
123    /// the full sample count.
124    pub equity: f64,
125    /// Mean signed round points the action wins the deciding seat: positive
126    /// for a net gain, negative for a net loss.  Averaged over the same
127    /// worlds as [`equity`](Self::equity).
128    pub ev: f64,
129    /// Whether this is the bot's own pick — the move a [`Strategy`] method
130    /// would return on this view.  Because the bot deviates from the greedy
131    /// baseline only on a statistically clear gain, this need not be the
132    /// highest-equity candidate.
133    pub recommended: bool,
134}
135
136impl<R: Rng> MonteCarloBot<R> {
137    /// A bot with default strength: 128 worlds per decision
138    pub const fn new(rng: R) -> Self {
139        Self { rng, samples: 128 }
140    }
141
142    /// Set how many worlds each decision samples
143    ///
144    /// More samples play stronger and slower.  At the default of 128 the
145    /// bot wins about 65% of decisive rounds against the default
146    /// [`HeuristicBot`] — which is tuned for whole-game play and so concedes
147    /// single rounds — at roughly 10 ms per average turn in release builds
148    /// (easy decisions stop at a fraction of the budget; a hard first
149    /// discard, where every shed stays plausible, runs the full count for
150    /// ~25 ms); 32 keeps a smaller edge at a quarter of the cost.  The
151    /// `parallel` feature divides any of these by most of a machine's
152    /// cores.
153    ///
154    /// [`HeuristicBot`]: crate::HeuristicBot
155    #[must_use]
156    pub const fn samples(mut self, samples: u32) -> Self {
157        self.samples = samples;
158        self
159    }
160
161    /// Sample determinized worlds consistent with the view
162    ///
163    /// The opponent's hidden cards are not sampled uniformly: a real
164    /// opponent has been collecting melds since the deal, so a uniform
165    /// hand would be far too weak and the rollouts would recommend
166    /// hunting gin against an opponent who never knocks.  Each world
167    /// instead keeps the lowest-deadwood of several uniform draws, more of
168    /// them the deeper the pile — see [`opponent_strength`] — so the bias
169    /// keeps intensifying for the whole round instead of leveling off
170    /// partway through it.  Charging the pick a cold-card penalty as well —
171    /// extra deadwood per hidden card adjoining one the opponent shed or
172    /// passed, the heuristic's disinterest signal pointed the other way —
173    /// measured flat (+0.2/−0.2 points on two 10 000-round seeds at mc:64),
174    /// so the deadwood bias stands alone.
175    fn sample_worlds(&mut self, view: &View<'_>, count: u32) -> Vec<World> {
176        let unseen = view.unseen();
177        let known = view.opponent_known();
178        let missing = view.opponent_hand_len() - known.len();
179        let strength = opponent_strength(view.discard_pile().len());
180        // One scratch pool for the whole decision.  Each hidden-hand draw is
181        // a partial Fisher-Yates prefix, which is uniform from any starting
182        // permutation, so the pool never needs rebuilding between draws.
183        let mut pool: Vec<Card> = unseen.iter().collect();
184
185        (0..count)
186            .map(|_| {
187                let hidden = (0..strength)
188                    .map(|_| {
189                        for i in 0..missing {
190                            let j = self.rng.random_range(i..pool.len());
191                            pool.swap(i, j);
192                        }
193                        pool[..missing].iter().copied().collect::<Hand>()
194                    })
195                    .min_by_key(|&hidden| deadwood(known | hidden))
196                    .expect("at least one draw is always sampled");
197
198                let mut stock: Vec<Card> = pool
199                    .iter()
200                    .copied()
201                    .filter(|&card| !hidden.contains(card))
202                    .collect();
203                for i in (1..stock.len()).rev() {
204                    let j = self.rng.random_range(0..=i);
205                    stock.swap(i, j);
206                }
207                World {
208                    opponent: known | hidden,
209                    stock,
210                }
211            })
212            .collect()
213    }
214
215    /// Instantiate one world as a rollout state, to act at `phase`
216    fn sim(view: &View<'_>, world: &World, phase: SimPhase) -> Sim {
217        let seat = view.seat();
218        let mut hands = [Hand::EMPTY; 2];
219        hands[seat as usize] = view.hand();
220        hands[seat.opponent() as usize] = world.opponent;
221        Sim {
222            rules: *view.rules(),
223            knock_limit: view.knock_limit(),
224            hands,
225            stock: world.stock.clone(),
226            pile: view.discard_pile().to_vec(),
227            turn: seat,
228            phase,
229            taken: view.taken_discard(),
230            // In the upcard phase, the dealer decides second.
231            passes: u8::from(seat == view.dealer()),
232            forced_stock: false,
233        }
234    }
235
236    /// Assess every candidate action for the current decision, each with its
237    /// Monte Carlo equity and expected round points, ranked by equity with
238    /// the bot's own pick flagged — the read a solver or hint view shows
239    ///
240    /// The candidates and the flagged pick mirror the matching [`Strategy`]
241    /// method on the same sampled worlds, so the recommended row is the move
242    /// the bot would play — with one deliberate contraction: a knock's shed
243    /// is not a real choice (dropping the largest deadwood is always the best
244    /// knock), so the discard phase lists a single knock rather than one per
245    /// shed.  Returns empty when the seat has no real choice: a forced stock
246    /// draw, the layoff phase, or a finished round.
247    #[must_use]
248    pub fn assess(&mut self, view: &View<'_>) -> Vec<Assessment> {
249        let candidates = self.hint_candidates(view);
250        if candidates.is_empty() {
251            return Vec::new();
252        }
253        let worlds = self.sample_worlds(view, self.samples);
254        let scored = Self::score_worlds(view, &worlds, &candidates);
255        Self::rank(&candidates, &scored)
256    }
257
258    /// Score every candidate on freshly sampled worlds and return the move to
259    /// play: the greedy incumbent (`candidates[0]`) unless a challenger clears
260    /// the significance gate.  The shared core of the [`Strategy`] methods, so
261    /// each is a thin wrapper over the same read [`assess`](Self::assess)
262    /// surfaces; `candidates` must be non-empty.
263    fn choose(&mut self, view: &View<'_>, candidates: &[Candidate]) -> Choice {
264        let worlds = self.sample_worlds(view, self.samples);
265        let scored = Self::score_worlds(view, &worlds, candidates);
266        candidates[recommended(&scored)].choice
267    }
268
269    /// The ordered candidate moves for the current decision, the greedy
270    /// incumbent first
271    ///
272    /// The single source of candidates for both the [`Strategy`] methods and
273    /// the solver read, with one deliberate contraction: a knock's shed is not
274    /// a real choice (dropping the largest deadwood is always the best knock),
275    /// so the discard phase lists a single leading knock rather than one per
276    /// shed.  Empty when the seat has no real choice.
277    fn hint_candidates(&self, view: &View<'_>) -> Vec<Candidate> {
278        let candidate = |label: String, choice: Choice| Candidate { label, choice };
279        match view.phase() {
280            Phase::Upcard => {
281                let top = view.upcard().expect("the upcard offer has an upcard");
282                let take = candidate(format!("take {top}"), Choice::Upcard(UpcardAction::Take));
283                let pass = candidate("pass".to_string(), Choice::Upcard(UpcardAction::Pass));
284                // Incumbent first, so the gate compares the challenger against
285                // it exactly as `offer_upcard` does.
286                if crate::heuristic::improves(view.hand(), top) {
287                    vec![take, pass]
288                } else {
289                    vec![pass, take]
290                }
291            }
292            Phase::Draw => {
293                if !view.can_take_discard() {
294                    // A forced stock draw is not a choice.
295                    return Vec::new();
296                }
297                let top = view.upcard().expect("the pile is never empty on a draw");
298                let stock = candidate("draw stock".to_string(), Choice::Draw(DrawAction::Stock));
299                let pile = candidate(format!("take {top}"), Choice::Draw(DrawAction::TakeDiscard));
300                // Incumbent first, mirroring `choose_draw`.
301                if crate::heuristic::improves(view.hand(), top) {
302                    vec![pile, stock]
303                } else {
304                    vec![stock, pile]
305                }
306            }
307            Phase::Discard => {
308                let hand = view.hand();
309                if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
310                    let choice = Choice::Turn(TurnAction::BigGin(best_melds(hand)));
311                    return vec![candidate("big gin".to_string(), choice)];
312                }
313                // The same greedy shed ranking `play_turn` evaluates.
314                let mut sheds: Vec<(Card, u8)> = hand
315                    .iter()
316                    .filter(|&card| Some(card) != view.taken_discard())
317                    .map(|card| (card, deadwood(hand - card.into())))
318                    .collect();
319                sheds.sort_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()));
320                sheds.truncate(MAX_CANDIDATES);
321
322                let limit = view.knock_limit();
323                let mut out = Vec::new();
324                // The best knock leads, as the greedy incumbent; if even it
325                // exceeds the limit, no shed can knock.
326                if let Some(&(card, rest)) = sheds.first()
327                    && rest <= limit
328                {
329                    let melds = best_melds(hand - card.into());
330                    let knock = Choice::Turn(TurnAction::Knock {
331                        discard: card,
332                        melds,
333                    });
334                    out.push(candidate("knock".to_string(), knock));
335                }
336                for &(card, _) in &sheds {
337                    let discard = Choice::Turn(TurnAction::Discard(card));
338                    out.push(candidate(format!("discard {card}"), discard));
339                }
340                out
341            }
342            _ => Vec::new(),
343        }
344    }
345
346    /// Roll candidates through the same `worlds` (common random numbers) in
347    /// growing batches, eliminating challengers the incumbent already
348    /// dominates, and return per candidate its per-world equities and summed
349    /// round points
350    ///
351    /// A challenger is eliminated at a batch boundary when the incumbent's
352    /// paired advantage over it clears the same [`beats`] gate a challenger
353    /// must clear to be preferred; once every challenger is gone the
354    /// incumbent wins by default and the remaining worlds are never rolled.
355    /// Survivors always reach the full world count, so the final
356    /// [`recommended`] read over them is exactly the unbatched one.  An
357    /// eliminated candidate keeps the equities it accumulated: its paired
358    /// mean against the incumbent is negative on that prefix, and [`beats`]
359    /// zips to the shorter slice, so [`recommended`] rejects it with no
360    /// special casing.
361    fn score_worlds(
362        view: &View<'_>,
363        worlds: &[World],
364        candidates: &[Candidate],
365    ) -> Vec<(Vec<f64>, f64)> {
366        let me = view.seat();
367        let rules = view.rules();
368        let standing = view.game_scores();
369        let eval = |candidate: &Candidate, world: &World| {
370            let sim = Self::sim(view, world, candidate.choice.phase());
371            let result = candidate.choice.roll(sim);
372            (
373                equity(result, me, standing, rules),
374                round_points(result, me, rules),
375            )
376        };
377
378        let mut scored: Vec<(Vec<f64>, f64)> = vec![(Vec::new(), 0.0); candidates.len()];
379        let mut alive: Vec<usize> = (1..candidates.len()).collect();
380        let mut done = 0;
381        while done < worlds.len() {
382            let batch = &worlds[done..worlds.len().min(done + done.max(BATCH))];
383            for &i in std::iter::once(&0).chain(&alive) {
384                let candidate = &candidates[i];
385                #[cfg(feature = "parallel")]
386                let results: Vec<(f64, f64)> = {
387                    use rayon::prelude::*;
388                    batch
389                        .par_iter()
390                        .map(|world| eval(candidate, world))
391                        .collect()
392                };
393                #[cfg(not(feature = "parallel"))]
394                let results = batch.iter().map(|world| eval(candidate, world));
395
396                // Reduced sequentially in world order in both builds, so a
397                // parallel bot makes bit-identical decisions to a serial one.
398                let (equities, ev_sum) = &mut scored[i];
399                for (equity, points) in results {
400                    equities.push(equity);
401                    *ev_sum += points;
402                }
403            }
404            done += batch.len();
405            if done < worlds.len() {
406                alive.retain(|&i| !beats(&scored[0].0, &scored[i].0));
407                if alive.is_empty() {
408                    break;
409                }
410            }
411        }
412        scored
413    }
414
415    /// Reduce the scored candidates to assessments ranked by mean equity,
416    /// flagging the bot's pick — the same index [`choose`](Self::choose)
417    /// returns, so the solver read matches the move played
418    ///
419    /// Each candidate averages over the worlds it was actually rolled
420    /// through, which is fewer than the sample count for a challenger
421    /// [`score_worlds`](Self::score_worlds) eliminated early.
422    fn rank(candidates: &[Candidate], scored: &[(Vec<f64>, f64)]) -> Vec<Assessment> {
423        let best = recommended(scored);
424        let mut out: Vec<Assessment> = candidates
425            .iter()
426            .zip(scored)
427            .enumerate()
428            .map(|(i, (candidate, (equities, ev_sum)))| {
429                let n = equities.len() as f64;
430                Assessment {
431                    action: candidate.label.clone(),
432                    equity: equities.iter().sum::<f64>() / n,
433                    ev: ev_sum / n,
434                    recommended: i == best,
435                }
436            })
437            .collect();
438        out.sort_by(|a, b| b.equity.total_cmp(&a.equity));
439        out
440    }
441}
442
443/// The index of the recommended candidate: the greedy incumbent (`scored[0]`)
444/// unless a challenger's paired advantage clears the [`beats`] gate, in which
445/// case the largest such gain
446///
447/// Shared by [`MonteCarloBot::choose`] and [`MonteCarloBot::rank`], so the
448/// move the bot plays and the pick the solver flags never diverge.
449fn recommended(scored: &[(Vec<f64>, f64)]) -> usize {
450    let mean = |e: &[f64]| e.iter().sum::<f64>() / e.len() as f64;
451    let defend = &scored[0].0;
452    (1..scored.len())
453        .filter(|&i| beats(&scored[i].0, defend))
454        .max_by(|&a, &b| mean(&scored[a].0).total_cmp(&mean(&scored[b].0)))
455        .unwrap_or(0)
456}
457
458/// How many uniform hands [`MonteCarloBot::sample_worlds`] draws before
459/// keeping the lowest-deadwood one, given the discard pile's current
460/// length
461///
462/// Scales with the pile and never plateaus early — the 52-card deck
463/// already bounds it below 16 by the last legal stock draw — so the
464/// assumed opponent keeps improving for the whole round instead of
465/// leveling off a third of the way through it.
466const fn opponent_strength(pile_len: usize) -> usize {
467    if pile_len < 2 { 1 } else { pile_len / 2 }
468}
469
470/// Whether the challenger's paired advantage over the incumbent is large
471/// enough to trust
472///
473/// The true value difference between most candidate actions is well below
474/// the rollout noise floor, and deviating from the solid greedy baseline on
475/// noise alone plays *worse* than the baseline.  A one-sided paired test —
476/// the mean difference at least two standard errors above zero, since
477/// several challengers get tested per decision — keeps only the deviations
478/// the samples actually support.
479fn beats(challenger: &[f64], incumbent: &[f64]) -> bool {
480    let n = challenger.len() as f64;
481    let mean = challenger
482        .iter()
483        .zip(incumbent)
484        .map(|(c, i)| c - i)
485        .sum::<f64>()
486        / n;
487    if mean <= 0.0 {
488        return false;
489    }
490    let var = challenger
491        .iter()
492        .zip(incumbent)
493        .map(|(c, i)| (c - i - mean).powi(2))
494        .sum::<f64>()
495        / n;
496    mean > 2.0 * (var / n).sqrt()
497}
498
499/// The value of `result` to `me` in the game standing at the `standing`
500/// totals (`[mine, theirs]`): 1 for a result that wins the game, 0 for
501/// one that loses it, otherwise affine in the signed round points
502///
503/// The result lands on the standing exactly as [`gin_rummy::Game::record`]
504/// applies it: the winner banks [`RoundResult::points`] plus an immediate
505/// box where [`Rules::immediate_boxes`] grants one.  Deferred boxes, the
506/// game bonus, and shutout doubling only inflate the final tally — they
507/// never decide who reaches [`Rules::game_target`] first — so they are
508/// correctly absent.
509///
510/// Short of a clinch the value stays affine in round points, so `beats`
511/// makes exactly the decisions the round-point objective made and the
512/// bot deviates from its round game only when a rollout can actually end
513/// the game: it takes the knock that clinches instead of milking a
514/// bigger score, and it defends the round when losing it hands the
515/// opponent the game.  Shaped utilities that also bend mid-game play — a
516/// win-probability race over the points still needed — measured slightly
517/// *weaker* over whole games (their distortion at level scores buys
518/// nothing), and rolling whole games out instead would drown the
519/// significance gate in cross-round variance.  A non-clinch gain is less
520/// than the target by definition, so scaling by four targets pins every
521/// mid-game value inside (¼, ¾), a guaranteed gap below a clinch and
522/// above a loss.
523fn equity(result: RoundResult, me: Player, standing: [u16; 2], rules: &Rules) -> f64 {
524    let mut scores = standing;
525    let mut points = 0.0;
526    if let Some(winner) = result.winner() {
527        let immediate = if rules.immediate_boxes {
528            rules.box_bonus
529        } else {
530            0
531        };
532        let gain = result.points(rules).saturating_add(immediate);
533        let side = usize::from(winner != me);
534        scores[side] = scores[side].saturating_add(gain);
535        points = if winner == me {
536            f64::from(gain)
537        } else {
538            -f64::from(gain)
539        };
540    }
541    // Mine first: both seats over the target is unreachable in a game,
542    // where only one seat scores per round.
543    if scores[0] >= rules.game_target {
544        1.0
545    } else if scores[1] >= rules.game_target {
546        0.0
547    } else {
548        0.5 + points / (4.0 * f64::from(rules.game_target))
549    }
550}
551
552/// The signed round points `result` wins `me`, the expected-value column of
553/// [`MonteCarloBot::assess`]
554///
555/// Mirrors the `points` figure inside [`equity`] — the winner banks
556/// [`RoundResult::points`] plus an immediate box where
557/// [`Rules::immediate_boxes`] grants one — but returns the raw round points
558/// rather than [`equity`]'s game-winning rescaling, so a solver can show
559/// expected points beside the win-rate equity.
560fn round_points(result: RoundResult, me: Player, rules: &Rules) -> f64 {
561    let Some(winner) = result.winner() else {
562        return 0.0;
563    };
564    let immediate = if rules.immediate_boxes {
565        rules.box_bonus
566    } else {
567        0
568    };
569    let gain = result.points(rules).saturating_add(immediate);
570    if winner == me {
571        f64::from(gain)
572    } else {
573        -f64::from(gain)
574    }
575}
576
577impl<R: Rng> Strategy for MonteCarloBot<R> {
578    fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
579        let candidates = self.hint_candidates(view);
580        match self.choose(view, &candidates) {
581            Choice::Upcard(action) => action,
582            _ => unreachable!("the upcard offer yields upcard choices"),
583        }
584    }
585
586    fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
587        let candidates = self.hint_candidates(view);
588        // The driver never consults a strategy on the forced stock draw, but
589        // guard so a direct call cannot roll an empty candidate set.
590        if candidates.is_empty() {
591            return DrawAction::Stock;
592        }
593        match self.choose(view, &candidates) {
594            Choice::Draw(action) => action,
595            _ => unreachable!("the draw phase yields draw choices"),
596        }
597    }
598
599    fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
600        let hand = view.hand();
601        if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
602            // Big gin scores at least as much as gin under every ruleset, and
603            // is forced, so take it without a rollout (and without drawing
604            // from the rng, keeping seeded play reproducible).
605            return TurnAction::BigGin(best_melds(hand));
606        }
607        let candidates = self.hint_candidates(view);
608        match self.choose(view, &candidates) {
609            Choice::Turn(action) => action,
610            _ => unreachable!("the discard phase yields turn choices"),
611        }
612    }
613
614    fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
615        // The round is over bar settlement; the greedy layoff is
616        // near-exact and simulation adds nothing.
617        greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
618    }
619
620    fn name(&self) -> &str {
621        "mc"
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use crate::Table;
629    use gin_rummy::{Round, Rules};
630    use rand::SeedableRng as _;
631    use rand::rngs::StdRng;
632
633    fn fixed_table() -> Table {
634        let deck: Vec<_> = Hand::ALL.iter().collect();
635        let hands = [
636            deck.iter().step_by(2).take(10).copied().collect::<Hand>(),
637            deck.iter().skip(1).step_by(2).take(10).copied().collect(),
638        ];
639        let round = Round::from_deal(
640            Rules::default(),
641            Player::One,
642            hands,
643            deck[20],
644            deck[21..].to_vec(),
645        )
646        .expect("a partitioned deck");
647        Table::new(round)
648    }
649
650    #[test]
651    fn sampled_worlds_are_consistent_with_the_view() {
652        let table = fixed_table();
653        let view = table.view(Player::Two);
654        let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(1)).samples(32);
655
656        for world in bot.sample_worlds(&view, 32) {
657            // Right sizes: a full opponent hand and the whole stock.
658            assert_eq!(world.opponent.len(), view.opponent_hand_len());
659            assert_eq!(world.stock.len(), view.stock_len());
660
661            // Placement is a partition of the unseen cards...
662            let stock: Hand = world.stock.iter().copied().collect();
663            assert!((world.opponent & stock).is_empty());
664            assert_eq!(
665                world.opponent | stock,
666                view.unseen() | view.opponent_known()
667            );
668
669            // ...that never touches what this seat can see.
670            assert!((world.opponent & view.hand()).is_empty());
671            assert!((stock & view.hand()).is_empty());
672            assert_eq!(
673                world.opponent & view.opponent_known(),
674                view.opponent_known()
675            );
676        }
677    }
678
679    #[test]
680    fn opponent_strength_keeps_growing_past_the_old_cap() {
681        // The old formula flattened at 6 once the pile reached 12 cards;
682        // a real opponent keeps improving long after that point, so the
683        // replacement must keep climbing well past it.
684        assert_eq!(opponent_strength(0), 1);
685        assert_eq!(opponent_strength(12), 6);
686        assert!(opponent_strength(24) > 6);
687    }
688
689    #[test]
690    fn seeded_bots_repeat_their_decisions() {
691        let table = fixed_table();
692        let decide = |seed| {
693            let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(seed)).samples(16);
694            bot.offer_upcard(&table.view(Player::Two))
695        };
696        assert_eq!(decide(3), decide(3));
697    }
698
699    #[test]
700    fn equity_is_terminal_at_the_target() {
701        let rules = Rules::default();
702        let me = Player::One;
703        let win = RoundResult::Knock {
704            winner: me,
705            margin: 15,
706        };
707        assert_eq!(equity(win, me, [90, 50], &rules), 1.0);
708
709        let loss = RoundResult::Knock {
710            winner: me.opponent(),
711            margin: 15,
712        };
713        assert_eq!(equity(loss, me, [50, 90], &rules), 0.0);
714    }
715
716    #[test]
717    fn equity_prices_immediate_boxes() {
718        // 95 + 3 crosses 100 only with the palace box of 10.
719        let me = Player::One;
720        let result = RoundResult::Knock {
721            winner: me,
722            margin: 3,
723        };
724        assert_eq!(equity(result, me, [95, 95], &Rules::palace()), 1.0);
725
726        let deferred = equity(result, me, [95, 95], &Rules::default());
727        assert!(deferred > 0.5 && deferred < 1.0);
728    }
729
730    #[test]
731    fn equity_orders_results_at_level_scores() {
732        let rules = Rules::default();
733        let me = Player::One;
734        let gin = equity(
735            RoundResult::Gin {
736                winner: me,
737                deadwood: 30,
738            },
739            me,
740            [0, 0],
741            &rules,
742        );
743        let knock = equity(
744            RoundResult::Knock {
745                winner: me,
746                margin: 10,
747            },
748            me,
749            [0, 0],
750            &rules,
751        );
752        let dead = equity(RoundResult::Dead, me, [0, 0], &rules);
753        let loss = equity(
754            RoundResult::Knock {
755                winner: me.opponent(),
756                margin: 10,
757            },
758            me,
759            [0, 0],
760            &rules,
761        );
762        assert!(gin > knock && knock > dead && dead > loss);
763        assert_eq!(dead, 0.5);
764    }
765
766    #[test]
767    fn mid_game_equity_is_affine_in_round_points() {
768        // Short of a clinch the standing shifts nothing: a dead round is
769        // worth exactly 1/2, and a win is worth the same premium over it
770        // from any standing — so mid-game decisions reduce to the
771        // round-point objective.
772        let rules = Rules::default();
773        let me = Player::One;
774        let win = RoundResult::Knock {
775            winner: me,
776            margin: 10,
777        };
778        assert_eq!(equity(RoundResult::Dead, me, [60, 20], &rules), 0.5);
779        assert_eq!(
780            equity(win, me, [60, 20], &rules),
781            equity(win, me, [0, 0], &rules),
782        );
783    }
784
785    #[test]
786    fn beats_requires_a_clear_margin() {
787        // A small mean edge buried in noise is not enough: the paired
788        // differences swing ±1 around a +0.05 mean.
789        let base: Vec<f64> = (0..32).map(|i| f64::from(i % 5)).collect();
790        let noisy: Vec<f64> = base
791            .iter()
792            .enumerate()
793            .map(|(i, x)| x + if i % 2 == 0 { 1.05 } else { -0.95 })
794            .collect();
795        assert!(!beats(&noisy, &base));
796
797        // A consistent advantage is.
798        let better: Vec<f64> = base.iter().map(|x| x + 1.0).collect();
799        assert!(beats(&better, &base));
800        assert!(!beats(&base, &better));
801        // Equality never beats.
802        assert!(!beats(&base, &base));
803    }
804
805    #[test]
806    fn assess_ranks_candidates_and_flags_the_bots_pick() {
807        let table = fixed_table();
808        let seat = table.turn().expect("a fresh deal has a mover");
809        let view = table.view(seat);
810
811        // A solver and a chooser seeded alike sample identical worlds (the
812        // rollout draws no randomness), so the flagged row must be the move
813        // the bot actually plays.
814        let mut solver = MonteCarloBot::new(StdRng::seed_from_u64(7)).samples(64);
815        let mut chooser = MonteCarloBot::new(StdRng::seed_from_u64(7)).samples(64);
816
817        let rows = solver.assess(&view);
818        assert!(!rows.is_empty(), "the upcard offer is a real choice");
819
820        // Equities are probabilities, and the table is ranked by them.
821        for row in &rows {
822            assert!((0.0..=1.0).contains(&row.equity));
823        }
824        assert!(rows.windows(2).all(|w| w[0].equity >= w[1].equity));
825
826        // Exactly one recommendation, and it is the move the bot returns.
827        assert_eq!(rows.iter().filter(|r| r.recommended).count(), 1);
828        let picked = rows.iter().find(|r| r.recommended).expect("a flagged pick");
829        let expected = match chooser.offer_upcard(&view) {
830            UpcardAction::Take => format!("take {}", view.upcard().expect("an upcard offer")),
831            UpcardAction::Pass => "pass".to_string(),
832        };
833        assert_eq!(picked.action, expected);
834    }
835
836    /// A table paused on a discard where the mover can knock: the non-dealer
837    /// drew K♠ onto A♣2♣3♣ 4♦5♦6♦ 7♥8♥9♥ 2♠ after both seats passed the
838    /// upcard, so shedding the king knocks at 2 deadwood with nothing locked
839    /// by a take.
840    fn knock_position() -> Table {
841        let two: Hand = "A23.456.789.2".parse().expect("a legal hand");
842        let one: Hand = "TJ.TJ.TJ.3456".parse().expect("a legal hand");
843        let upcard: Card = "QS".parse().expect("a card");
844        // The non-dealer draws this off the stock (last card drawn first),
845        // reaching an 11-card hand whose only loose cards are 2♠ and K♠.
846        let king: Card = "KS".parse().expect("a card");
847        let mut stock: Vec<Card> = (Hand::ALL - two - one - upcard.into() - king.into())
848            .iter()
849            .collect();
850        stock.push(king);
851        let round = Round::from_deal(Rules::default(), Player::One, [one, two], upcard, stock)
852            .expect("a partitioned deck");
853        let mut table = Table::new(round);
854
855        // Both pass the upcard, forcing the non-dealer's stock draw and
856        // landing them on the discard with nothing locked by a take.
857        struct Passer;
858        impl Strategy for Passer {
859            fn offer_upcard(&mut self, _: &View<'_>) -> UpcardAction {
860                UpcardAction::Pass
861            }
862            fn choose_draw(&mut self, _: &View<'_>) -> DrawAction {
863                DrawAction::Stock
864            }
865            fn play_turn(&mut self, _: &View<'_>) -> TurnAction {
866                unreachable!("the round stops at the discard")
867            }
868            fn choose_layoff(&mut self, _: &View<'_>) -> Option<Layoff> {
869                None
870            }
871            fn name(&self) -> &str {
872                "passer"
873            }
874        }
875        while table.round().phase() != Phase::Discard {
876            table
877                .step(&mut Passer)
878                .expect("a legal pass or forced draw");
879        }
880        table
881    }
882
883    #[test]
884    fn assess_reports_a_single_knock_at_a_discard() {
885        // A knock's shed is forced — dropping the largest deadwood is always
886        // the best knock — so the solver lists one knock row, not one per
887        // shed, and it sheds that largest card.
888        let table = knock_position();
889        let seat = table.turn().expect("the drawer is mid-turn");
890        let mut solver = MonteCarloBot::new(StdRng::seed_from_u64(1)).samples(32);
891        let rows = solver.assess(&table.view(seat));
892
893        let knocks: Vec<_> = rows
894            .iter()
895            .filter(|r| r.action.starts_with("knock"))
896            .collect();
897        assert_eq!(knocks.len(), 1, "one knock row, not one per shed");
898        assert_eq!(knocks[0].action, "knock");
899    }
900
901    #[test]
902    fn seeded_pick_is_identical_across_serial_and_parallel_builds() {
903        // The `parallel` feature must not change a single decision: batch
904        // results are collected in world order and reduced sequentially in
905        // both builds, so this exact pick is the answer in either one.  CI
906        // runs the suite with and without the feature; a failure in only
907        // one build means the parallel reduce stopped being order-exact.
908        // Re-pin the expected action whenever sampling logic changes.
909        let table = knock_position();
910        let seat = table.turn().expect("the drawer is mid-turn");
911        let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(11)).samples(64);
912        let rows = bot.assess(&table.view(seat));
913        let pick = rows.iter().find(|r| r.recommended).expect("a flagged pick");
914        assert_eq!(pick.action, "knock");
915    }
916
917    #[test]
918    fn elimination_matches_the_full_read() {
919        // Batched scoring must pick what an unbatched run over the same
920        // worlds picks, spend strictly fewer rollouts doing it (the knock
921        // dominates every plain shed here), and leave survivors' equities
922        // bit-identical to the unbatched ones.
923        let table = knock_position();
924        let seat = table.turn().expect("the drawer is mid-turn");
925        let view = table.view(seat);
926        let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(9)).samples(256);
927        let candidates = bot.hint_candidates(&view);
928        let worlds = bot.sample_worlds(&view, 256);
929        let batched = MonteCarloBot::<StdRng>::score_worlds(&view, &worlds, &candidates);
930
931        let me = view.seat();
932        let rules = view.rules();
933        let standing = view.game_scores();
934        let full: Vec<(Vec<f64>, f64)> = candidates
935            .iter()
936            .map(|candidate| {
937                let mut equities = Vec::new();
938                let mut ev_sum = 0.0;
939                for world in &worlds {
940                    let sim = MonteCarloBot::<StdRng>::sim(&view, world, candidate.choice.phase());
941                    let result = candidate.choice.roll(sim);
942                    equities.push(equity(result, me, standing, rules));
943                    ev_sum += round_points(result, me, rules);
944                }
945                (equities, ev_sum)
946            })
947            .collect();
948
949        assert_eq!(recommended(&batched), recommended(&full));
950
951        let rolled: usize = batched.iter().map(|(e, _)| e.len()).sum();
952        let all: usize = full.iter().map(|(e, _)| e.len()).sum();
953        assert!(
954            rolled < all,
955            "no challenger was eliminated: {rolled} of {all} rollouts"
956        );
957
958        for (b, f) in batched.iter().zip(&full) {
959            if b.0.len() == worlds.len() {
960                assert_eq!(
961                    b.0, f.0,
962                    "a survivor's equities must be unbatched-identical"
963                );
964            }
965        }
966    }
967}