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}