1use crate::heuristic::greedy_layoff;
4use crate::sim::{Sim, SimPhase};
5use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
6use gin_rummy::deck::Deck;
7use gin_rummy::{Card, Hand, Player, RoundResult, Rules, best_melds, deadwood};
8use rand::Rng;
9
10struct World {
13 opponent: Hand,
14 stock: Vec<Card>,
16}
17
18pub struct MonteCarloBot<R: Rng> {
36 rng: R,
37 samples: u32,
38 max_candidates: usize,
39}
40
41impl<R: Rng> MonteCarloBot<R> {
42 pub const fn new(rng: R) -> Self {
45 Self {
46 rng,
47 samples: 128,
48 max_candidates: 4,
49 }
50 }
51
52 #[must_use]
62 pub const fn samples(mut self, samples: u32) -> Self {
63 self.samples = samples;
64 self
65 }
66
67 #[must_use]
69 pub const fn max_candidates(mut self, max_candidates: usize) -> Self {
70 self.max_candidates = max_candidates;
71 self
72 }
73
74 fn sample_worlds(&mut self, view: &View<'_>) -> Vec<World> {
83 let unseen = view.unseen();
84 let known = view.opponent_known();
85 let missing = view.opponent_hand_len() - known.len();
86 let strength = (view.discard_pile().len() / 2).clamp(1, 6);
88
89 (0..self.samples)
90 .map(|_| {
91 let hidden = (0..strength)
92 .map(|_| {
93 let mut pool = Deck::EMPTY;
94 for card in unseen {
95 pool.insert(card);
96 }
97 pool.draw(&mut self.rng, missing)
98 })
99 .min_by_key(|&hidden| deadwood(known | hidden))
100 .expect("at least one draw is always sampled");
101
102 let mut pool = Deck::EMPTY;
103 for card in unseen - hidden {
104 pool.insert(card);
105 }
106 let mut stock = Vec::with_capacity(pool.len());
107 while let Some(card) = pool.pop(&mut self.rng) {
108 stock.push(card);
109 }
110 World {
111 opponent: known | hidden,
112 stock,
113 }
114 })
115 .collect()
116 }
117
118 fn equities(
122 view: &View<'_>,
123 worlds: &[World],
124 phase: SimPhase,
125 rollout: impl Fn(Sim) -> RoundResult,
126 ) -> Vec<f64> {
127 let me = view.seat();
128 let rules = view.rules();
129 let standing = view.game_scores();
130 worlds
131 .iter()
132 .map(|world| equity(rollout(Self::sim(view, world, phase)), me, standing, rules))
133 .collect()
134 }
135
136 fn sim(view: &View<'_>, world: &World, phase: SimPhase) -> Sim {
138 let seat = view.seat();
139 let mut hands = [Hand::EMPTY; 2];
140 hands[seat as usize] = view.hand();
141 hands[seat.opponent() as usize] = world.opponent;
142 Sim {
143 rules: *view.rules(),
144 knock_limit: view.knock_limit(),
145 hands,
146 stock: world.stock.clone(),
147 pile: view.discard_pile().to_vec(),
148 turn: seat,
149 phase,
150 taken: view.taken_discard(),
151 passes: u8::from(seat == view.dealer()),
153 forced_stock: false,
154 }
155 }
156}
157
158fn beats(challenger: &[f64], incumbent: &[f64]) -> bool {
168 let n = challenger.len() as f64;
169 let mean = challenger
170 .iter()
171 .zip(incumbent)
172 .map(|(c, i)| c - i)
173 .sum::<f64>()
174 / n;
175 if mean <= 0.0 {
176 return false;
177 }
178 let var = challenger
179 .iter()
180 .zip(incumbent)
181 .map(|(c, i)| (c - i - mean).powi(2))
182 .sum::<f64>()
183 / n;
184 mean > 2.0 * (var / n).sqrt()
185}
186
187fn equity(result: RoundResult, me: Player, standing: [u16; 2], rules: &Rules) -> f64 {
212 let mut scores = standing;
213 let mut points = 0.0;
214 if let Some(winner) = result.winner() {
215 let immediate = if rules.immediate_boxes {
216 rules.box_bonus
217 } else {
218 0
219 };
220 let gain = result.points(rules).saturating_add(immediate);
221 let side = usize::from(winner != me);
222 scores[side] = scores[side].saturating_add(gain);
223 points = if winner == me {
224 f64::from(gain)
225 } else {
226 -f64::from(gain)
227 };
228 }
229 if scores[0] >= rules.game_target {
232 1.0
233 } else if scores[1] >= rules.game_target {
234 0.0
235 } else {
236 0.5 + points / (4.0 * f64::from(rules.game_target))
237 }
238}
239
240impl<R: Rng> Strategy for MonteCarloBot<R> {
241 fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
242 let top = view.upcard().expect("the upcard offer has an upcard");
243 let incumbent = if crate::heuristic::improves(view.hand(), top) {
244 UpcardAction::Take
245 } else {
246 UpcardAction::Pass
247 };
248
249 let worlds = self.sample_worlds(view);
250 let take = Self::equities(view, &worlds, SimPhase::Upcard, |mut sim| {
251 sim.take_discard();
252 sim.rollout()
253 });
254 let pass = Self::equities(view, &worlds, SimPhase::Upcard, |mut sim| {
255 sim.pass();
256 sim.rollout()
257 });
258
259 let (defend, challenge, challenger) = match incumbent {
260 UpcardAction::Take => (take, pass, UpcardAction::Pass),
261 UpcardAction::Pass => (pass, take, UpcardAction::Take),
262 };
263 if beats(&challenge, &defend) {
264 challenger
265 } else {
266 incumbent
267 }
268 }
269
270 fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
271 let top = view.upcard().expect("the pile is never empty on a draw");
272 let incumbent = if crate::heuristic::improves(view.hand(), top) {
273 DrawAction::TakeDiscard
274 } else {
275 DrawAction::Stock
276 };
277
278 let worlds = self.sample_worlds(view);
279 let stock = Self::equities(view, &worlds, SimPhase::Draw, |mut sim| {
280 sim.draw_stock();
281 sim.rollout()
282 });
283 let pile = Self::equities(view, &worlds, SimPhase::Draw, |mut sim| {
284 sim.take_discard();
285 sim.rollout()
286 });
287
288 let (defend, challenge, challenger) = match incumbent {
289 DrawAction::TakeDiscard => (pile, stock, DrawAction::Stock),
290 DrawAction::Stock => (stock, pile, DrawAction::TakeDiscard),
291 };
292 if beats(&challenge, &defend) {
293 challenger
294 } else {
295 incumbent
296 }
297 }
298
299 fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
300 let hand = view.hand();
301 if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
302 return TurnAction::BigGin(best_melds(hand));
304 }
305
306 let mut candidates: Vec<(Card, u8)> = hand
309 .iter()
310 .filter(|&card| Some(card) != view.taken_discard())
311 .map(|card| (card, deadwood(hand - card.into())))
312 .collect();
313 candidates.sort_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()));
314 candidates.truncate(self.max_candidates.max(1));
315
316 let worlds = self.sample_worlds(view);
317 let limit = view.knock_limit();
318 let actions: Vec<(TurnAction, Vec<f64>)> = candidates
319 .iter()
320 .flat_map(|&(card, rest)| {
321 let melds = best_melds(hand - card.into());
322 let knock = (rest <= limit).then(|| {
323 let scores =
324 Self::equities(view, &worlds, SimPhase::Shed, |sim| sim.knock(card, melds));
325 (
326 TurnAction::Knock {
327 discard: card,
328 melds,
329 },
330 scores,
331 )
332 });
333 let discard = Self::equities(view, &worlds, SimPhase::Shed, |mut sim| {
334 sim.discard(card).unwrap_or_else(|| sim.rollout())
335 });
336 knock
337 .into_iter()
338 .chain(std::iter::once((TurnAction::Discard(card), discard)))
339 })
340 .collect();
341
342 let (incumbent, defend) = &actions[0];
345 actions[1..]
346 .iter()
347 .filter(|(_, challenge)| beats(challenge, defend))
348 .max_by(|(_, a), (_, b)| {
349 let mean = |s: &[f64]| s.iter().sum::<f64>() / s.len() as f64;
350 mean(a).total_cmp(&mean(b))
351 })
352 .map_or(*incumbent, |(action, _)| *action)
353 }
354
355 fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
356 greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
359 }
360
361 fn name(&self) -> &str {
362 "mc"
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::Table;
370 use gin_rummy::{Round, Rules};
371 use rand::SeedableRng as _;
372 use rand::rngs::StdRng;
373
374 fn fixed_table() -> Table {
375 let deck: Vec<_> = Hand::ALL.iter().collect();
376 let hands = [
377 deck.iter().step_by(2).take(10).copied().collect::<Hand>(),
378 deck.iter().skip(1).step_by(2).take(10).copied().collect(),
379 ];
380 let round = Round::from_deal(
381 Rules::default(),
382 Player::One,
383 hands,
384 deck[20],
385 deck[21..].to_vec(),
386 )
387 .expect("a partitioned deck");
388 Table::new(round)
389 }
390
391 #[test]
392 fn sampled_worlds_are_consistent_with_the_view() {
393 let table = fixed_table();
394 let view = table.view(Player::Two);
395 let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(1)).samples(32);
396
397 for world in bot.sample_worlds(&view) {
398 assert_eq!(world.opponent.len(), view.opponent_hand_len());
400 assert_eq!(world.stock.len(), view.stock_len());
401
402 let stock: Hand = world.stock.iter().copied().collect();
404 assert!((world.opponent & stock).is_empty());
405 assert_eq!(
406 world.opponent | stock,
407 view.unseen() | view.opponent_known()
408 );
409
410 assert!((world.opponent & view.hand()).is_empty());
412 assert!((stock & view.hand()).is_empty());
413 assert_eq!(
414 world.opponent & view.opponent_known(),
415 view.opponent_known()
416 );
417 }
418 }
419
420 #[test]
421 fn seeded_bots_repeat_their_decisions() {
422 let table = fixed_table();
423 let decide = |seed| {
424 let mut bot = MonteCarloBot::new(StdRng::seed_from_u64(seed)).samples(16);
425 bot.offer_upcard(&table.view(Player::Two))
426 };
427 assert_eq!(decide(3), decide(3));
428 }
429
430 #[test]
431 fn equity_is_terminal_at_the_target() {
432 let rules = Rules::default();
433 let me = Player::One;
434 let win = RoundResult::Knock {
435 winner: me,
436 margin: 15,
437 };
438 assert_eq!(equity(win, me, [90, 50], &rules), 1.0);
439
440 let loss = RoundResult::Knock {
441 winner: me.opponent(),
442 margin: 15,
443 };
444 assert_eq!(equity(loss, me, [50, 90], &rules), 0.0);
445 }
446
447 #[test]
448 fn equity_prices_immediate_boxes() {
449 let me = Player::One;
451 let result = RoundResult::Knock {
452 winner: me,
453 margin: 3,
454 };
455 assert_eq!(equity(result, me, [95, 95], &Rules::palace()), 1.0);
456
457 let deferred = equity(result, me, [95, 95], &Rules::default());
458 assert!(deferred > 0.5 && deferred < 1.0);
459 }
460
461 #[test]
462 fn equity_orders_results_at_level_scores() {
463 let rules = Rules::default();
464 let me = Player::One;
465 let gin = equity(
466 RoundResult::Gin {
467 winner: me,
468 deadwood: 30,
469 },
470 me,
471 [0, 0],
472 &rules,
473 );
474 let knock = equity(
475 RoundResult::Knock {
476 winner: me,
477 margin: 10,
478 },
479 me,
480 [0, 0],
481 &rules,
482 );
483 let dead = equity(RoundResult::Dead, me, [0, 0], &rules);
484 let loss = equity(
485 RoundResult::Knock {
486 winner: me.opponent(),
487 margin: 10,
488 },
489 me,
490 [0, 0],
491 &rules,
492 );
493 assert!(gin > knock && knock > dead && dead > loss);
494 assert_eq!(dead, 0.5);
495 }
496
497 #[test]
498 fn mid_game_equity_is_affine_in_round_points() {
499 let rules = Rules::default();
504 let me = Player::One;
505 let win = RoundResult::Knock {
506 winner: me,
507 margin: 10,
508 };
509 assert_eq!(equity(RoundResult::Dead, me, [60, 20], &rules), 0.5);
510 assert_eq!(
511 equity(win, me, [60, 20], &rules),
512 equity(win, me, [0, 0], &rules),
513 );
514 }
515
516 #[test]
517 fn beats_requires_a_clear_margin() {
518 let base: Vec<f64> = (0..32).map(|i| f64::from(i % 5)).collect();
521 let noisy: Vec<f64> = base
522 .iter()
523 .enumerate()
524 .map(|(i, x)| x + if i % 2 == 0 { 1.05 } else { -0.95 })
525 .collect();
526 assert!(!beats(&noisy, &base));
527
528 let better: Vec<f64> = base.iter().map(|x| x + 1.0).collect();
530 assert!(beats(&better, &base));
531 assert!(!beats(&base, &better));
532 assert!(!beats(&base, &base));
534 }
535}