use crate::heuristic::greedy_layoff;
use crate::sim::{Sim, SimPhase};
use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
use gin_rummy::{Card, Hand, Phase, Player, RoundResult, Rules, best_melds, deadwood};
use rand::{Rng, RngExt as _};
const MAX_CANDIDATES: usize = 4;
const BATCH: usize = 32;
struct World {
opponent: Hand,
stock: Vec<Card>,
}
struct Candidate {
label: String,
choice: Choice,
}
#[derive(Clone, Copy)]
enum Choice {
Upcard(UpcardAction),
Draw(DrawAction),
Turn(TurnAction),
}
impl Choice {
fn phase(self) -> SimPhase {
match self {
Self::Upcard(_) => SimPhase::Upcard,
Self::Draw(_) => SimPhase::Draw,
Self::Turn(_) => SimPhase::Shed,
}
}
fn roll(self, mut sim: Sim) -> RoundResult {
match self {
Self::Upcard(UpcardAction::Take) | Self::Draw(DrawAction::TakeDiscard) => {
sim.take_discard();
sim.rollout()
}
Self::Upcard(UpcardAction::Pass) => {
sim.pass();
sim.rollout()
}
Self::Draw(DrawAction::Stock) => {
sim.draw_stock();
sim.rollout()
}
Self::Turn(TurnAction::BigGin(_)) => sim.big_gin(),
Self::Turn(TurnAction::Knock { discard, melds }) => sim.knock(discard, melds),
Self::Turn(TurnAction::Discard(card)) => {
sim.discard(card).unwrap_or_else(|| sim.rollout())
}
}
}
}
pub struct MonteCarloBot<R: Rng> {
rng: R,
samples: u32,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Assessment {
pub action: String,
pub equity: f64,
pub ev: f64,
pub recommended: bool,
}
impl<R: Rng> MonteCarloBot<R> {
pub const fn new(rng: R) -> Self {
Self { rng, samples: 128 }
}
#[must_use]
pub const fn samples(mut self, samples: u32) -> Self {
self.samples = samples;
self
}
fn sample_worlds(&mut self, view: &View<'_>, count: u32) -> Vec<World> {
let unseen = view.unseen();
let known = view.opponent_known();
let missing = view.opponent_hand_len() - known.len();
let strength = opponent_strength(view.discard_pile().len());
let mut pool: Vec<Card> = unseen.iter().collect();
(0..count)
.map(|_| {
let hidden = (0..strength)
.map(|_| {
for i in 0..missing {
let j = self.rng.random_range(i..pool.len());
pool.swap(i, j);
}
pool[..missing].iter().copied().collect::<Hand>()
})
.min_by_key(|&hidden| deadwood(known | hidden))
.expect("at least one draw is always sampled");
let mut stock: Vec<Card> = pool
.iter()
.copied()
.filter(|&card| !hidden.contains(card))
.collect();
for i in (1..stock.len()).rev() {
let j = self.rng.random_range(0..=i);
stock.swap(i, j);
}
World {
opponent: known | hidden,
stock,
}
})
.collect()
}
fn sim(view: &View<'_>, world: &World, phase: SimPhase) -> Sim {
let seat = view.seat();
let mut hands = [Hand::EMPTY; 2];
hands[seat as usize] = view.hand();
hands[seat.opponent() as usize] = world.opponent;
Sim {
rules: *view.rules(),
knock_limit: view.knock_limit(),
hands,
stock: world.stock.clone(),
pile: view.discard_pile().to_vec(),
turn: seat,
phase,
taken: view.taken_discard(),
passes: u8::from(seat == view.dealer()),
forced_stock: false,
}
}
#[must_use]
pub fn assess(&mut self, view: &View<'_>) -> Vec<Assessment> {
let candidates = self.hint_candidates(view);
if candidates.is_empty() {
return Vec::new();
}
let worlds = self.sample_worlds(view, self.samples);
let scored = Self::score_worlds(view, &worlds, &candidates);
Self::rank(&candidates, &scored)
}
fn choose(&mut self, view: &View<'_>, candidates: &[Candidate]) -> Choice {
let worlds = self.sample_worlds(view, self.samples);
let scored = Self::score_worlds(view, &worlds, candidates);
candidates[recommended(&scored)].choice
}
fn hint_candidates(&self, view: &View<'_>) -> Vec<Candidate> {
let candidate = |label: String, choice: Choice| Candidate { label, choice };
match view.phase() {
Phase::Upcard => {
let top = view.upcard().expect("the upcard offer has an upcard");
let take = candidate(format!("take {top}"), Choice::Upcard(UpcardAction::Take));
let pass = candidate("pass".to_string(), Choice::Upcard(UpcardAction::Pass));
if crate::heuristic::improves(view.hand(), top) {
vec![take, pass]
} else {
vec![pass, take]
}
}
Phase::Draw => {
if !view.can_take_discard() {
return Vec::new();
}
let top = view.upcard().expect("the pile is never empty on a draw");
let stock = candidate("draw stock".to_string(), Choice::Draw(DrawAction::Stock));
let pile = candidate(format!("take {top}"), Choice::Draw(DrawAction::TakeDiscard));
if crate::heuristic::improves(view.hand(), top) {
vec![pile, stock]
} else {
vec![stock, pile]
}
}
Phase::Discard => {
let hand = view.hand();
if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
let choice = Choice::Turn(TurnAction::BigGin(best_melds(hand)));
return vec![candidate("big gin".to_string(), choice)];
}
let mut sheds: Vec<(Card, u8)> = hand
.iter()
.filter(|&card| Some(card) != view.taken_discard())
.map(|card| (card, deadwood(hand - card.into())))
.collect();
sheds.sort_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()));
sheds.truncate(MAX_CANDIDATES);
let limit = view.knock_limit();
let mut out = Vec::new();
if let Some(&(card, rest)) = sheds.first()
&& rest <= limit
{
let melds = best_melds(hand - card.into());
let knock = Choice::Turn(TurnAction::Knock {
discard: card,
melds,
});
out.push(candidate("knock".to_string(), knock));
}
for &(card, _) in &sheds {
let discard = Choice::Turn(TurnAction::Discard(card));
out.push(candidate(format!("discard {card}"), discard));
}
out
}
_ => Vec::new(),
}
}
fn score_worlds(
view: &View<'_>,
worlds: &[World],
candidates: &[Candidate],
) -> Vec<(Vec<f64>, f64)> {
let me = view.seat();
let rules = view.rules();
let standing = view.game_scores();
let eval = |candidate: &Candidate, world: &World| {
let sim = Self::sim(view, world, candidate.choice.phase());
let result = candidate.choice.roll(sim);
(
equity(result, me, standing, rules),
round_points(result, me, rules),
)
};
let mut scored: Vec<(Vec<f64>, f64)> = vec![(Vec::new(), 0.0); candidates.len()];
let mut alive: Vec<usize> = (1..candidates.len()).collect();
let mut done = 0;
while done < worlds.len() {
let batch = &worlds[done..worlds.len().min(done + done.max(BATCH))];
for &i in std::iter::once(&0).chain(&alive) {
let candidate = &candidates[i];
#[cfg(feature = "parallel")]
let results: Vec<(f64, f64)> = {
use rayon::prelude::*;
batch
.par_iter()
.map(|world| eval(candidate, world))
.collect()
};
#[cfg(not(feature = "parallel"))]
let results = batch.iter().map(|world| eval(candidate, world));
let (equities, ev_sum) = &mut scored[i];
for (equity, points) in results {
equities.push(equity);
*ev_sum += points;
}
}
done += batch.len();
if done < worlds.len() {
alive.retain(|&i| !beats(&scored[0].0, &scored[i].0));
if alive.is_empty() {
break;
}
}
}
scored
}
fn rank(candidates: &[Candidate], scored: &[(Vec<f64>, f64)]) -> Vec<Assessment> {
let best = recommended(scored);
let mut out: Vec<Assessment> = candidates
.iter()
.zip(scored)
.enumerate()
.map(|(i, (candidate, (equities, ev_sum)))| {
let n = equities.len() as f64;
Assessment {
action: candidate.label.clone(),
equity: equities.iter().sum::<f64>() / n,
ev: ev_sum / n,
recommended: i == best,
}
})
.collect();
out.sort_by(|a, b| b.equity.total_cmp(&a.equity));
out
}
}
fn recommended(scored: &[(Vec<f64>, f64)]) -> usize {
let mean = |e: &[f64]| e.iter().sum::<f64>() / e.len() as f64;
let defend = &scored[0].0;
(1..scored.len())
.filter(|&i| beats(&scored[i].0, defend))
.max_by(|&a, &b| mean(&scored[a].0).total_cmp(&mean(&scored[b].0)))
.unwrap_or(0)
}
const fn opponent_strength(pile_len: usize) -> usize {
if pile_len < 2 { 1 } else { pile_len / 2 }
}
fn beats(challenger: &[f64], incumbent: &[f64]) -> bool {
let n = challenger.len() as f64;
let mean = challenger
.iter()
.zip(incumbent)
.map(|(c, i)| c - i)
.sum::<f64>()
/ n;
if mean <= 0.0 {
return false;
}
let var = challenger
.iter()
.zip(incumbent)
.map(|(c, i)| (c - i - mean).powi(2))
.sum::<f64>()
/ n;
mean > 2.0 * (var / n).sqrt()
}
fn equity(result: RoundResult, me: Player, standing: [u16; 2], rules: &Rules) -> f64 {
let mut scores = standing;
let mut points = 0.0;
if let Some(winner) = result.winner() {
let immediate = if rules.immediate_boxes {
rules.box_bonus
} else {
0
};
let gain = result.points(rules).saturating_add(immediate);
let side = usize::from(winner != me);
scores[side] = scores[side].saturating_add(gain);
points = if winner == me {
f64::from(gain)
} else {
-f64::from(gain)
};
}
if scores[0] >= rules.game_target {
1.0
} else if scores[1] >= rules.game_target {
0.0
} else {
0.5 + points / (4.0 * f64::from(rules.game_target))
}
}
fn round_points(result: RoundResult, me: Player, rules: &Rules) -> f64 {
let Some(winner) = result.winner() else {
return 0.0;
};
let immediate = if rules.immediate_boxes {
rules.box_bonus
} else {
0
};
let gain = result.points(rules).saturating_add(immediate);
if winner == me {
f64::from(gain)
} else {
-f64::from(gain)
}
}
impl<R: Rng> Strategy for MonteCarloBot<R> {
fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
let candidates = self.hint_candidates(view);
match self.choose(view, &candidates) {
Choice::Upcard(action) => action,
_ => unreachable!("the upcard offer yields upcard choices"),
}
}
fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
let candidates = self.hint_candidates(view);
if candidates.is_empty() {
return DrawAction::Stock;
}
match self.choose(view, &candidates) {
Choice::Draw(action) => action,
_ => unreachable!("the draw phase yields draw choices"),
}
}
fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
let hand = view.hand();
if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
return TurnAction::BigGin(best_melds(hand));
}
let candidates = self.hint_candidates(view);
match self.choose(view, &candidates) {
Choice::Turn(action) => action,
_ => unreachable!("the discard phase yields turn choices"),
}
}
fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
}
fn name(&self) -> &str {
"mc"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Table;
use gin_rummy::{Round, Rules};
use rand::SeedableRng as _;
use rand::rngs::StdRng;
fn fixed_table() -> Table {
let deck: Vec<_> = Hand::ALL.iter().collect();
let hands = [
deck.iter().step_by(2).take(10).copied().collect::<Hand>(),
deck.iter().skip(1).step_by(2).take(10).copied().collect(),
];
let round = Round::from_deal(
Rules::default(),
Player::One,
hands,
deck[20],
deck[21..].to_vec(),
)
.expect("a partitioned deck");
Table::new(round)
}
#[test]
fn sampled_worlds_are_consistent_with_the_view() {
let table = fixed_table();
let view = table.view(Player::Two);
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(1)).samples(32);
for world in bot.sample_worlds(&view, 32) {
assert_eq!(world.opponent.len(), view.opponent_hand_len());
assert_eq!(world.stock.len(), view.stock_len());
let stock: Hand = world.stock.iter().copied().collect();
assert!((world.opponent & stock).is_empty());
assert_eq!(
world.opponent | stock,
view.unseen() | view.opponent_known()
);
assert!((world.opponent & view.hand()).is_empty());
assert!((stock & view.hand()).is_empty());
assert_eq!(
world.opponent & view.opponent_known(),
view.opponent_known()
);
}
}
#[test]
fn opponent_strength_keeps_growing_past_the_old_cap() {
assert_eq!(opponent_strength(0), 1);
assert_eq!(opponent_strength(12), 6);
assert!(opponent_strength(24) > 6);
}
#[test]
fn seeded_bots_repeat_their_decisions() {
let table = fixed_table();
let decide = |seed| {
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(seed)).samples(16);
bot.offer_upcard(&table.view(Player::Two))
};
assert_eq!(decide(3), decide(3));
}
#[test]
fn equity_is_terminal_at_the_target() {
let rules = Rules::default();
let me = Player::One;
let win = RoundResult::Knock {
winner: me,
margin: 15,
};
assert_eq!(equity(win, me, [90, 50], &rules), 1.0);
let loss = RoundResult::Knock {
winner: me.opponent(),
margin: 15,
};
assert_eq!(equity(loss, me, [50, 90], &rules), 0.0);
}
#[test]
fn equity_prices_immediate_boxes() {
let me = Player::One;
let result = RoundResult::Knock {
winner: me,
margin: 3,
};
assert_eq!(equity(result, me, [95, 95], &Rules::palace()), 1.0);
let deferred = equity(result, me, [95, 95], &Rules::default());
assert!(deferred > 0.5 && deferred < 1.0);
}
#[test]
fn equity_orders_results_at_level_scores() {
let rules = Rules::default();
let me = Player::One;
let gin = equity(
RoundResult::Gin {
winner: me,
deadwood: 30,
},
me,
[0, 0],
&rules,
);
let knock = equity(
RoundResult::Knock {
winner: me,
margin: 10,
},
me,
[0, 0],
&rules,
);
let dead = equity(RoundResult::Dead, me, [0, 0], &rules);
let loss = equity(
RoundResult::Knock {
winner: me.opponent(),
margin: 10,
},
me,
[0, 0],
&rules,
);
assert!(gin > knock && knock > dead && dead > loss);
assert_eq!(dead, 0.5);
}
#[test]
fn mid_game_equity_is_affine_in_round_points() {
let rules = Rules::default();
let me = Player::One;
let win = RoundResult::Knock {
winner: me,
margin: 10,
};
assert_eq!(equity(RoundResult::Dead, me, [60, 20], &rules), 0.5);
assert_eq!(
equity(win, me, [60, 20], &rules),
equity(win, me, [0, 0], &rules),
);
}
#[test]
fn beats_requires_a_clear_margin() {
let base: Vec<f64> = (0..32).map(|i| f64::from(i % 5)).collect();
let noisy: Vec<f64> = base
.iter()
.enumerate()
.map(|(i, x)| x + if i % 2 == 0 { 1.05 } else { -0.95 })
.collect();
assert!(!beats(&noisy, &base));
let better: Vec<f64> = base.iter().map(|x| x + 1.0).collect();
assert!(beats(&better, &base));
assert!(!beats(&base, &better));
assert!(!beats(&base, &base));
}
#[test]
fn assess_ranks_candidates_and_flags_the_bots_pick() {
let table = fixed_table();
let seat = table.turn().expect("a fresh deal has a mover");
let view = table.view(seat);
let mut solver = MonteCarloBot::new(StdRng::seed_from_u64(7)).samples(64);
let mut chooser = MonteCarloBot::new(StdRng::seed_from_u64(7)).samples(64);
let rows = solver.assess(&view);
assert!(!rows.is_empty(), "the upcard offer is a real choice");
for row in &rows {
assert!((0.0..=1.0).contains(&row.equity));
}
assert!(rows.windows(2).all(|w| w[0].equity >= w[1].equity));
assert_eq!(rows.iter().filter(|r| r.recommended).count(), 1);
let picked = rows.iter().find(|r| r.recommended).expect("a flagged pick");
let expected = match chooser.offer_upcard(&view) {
UpcardAction::Take => format!("take {}", view.upcard().expect("an upcard offer")),
UpcardAction::Pass => "pass".to_string(),
};
assert_eq!(picked.action, expected);
}
fn knock_position() -> Table {
let two: Hand = "A23.456.789.2".parse().expect("a legal hand");
let one: Hand = "TJ.TJ.TJ.3456".parse().expect("a legal hand");
let upcard: Card = "QS".parse().expect("a card");
let king: Card = "KS".parse().expect("a card");
let mut stock: Vec<Card> = (Hand::ALL - two - one - upcard.into() - king.into())
.iter()
.collect();
stock.push(king);
let round = Round::from_deal(Rules::default(), Player::One, [one, two], upcard, stock)
.expect("a partitioned deck");
let mut table = Table::new(round);
struct Passer;
impl Strategy for Passer {
fn offer_upcard(&mut self, _: &View<'_>) -> UpcardAction {
UpcardAction::Pass
}
fn choose_draw(&mut self, _: &View<'_>) -> DrawAction {
DrawAction::Stock
}
fn play_turn(&mut self, _: &View<'_>) -> TurnAction {
unreachable!("the round stops at the discard")
}
fn choose_layoff(&mut self, _: &View<'_>) -> Option<Layoff> {
None
}
fn name(&self) -> &str {
"passer"
}
}
while table.round().phase() != Phase::Discard {
table
.step(&mut Passer)
.expect("a legal pass or forced draw");
}
table
}
#[test]
fn assess_reports_a_single_knock_at_a_discard() {
let table = knock_position();
let seat = table.turn().expect("the drawer is mid-turn");
let mut solver = MonteCarloBot::new(StdRng::seed_from_u64(1)).samples(32);
let rows = solver.assess(&table.view(seat));
let knocks: Vec<_> = rows
.iter()
.filter(|r| r.action.starts_with("knock"))
.collect();
assert_eq!(knocks.len(), 1, "one knock row, not one per shed");
assert_eq!(knocks[0].action, "knock");
}
#[test]
fn seeded_pick_is_identical_across_serial_and_parallel_builds() {
let table = knock_position();
let seat = table.turn().expect("the drawer is mid-turn");
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(11)).samples(64);
let rows = bot.assess(&table.view(seat));
let pick = rows.iter().find(|r| r.recommended).expect("a flagged pick");
assert_eq!(pick.action, "knock");
}
#[test]
fn elimination_matches_the_full_read() {
let table = knock_position();
let seat = table.turn().expect("the drawer is mid-turn");
let view = table.view(seat);
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(9)).samples(256);
let candidates = bot.hint_candidates(&view);
let worlds = bot.sample_worlds(&view, 256);
let batched = MonteCarloBot::<StdRng>::score_worlds(&view, &worlds, &candidates);
let me = view.seat();
let rules = view.rules();
let standing = view.game_scores();
let full: Vec<(Vec<f64>, f64)> = candidates
.iter()
.map(|candidate| {
let mut equities = Vec::new();
let mut ev_sum = 0.0;
for world in &worlds {
let sim = MonteCarloBot::<StdRng>::sim(&view, world, candidate.choice.phase());
let result = candidate.choice.roll(sim);
equities.push(equity(result, me, standing, rules));
ev_sum += round_points(result, me, rules);
}
(equities, ev_sum)
})
.collect();
assert_eq!(recommended(&batched), recommended(&full));
let rolled: usize = batched.iter().map(|(e, _)| e.len()).sum();
let all: usize = full.iter().map(|(e, _)| e.len()).sum();
assert!(
rolled < all,
"no challenger was eliminated: {rolled} of {all} rollouts"
);
for (b, f) in batched.iter().zip(&full) {
if b.0.len() == worlds.len() {
assert_eq!(
b.0, f.0,
"a survivor's equities must be unbatched-identical"
);
}
}
}
}