use crate::heuristic::greedy_layoff;
use crate::sim::{Sim, SimPhase};
use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
use gin_rummy::deck::Deck;
use gin_rummy::{Card, Hand, Melds, Phase, Player, RoundResult, Rules, best_melds, deadwood};
use rand::Rng;
struct World {
opponent: Hand,
stock: Vec<Card>,
}
struct Candidate {
label: String,
phase: SimPhase,
action: RolloutAction,
}
#[derive(Clone, Copy)]
enum RolloutAction {
TakeDiscard,
Pass,
DrawStock,
BigGin,
Knock { discard: Card, melds: Melds },
Discard(Card),
}
impl RolloutAction {
fn roll(self, mut sim: Sim) -> RoundResult {
match self {
Self::TakeDiscard => {
sim.take_discard();
sim.rollout()
}
Self::Pass => {
sim.pass();
sim.rollout()
}
Self::DrawStock => {
sim.draw_stock();
sim.rollout()
}
Self::BigGin => sim.big_gin(),
Self::Knock { discard, melds } => sim.knock(discard, melds),
Self::Discard(card) => sim.discard(card).unwrap_or_else(|| sim.rollout()),
}
}
}
struct HintState {
candidates: Vec<Candidate>,
scored: Vec<(Vec<f64>, f64)>,
samples: u32,
fingerprint: Fingerprint,
}
#[derive(PartialEq)]
struct Fingerprint {
phase: Phase,
hand: Hand,
upcard: Option<Card>,
pile_len: usize,
taken: Option<Card>,
}
impl Fingerprint {
fn of(view: &View<'_>) -> Self {
Self {
phase: view.phase(),
hand: view.hand(),
upcard: view.upcard(),
pile_len: view.discard_pile().len(),
taken: view.taken_discard(),
}
}
}
pub struct MonteCarloBot<R: Rng> {
rng: R,
samples: u32,
max_candidates: usize,
hint_state: Option<HintState>,
}
#[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,
max_candidates: 4,
hint_state: None,
}
}
#[must_use]
pub const fn samples(mut self, samples: u32) -> Self {
self.samples = samples;
self
}
#[must_use]
pub const fn max_candidates(mut self, max_candidates: usize) -> Self {
self.max_candidates = max_candidates;
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());
(0..count)
.map(|_| {
let hidden = (0..strength)
.map(|_| {
let mut pool = Deck::EMPTY;
for card in unseen {
pool.insert(card);
}
pool.draw(&mut self.rng, missing)
})
.min_by_key(|&hidden| deadwood(known | hidden))
.expect("at least one draw is always sampled");
let mut pool = Deck::EMPTY;
for card in unseen - hidden {
pool.insert(card);
}
let mut stock = Vec::with_capacity(pool.len());
while let Some(card) = pool.pop(&mut self.rng) {
stock.push(card);
}
World {
opponent: known | hidden,
stock,
}
})
.collect()
}
fn equities(
view: &View<'_>,
worlds: &[World],
phase: SimPhase,
rollout: impl Fn(Sim) -> RoundResult,
) -> Vec<f64> {
let me = view.seat();
let rules = view.rules();
let standing = view.game_scores();
worlds
.iter()
.map(|world| equity(rollout(Self::sim(view, world, phase)), me, standing, rules))
.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 out = self.hint_open(view, self.samples);
self.hint_state = None;
out
}
#[must_use]
pub fn hint_open(&mut self, view: &View<'_>, batch: u32) -> Vec<Assessment> {
let candidates = self.hint_candidates(view);
if candidates.is_empty() {
self.hint_state = None;
return Vec::new();
}
let worlds = self.sample_worlds(view, batch);
let scored = Self::score_worlds(view, &worlds, &candidates);
let state = HintState {
candidates,
scored,
samples: batch,
fingerprint: Fingerprint::of(view),
};
let out = Self::rank_state(&state);
self.hint_state = Some(state);
out
}
#[must_use]
pub fn hint_refine(&mut self, view: &View<'_>, extra: u32) -> Vec<Assessment> {
let Some(mut state) = self.hint_state.take() else {
return Vec::new();
};
if state.fingerprint != Fingerprint::of(view) {
return Vec::new();
}
let worlds = self.sample_worlds(view, extra);
let batch = Self::score_worlds(view, &worlds, &state.candidates);
for (acc, add) in state.scored.iter_mut().zip(batch) {
acc.0.extend(add.0);
acc.1 += add.1;
}
state.samples += extra;
let out = Self::rank_state(&state);
self.hint_state = Some(state);
out
}
fn hint_candidates(&self, view: &View<'_>) -> Vec<Candidate> {
let shed = |label, action| Candidate {
label,
phase: SimPhase::Shed,
action,
};
match view.phase() {
Phase::Upcard => {
let top = view.upcard().expect("the upcard offer has an upcard");
let take = Candidate {
label: format!("take {top}"),
phase: SimPhase::Upcard,
action: RolloutAction::TakeDiscard,
};
let pass = Candidate {
label: "pass".to_string(),
phase: SimPhase::Upcard,
action: RolloutAction::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 {
label: "draw stock".to_string(),
phase: SimPhase::Draw,
action: RolloutAction::DrawStock,
};
let pile = Candidate {
label: format!("take {top}"),
phase: SimPhase::Draw,
action: RolloutAction::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() {
return vec![shed("big gin".to_string(), RolloutAction::BigGin)];
}
let mut candidates: Vec<(Card, u8)> = hand
.iter()
.filter(|&card| Some(card) != view.taken_discard())
.map(|card| (card, deadwood(hand - card.into())))
.collect();
candidates.sort_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()));
candidates.truncate(self.max_candidates.max(1));
let limit = view.knock_limit();
let mut out = Vec::new();
if let Some(&(card, rest)) = candidates.first()
&& rest <= limit
{
let melds = best_melds(hand - card.into());
out.push(shed(
format!("knock, drop {card}"),
RolloutAction::Knock {
discard: card,
melds,
},
));
}
for &(card, _) in &candidates {
out.push(shed(
format!("discard {card}"),
RolloutAction::Discard(card),
));
}
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();
candidates
.iter()
.map(|candidate| {
let mut equities = Vec::with_capacity(worlds.len());
let mut ev_sum = 0.0;
for world in worlds {
let result = candidate
.action
.roll(Self::sim(view, world, candidate.phase));
equities.push(equity(result, me, standing, rules));
ev_sum += round_points(result, me, rules);
}
(equities, ev_sum)
})
.collect()
}
fn rank_state(state: &HintState) -> Vec<Assessment> {
let mean = |e: &[f64]| e.iter().sum::<f64>() / e.len() as f64;
let defend = &state.scored[0].0;
let recommended = (1..state.scored.len())
.filter(|&i| beats(&state.scored[i].0, defend))
.max_by(|&a, &b| mean(&state.scored[a].0).total_cmp(&mean(&state.scored[b].0)))
.unwrap_or(0);
let n = f64::from(state.samples);
let mut out: Vec<Assessment> = state
.candidates
.iter()
.zip(&state.scored)
.enumerate()
.map(|(i, (candidate, (equities, ev_sum)))| Assessment {
action: candidate.label.clone(),
equity: equities.iter().sum::<f64>() / n,
ev: ev_sum / n,
recommended: i == recommended,
})
.collect();
out.sort_by(|a, b| b.equity.total_cmp(&a.equity));
out
}
}
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 top = view.upcard().expect("the upcard offer has an upcard");
let incumbent = if crate::heuristic::improves(view.hand(), top) {
UpcardAction::Take
} else {
UpcardAction::Pass
};
let worlds = self.sample_worlds(view, self.samples);
let take = Self::equities(view, &worlds, SimPhase::Upcard, |mut sim| {
sim.take_discard();
sim.rollout()
});
let pass = Self::equities(view, &worlds, SimPhase::Upcard, |mut sim| {
sim.pass();
sim.rollout()
});
let (defend, challenge, challenger) = match incumbent {
UpcardAction::Take => (take, pass, UpcardAction::Pass),
UpcardAction::Pass => (pass, take, UpcardAction::Take),
};
if beats(&challenge, &defend) {
challenger
} else {
incumbent
}
}
fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
let top = view.upcard().expect("the pile is never empty on a draw");
let incumbent = if crate::heuristic::improves(view.hand(), top) {
DrawAction::TakeDiscard
} else {
DrawAction::Stock
};
let worlds = self.sample_worlds(view, self.samples);
let stock = Self::equities(view, &worlds, SimPhase::Draw, |mut sim| {
sim.draw_stock();
sim.rollout()
});
let pile = Self::equities(view, &worlds, SimPhase::Draw, |mut sim| {
sim.take_discard();
sim.rollout()
});
let (defend, challenge, challenger) = match incumbent {
DrawAction::TakeDiscard => (pile, stock, DrawAction::Stock),
DrawAction::Stock => (stock, pile, DrawAction::TakeDiscard),
};
if beats(&challenge, &defend) {
challenger
} else {
incumbent
}
}
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 mut candidates: Vec<(Card, u8)> = hand
.iter()
.filter(|&card| Some(card) != view.taken_discard())
.map(|card| (card, deadwood(hand - card.into())))
.collect();
candidates.sort_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()));
candidates.truncate(self.max_candidates.max(1));
let worlds = self.sample_worlds(view, self.samples);
let limit = view.knock_limit();
let actions: Vec<(TurnAction, Vec<f64>)> = candidates
.iter()
.flat_map(|&(card, rest)| {
let melds = best_melds(hand - card.into());
let knock = (rest <= limit).then(|| {
let scores =
Self::equities(view, &worlds, SimPhase::Shed, |sim| sim.knock(card, melds));
(
TurnAction::Knock {
discard: card,
melds,
},
scores,
)
});
let discard = Self::equities(view, &worlds, SimPhase::Shed, |mut sim| {
sim.discard(card).unwrap_or_else(|| sim.rollout())
});
knock
.into_iter()
.chain(std::iter::once((TurnAction::Discard(card), discard)))
})
.collect();
let (incumbent, defend) = &actions[0];
actions[1..]
.iter()
.filter(|(_, challenge)| beats(challenge, defend))
.max_by(|(_, a), (_, b)| {
let mean = |s: &[f64]| s.iter().sum::<f64>() / s.len() as f64;
mean(a).total_cmp(&mean(b))
})
.map_or(*incumbent, |(action, _)| *action)
}
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);
}
#[test]
fn refining_a_session_matches_one_larger_batch() {
let table = fixed_table();
let seat = table.turn().expect("a fresh deal has a mover");
let view = table.view(seat);
let one_shot = {
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(9)).samples(128);
bot.assess(&view)
};
let refined = {
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(9));
let _ = bot.hint_open(&view, 32);
bot.hint_refine(&view, 96)
};
assert_eq!(refined.len(), one_shot.len());
for (a, b) in refined.iter().zip(&one_shot) {
assert_eq!(a.action, b.action);
assert_eq!(a.recommended, b.recommended);
assert_eq!(a.equity, b.equity);
assert!((a.ev - b.ev).abs() < 1e-9);
}
}
#[test]
fn hint_refine_without_an_open_session_is_empty() {
let table = fixed_table();
let seat = table.turn().expect("a fresh deal has a mover");
let view = table.view(seat);
let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(4)).samples(16);
assert!(bot.hint_refine(&view, 16).is_empty());
assert!(!bot.assess(&view).is_empty());
assert!(bot.hint_refine(&view, 16).is_empty());
}
#[test]
fn assess_reports_a_single_knock_at_a_discard() {
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");
}
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, drop K♠");
}
}