Skip to main content

gin_rummy_engine/
eaai.rs

1//! [`EaaiSimpleBot`]: the reference baseline of the EAAI-2021 challenge
2//!
3//! A port of Todd Neller's `SimpleGinRummyPlayer`, the opponent every
4//! entry of the EAAI-2021 Gin Rummy Undergraduate Research Challenge was
5//! measured against, so win rates against it are comparable across
6//! engines and papers.  Its policy: draw the face-up card only if it
7//! immediately joins a meld, shed a uniformly random card among those
8//! leaving minimal deadwood, and knock as early as possible.
9
10use crate::heuristic::greedy_layoff;
11use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
12use gin_rummy::{Card, Hand, Rank, Suit, best_melds, deadwood};
13use rand::{Rng, RngExt as _};
14
15/// Whether `top` would sit inside some meld of `hand` + `top` — the
16/// baseline's test for drawing the face-up card.
17fn joins_a_meld(hand: Hand, top: Card) -> bool {
18    let with = hand | top.into();
19    let of_rank = Suit::ASC
20        .into_iter()
21        .filter(|&suit| {
22            with.contains(Card {
23                suit,
24                rank: top.rank,
25            })
26        })
27        .count();
28    if of_rank >= 3 {
29        return true;
30    }
31
32    // Any three consecutive ranks of the card's suit around it; runs
33    // never wrap, so the windows truncate at the ace and the king.
34    let pivot = top.rank.get();
35    (pivot.saturating_sub(2).max(1)..=pivot.min(11)).any(|low| {
36        (low..low + 3).all(|rank| {
37            with.contains(Card {
38                suit: top.suit,
39                rank: Rank::new(rank),
40            })
41        })
42    })
43}
44
45/// The reference baseline of the EAAI-2021 Gin Rummy challenge
46///
47/// A port of `SimpleGinRummyPlayer` from Todd Neller's challenge
48/// framework: it ignores everything the opponent does, takes the face-up
49/// card only when it immediately joins a meld, sheds a uniformly random
50/// card among those leaving minimal deadwood — refusing to repeat a
51/// (draw, discard) pair within a round, the original's loop breaker —
52/// and knocks at the first legal opportunity.  It exists as a fixed
53/// measuring stick for comparisons across engines and papers, not as a
54/// good player, so there are no tuning knobs.
55///
56/// Departures from the Java original, none of which change its policy
57/// under the challenge rules: the knock spread is [`best_melds`]'s
58/// optimal arrangement rather than a random optimal one (this affects
59/// only which layoffs the defender is offered); layoffs use this crate's
60/// greedy layoff in place of the framework's automatic first-fit sweep;
61/// and the knock threshold follows [`View::knock_limit`] rather than a
62/// hardcoded 10, so the bot stays legal under any ruleset.
63///
64/// The EAAI framework has no big gin, so this bot never declares it; run
65/// benchmarks with `big_gin_bonus: None` for exactly the challenge's
66/// round conditions.
67#[derive(Debug, Clone)]
68pub struct EaaiSimpleBot<R> {
69    rng: R,
70    /// The 10-card hand seen at this turn's draw decision, so `play_turn`
71    /// can identify the drawn card even after a stock draw.
72    pre_draw: Hand,
73    /// (drawn, discarded) pairs already played this round; the original
74    /// refuses to repeat one so mirror matches cannot loop forever.
75    seen_pairs: Vec<(Card, Card)>,
76}
77
78impl<R: Rng> EaaiSimpleBot<R> {
79    /// A baseline over the given generator; seed it for replayable runs
80    #[must_use]
81    pub const fn new(rng: R) -> Self {
82        Self {
83            rng,
84            pre_draw: Hand::EMPTY,
85            seen_pairs: Vec::new(),
86        }
87    }
88
89    /// The card drawn this turn: the one added since the draw decision.
90    fn drawn(&self, hand: Hand) -> Option<Card> {
91        let fresh = hand - self.pre_draw;
92        let mut cards = fresh.iter();
93        match (cards.next(), cards.next()) {
94            (Some(card), None) => Some(card),
95            _ => None,
96        }
97    }
98
99    /// The original's discard: uniformly random among the sheds leaving
100    /// minimal deadwood, never the just-taken upcard, never a repeated
101    /// (drawn, discarded) pair.  Records the chosen pair.
102    fn pick_discard(&mut self, hand: Hand, taken: Option<Card>, drawn: Option<Card>) -> (Card, u8) {
103        let mut min_deadwood = u8::MAX;
104        let mut candidates: Vec<Card> = Vec::new();
105        for card in hand {
106            if Some(card) == taken {
107                continue;
108            }
109            if let Some(drawn) = drawn
110                && self.seen_pairs.contains(&(drawn, card))
111            {
112                continue;
113            }
114            let rest = deadwood(hand - card.into());
115            if rest < min_deadwood {
116                min_deadwood = rest;
117                candidates.clear();
118            }
119            if rest <= min_deadwood {
120                candidates.push(card);
121            }
122        }
123
124        // The original would crash with every card blocked; forgetting
125        // the pair memory instead keeps the bot legal.
126        let (card, rest) = if candidates.is_empty() {
127            self.seen_pairs.clear();
128            hand.iter()
129                .filter(|&card| Some(card) != taken)
130                .map(|card| (card, deadwood(hand - card.into())))
131                .min_by_key(|&(_, rest)| rest)
132                .expect("an 11-card hand always has a legal discard")
133        } else {
134            let index = self.rng.random_range(0..candidates.len());
135            (candidates[index], min_deadwood)
136        };
137        if let Some(drawn) = drawn {
138            self.seen_pairs.push((drawn, card));
139        }
140        (card, rest)
141    }
142}
143
144impl<R: Rng> Strategy for EaaiSimpleBot<R> {
145    fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
146        // The upcard offer opens a round: forget the previous round.
147        self.seen_pairs.clear();
148        self.pre_draw = view.hand();
149        let top = view.upcard().expect("the upcard offer has an upcard");
150        if joins_a_meld(view.hand(), top) {
151            UpcardAction::Take
152        } else {
153            UpcardAction::Pass
154        }
155    }
156
157    fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
158        // A full stock means nothing has been drawn yet: this is the
159        // dealer's first decision of a round whose upcard offer it never
160        // received, so the pair memory is stale.  (A chain of pile takes
161        // can re-trigger this a turn later and drop one recorded pair —
162        // harmless.)
163        if view.stock_len() == 31 {
164            self.seen_pairs.clear();
165        }
166        self.pre_draw = view.hand();
167        let top = view.upcard().expect("the pile is never empty on a draw");
168        if joins_a_meld(view.hand(), top) {
169            DrawAction::TakeDiscard
170        } else {
171            DrawAction::Stock
172        }
173    }
174
175    fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
176        let hand = view.hand();
177        let drawn = view.taken_discard().or_else(|| self.drawn(hand));
178        let (card, rest) = self.pick_discard(hand, view.taken_discard(), drawn);
179
180        // "Knock as early as possible."  The framework has no big gin, so
181        // a fully melded 11-card hand knocks into plain gin instead.
182        if rest <= view.knock_limit() {
183            TurnAction::Knock {
184                discard: card,
185                melds: best_melds(hand - card.into()),
186            }
187        } else {
188            TurnAction::Discard(card)
189        }
190    }
191
192    fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
193        greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
194    }
195
196    fn name(&self) -> &str {
197        "eaai-simple"
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::{HeuristicBot, play_game, play_round};
205    use gin_rummy::{Game, Player, Round, Rules};
206    use rand::SeedableRng as _;
207    use rand::rngs::StdRng;
208
209    fn card(text: &str) -> Card {
210        text.parse().expect("a valid card")
211    }
212
213    #[test]
214    fn draws_the_upcard_only_into_a_meld() {
215        // ♣A♣2♣7 ♦7 ♥3♥4 ♠8♠9.
216        let hand: Hand = "A27.7.34.89".parse().expect("a valid hand");
217        // A third seven completes a set; the ♥5 extends 3-4 into a run.
218        assert!(joins_a_meld(hand, card("♠7")));
219        assert!(joins_a_meld(hand, card("♥5")));
220        assert!(joins_a_meld(hand, card("♥2")));
221        // The ♦3 pairs the ♥3 and neighbors the ♦7's suit but melds with
222        // neither; the ♦K is loose entirely.
223        assert!(!joins_a_meld(hand, card("♦3")));
224        assert!(!joins_a_meld(hand, card("♦K")));
225        // Rank edges truncate rather than wrap.
226        assert!(joins_a_meld("QK...".parse().unwrap(), card("♣J")));
227        assert!(!joins_a_meld("2K...".parse().unwrap(), card("♣A")));
228    }
229
230    #[test]
231    fn discards_uniformly_among_minimal_deadwood_sheds() {
232        // ♣A♣2♣3 ♦4♦5♦6 ♥7♥8♥9 melded; the ♠J and drawn ♠K tie as sheds.
233        let hand: Hand = "A23.456.789.JK".parse().expect("a valid hand");
234        let mut bot = EaaiSimpleBot::new(StdRng::seed_from_u64(0));
235        let mut seen = Hand::EMPTY;
236        for _ in 0..64 {
237            bot.seen_pairs.clear();
238            let (shed, rest) = bot.pick_discard(hand, None, Some(card("♠K")));
239            assert_eq!(rest, 10);
240            seen.insert(shed);
241        }
242        // Both minimal sheds appear over 64 draws; melded cards never do.
243        assert_eq!(seen, "...JK".parse().expect("a valid hand"));
244    }
245
246    #[test]
247    fn refuses_a_repeated_draw_discard_pair() {
248        let hand: Hand = "A23.456.789.JK".parse().expect("a valid hand");
249        // Both minimal sheds are burned pairs: the next-least deadwood
250        // shed breaks the club run at its cheapest card, uniquely ♣3.
251        let mut bot = EaaiSimpleBot::new(StdRng::seed_from_u64(0));
252        bot.seen_pairs.push((card("♠K"), card("♠J")));
253        bot.seen_pairs.push((card("♠K"), card("♠K")));
254        assert_eq!(
255            bot.pick_discard(hand, None, Some(card("♠K"))),
256            (card("♣3"), 23),
257        );
258
259        // With every card blocked the memory resets instead of panicking.
260        let mut bot = EaaiSimpleBot::new(StdRng::seed_from_u64(0));
261        for shed in hand {
262            bot.seen_pairs.push((card("♠K"), shed));
263        }
264        let (_, rest) = bot.pick_discard(hand, None, Some(card("♠K")));
265        assert_eq!(rest, 10);
266        assert_eq!(bot.seen_pairs.len(), 1);
267    }
268
269    #[test]
270    fn knocks_at_the_first_opportunity() {
271        // Dealt three melds and the ♠J, One passes the useless ♠T, draws
272        // the ♠K from the stock, and knocks on its very first turn.
273        let hands: [Hand; 2] = [
274            "A23.456.789.J".parse().expect("a valid hand"),
275            "45.89J.JQK.23".parse().expect("a valid hand"),
276        ];
277        let upcard = card("♠T");
278        let stock: Vec<Card> = Hand::ALL
279            .iter()
280            .filter(|&c| !hands[0].contains(c) && !hands[1].contains(c) && c != upcard)
281            .collect();
282        let round = Round::from_deal(Rules::default(), Player::Two, hands, upcard, stock)
283            .expect("a partitioned deck");
284
285        let mut bot = EaaiSimpleBot::new(StdRng::seed_from_u64(0));
286        let mut greedy = HeuristicBot::new();
287        let result = play_round(round, [&mut bot, &mut greedy]).expect("only legal actions");
288        assert_eq!(result.winner(), Some(Player::One));
289    }
290
291    #[test]
292    fn survives_whole_games_against_the_heuristic() {
293        // The challenge rules have no big gin; whole games exercise the
294        // per-round reset of the pair memory and every decision method.
295        let mut rules = Rules::default();
296        rules.big_gin_bonus = None;
297        for seed in 0..8 {
298            let mut rng = StdRng::seed_from_u64(seed);
299            let mut bot = EaaiSimpleBot::new(StdRng::seed_from_u64(seed));
300            let mut greedy = HeuristicBot::new();
301            let mut game = Game::new(rules, Player::One);
302            play_game(&mut game, [&mut bot, &mut greedy], &mut rng).expect("only legal actions");
303        }
304    }
305}