Skip to main content

gin_rummy_engine/
heuristic.rs

1//! [`HeuristicBot`]: a deterministic knowledge-based player
2//!
3//! The knowledge-free greedy core in this module — draw only on strict
4//! deadwood improvement, shed the least useful card, knock as soon as
5//! allowed — is the policy the sibling crate's `simulate` example proved
6//! out, and it doubles as the rollout policy of the Monte Carlo bot.  The
7//! bot itself layers opponent knowledge on top: discards are penalized by
8//! how much they could help the opponent's melds.
9
10use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
11use gin_rummy::{Card, Hand, Meld, Rank, Suit, best_melds, deadwood};
12
13/// The discard leaving the least deadwood, skipping the just-taken card
14///
15/// Ties prefer shedding higher pip values, then the lowest card in hand
16/// order.  This is the knowledge-free greedy shed shared with the Monte
17/// Carlo rollout policy.
18pub(crate) fn best_shed(hand: Hand, taken: Option<Card>) -> (Card, u8) {
19    hand.iter()
20        .filter(|&card| Some(card) != taken)
21        .map(|card| (card, deadwood(hand - card.into())))
22        .min_by_key(|&(card, rest)| (rest, u8::MAX - card.rank.deadwood()))
23        .expect("a hand with a draw always has a legal discard")
24}
25
26/// Whether taking `top` strictly lowers deadwood after the best legal shed
27/// (which may not be `top` itself)
28pub(crate) fn improves(hand: Hand, top: Card) -> bool {
29    let with = hand | top.into();
30    let (_, rest) = best_shed(with, Some(top));
31    rest < deadwood(hand)
32}
33
34/// The greedy layoff: the highest-pip own deadwood card that extends a
35/// spread meld, with the target meld's index
36///
37/// Restricted to deadwood cards of an optimal arrangement — laying off a
38/// melded card could *increase* final deadwood, since the defender's
39/// remainder is melded optimally at settlement.  Shared with the Monte
40/// Carlo rollout policy.
41pub(crate) fn greedy_layoff(
42    hand: Hand,
43    spread: impl Iterator<Item = Meld>,
44) -> Option<(Card, usize)> {
45    let dead = best_melds(hand).deadwood_cards();
46    spread
47        .enumerate()
48        .flat_map(|(index, meld)| {
49            dead.iter()
50                .filter(move |&card| meld.extended(card).is_some())
51                .map(move |card| (card, index))
52        })
53        .max_by_key(|&(card, _)| card.rank.deadwood())
54}
55
56/// Tuning knobs for [`HeuristicBot`]
57///
58/// Like [`gin_rummy::Rules`], the struct is non-exhaustive: start from
59/// [`HeuristicConfig::default`] and adjust fields.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[non_exhaustive]
62pub struct HeuristicConfig {
63    /// Knock whenever the residual deadwood is at most
64    /// `min(knock_limit, knock_threshold)`
65    ///
66    /// The default of 4 holds out past the first legal knock — banking a
67    /// small knock every hand loses a game to the gin and undercut bonuses.
68    /// Raise it toward the knock limit to knock as soon as the rules allow,
69    /// or lower it to hunt gin.  [`score_awareness`](Self::score_awareness)
70    /// bends this threshold by the game score at play time.
71    pub knock_threshold: u8,
72    /// Weight of discard safety against the opponent's revealed cards
73    ///
74    /// Zero ignores the opponent entirely, reproducing the pure greedy
75    /// player.  The default is 1.
76    pub safety_weight: u8,
77    /// How strongly the game score shifts the knock threshold
78    ///
79    /// Points of threshold shift per unit of `game_margin / (game_target −
80    /// leader_score)`, where `leader_score` is the higher of the two
81    /// running totals.  Ahead the effective threshold rises toward the
82    /// legal limit (bank the lead by knocking early); behind it falls
83    /// toward zero (hold out for a gin that swings the deficit).  The
84    /// denominator is the leader's distance to the winning line, not the
85    /// full target, so the same lead bends the threshold ever harder as
86    /// the game nears its end: a modest early-game nudge becomes a knock
87    /// at any deadwood once the front-runner is a hand from winning.  Zero
88    /// ignores the score, so a round played outside a game is unaffected.
89    pub score_awareness: u8,
90}
91
92impl Default for HeuristicConfig {
93    fn default() -> Self {
94        // Tuned by whole-game self-play (see `examples/tune.rs`): holding
95        // past the first legal knock and shifting the knock threshold by
96        // the leader's distance to the winning line lift the heuristic's
97        // game-win rate to ~50% against the Monte Carlo bot, up from ~42%
98        // for score-blind play.
99        Self {
100            knock_threshold: 4,
101            safety_weight: 1,
102            score_awareness: 40,
103        }
104    }
105}
106
107/// A deterministic knowledge-based player
108///
109/// Fast enough for tournaments at any scale: every decision costs a few
110/// deadwood-solver calls, each microseconds.
111#[derive(Debug, Clone, Copy, Default)]
112pub struct HeuristicBot {
113    config: HeuristicConfig,
114}
115
116impl HeuristicBot {
117    /// A bot with the default configuration
118    #[must_use]
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// A bot with custom tuning
124    #[must_use]
125    pub const fn with_config(config: HeuristicConfig) -> Self {
126        Self { config }
127    }
128
129    /// The cards that could meld with `card`: its rank in the other suits,
130    /// and its suit within two ranks
131    fn adjoiners(card: Card) -> Hand {
132        let mut mask = Hand::EMPTY;
133        for suit in Suit::ASC {
134            if suit != card.suit {
135                mask.insert(Card {
136                    suit,
137                    rank: card.rank,
138                });
139            }
140        }
141        let pivot = card.rank.get();
142        for rank in pivot.saturating_sub(2).max(1)..=(pivot + 2).min(13) {
143            if rank != pivot {
144                mask.insert(Card {
145                    suit: card.suit,
146                    rank: Rank::new(rank),
147                });
148            }
149        }
150        mask
151    }
152
153    /// How much discarding `card` could help the opponent
154    ///
155    /// Adjoining cards the opponent is known to hold weigh double, unseen
156    /// adjoiners weigh single, and adjoiners the opponent shed or declined
157    /// count against — they signal disinterest, and shed adjoiners are
158    /// physically unavailable.
159    fn danger(view: &View<'_>, card: Card) -> i32 {
160        let mask = Self::adjoiners(card);
161        let known = (mask & view.opponent_known()).len() as i32;
162        let unseen = (mask & view.unseen()).len() as i32;
163        let cold = (mask & (view.opponent_shed() | view.opponent_passed())).len() as i32;
164        2 * known + unseen - 2 * cold
165    }
166
167    /// The knock threshold in effect, shifted by the game score
168    ///
169    /// The neutral base is `knock_threshold`; `score_awareness` scales the
170    /// shift by `game_margin / (game_target − leader_score)`.  Ahead the
171    /// threshold rises (knock sooner), behind it falls toward zero (hold
172    /// out for gin); dividing by the leader's distance to the line, not
173    /// the full target, makes the same lead matter more late in the game.
174    fn knock_threshold(&self, view: &View<'_>) -> u8 {
175        let base = i32::from(self.config.knock_threshold);
176        let [mine, theirs] = view.game_scores().map(i32::from);
177        // The leader's distance to the winning line: the score bias grows
178        // as the game nears its end, not merely with the raw margin.
179        let remaining = i32::from(view.rules().game_target) - mine.max(theirs);
180        let bias = i32::from(self.config.score_awareness) * (mine - theirs) / remaining.max(1);
181        (base + bias).clamp(0, i32::from(u8::MAX)) as u8
182    }
183
184    /// The shed minimizing `(residual deadwood, weighted danger, -pips)`
185    fn choose_shed(&self, view: &View<'_>) -> (Card, u8) {
186        let hand = view.hand();
187        let taken = view.taken_discard();
188        let weight = i32::from(self.config.safety_weight);
189        hand.iter()
190            .filter(|&card| Some(card) != taken)
191            .map(|card| (card, deadwood(hand - card.into())))
192            .min_by_key(|&(card, rest)| {
193                (
194                    rest,
195                    weight * Self::danger(view, card),
196                    u8::MAX - card.rank.deadwood(),
197                )
198            })
199            .expect("an 11-card hand always has a legal discard")
200    }
201}
202
203impl Strategy for HeuristicBot {
204    fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
205        let top = view.upcard().expect("the upcard offer has an upcard");
206        if improves(view.hand(), top) {
207            UpcardAction::Take
208        } else {
209            UpcardAction::Pass
210        }
211    }
212
213    fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
214        let top = view.upcard().expect("the pile is never empty on a draw");
215        if improves(view.hand(), top) {
216            DrawAction::TakeDiscard
217        } else {
218            DrawAction::Stock
219        }
220    }
221
222    fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
223        let hand = view.hand();
224        if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
225            return TurnAction::BigGin(best_melds(hand));
226        }
227
228        let (card, rest) = self.choose_shed(view);
229        if rest <= view.knock_limit().min(self.knock_threshold(view)) {
230            TurnAction::Knock {
231                discard: card,
232                melds: best_melds(hand - card.into()),
233            }
234        } else {
235            TurnAction::Discard(card)
236        }
237    }
238
239    fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
240        greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
241    }
242
243    fn name(&self) -> &str {
244        "greedy"
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use gin_rummy::Meld;
252
253    fn card(text: &str) -> Card {
254        text.parse().expect("a valid card")
255    }
256
257    #[test]
258    fn best_shed_minimizes_deadwood_then_dumps_pips() {
259        // ♣A♣2♣3 ♦4♦5♦6 ♥7♥8♥9 + ♠K ♠5: shedding the king keeps 5 deadwood.
260        let hand: Hand = "A23.456.789.5K".parse().expect("a valid hand");
261        assert_eq!(best_shed(hand, None), (card("♠K"), 5));
262        // The king may not be shed if it was just taken; the five goes.
263        assert_eq!(best_shed(hand, Some(card("♠K"))), (card("♠5"), 10));
264    }
265
266    #[test]
267    fn improves_is_strict() {
268        let hand: Hand = "A2.456.789.5K".parse().expect("a valid hand");
269        // The ♣3 completes A-2-3: taking it sheds the king, 3+5=8 < 18.
270        assert!(improves(hand, card("♣3")));
271        // The ♦T helps nothing over drawing blind.
272        assert!(!improves(hand, card("♦T")));
273    }
274
275    #[test]
276    fn adjoiners_cover_sets_and_run_neighbors() {
277        let mask = HeuristicBot::adjoiners(card("♦7"));
278        for adjoining in ["♣7", "♥7", "♠7", "♦5", "♦6", "♦8", "♦9"] {
279            assert!(mask.contains(card(adjoining)), "{adjoining} adjoins ♦7");
280        }
281        assert_eq!(mask.len(), 7);
282        // Edges truncate: nothing below the ace.
283        assert_eq!(HeuristicBot::adjoiners(card("♣A")).len(), 5);
284    }
285
286    #[test]
287    fn greedy_layoff_extends_runs_but_never_breaks_melds() {
288        let spread = [
289            Meld::run(Suit::Clubs, Rank::new(5), Rank::new(7)),
290            Meld::set(Rank::new(9), Some(Suit::Spades)),
291        ];
292        // ♣8 extends the run.  The ♠9 would complete the nine-set, but it
293        // is melded into the defender's own ♠9-T-J-Q run and never offered.
294        let hand: Hand = "8...9TJQ".parse().expect("a valid hand");
295        assert_eq!(
296            greedy_layoff(hand, spread.iter().copied()),
297            Some((card("♣8"), 0)),
298        );
299
300        // A card inside the defender's own meld is not offered: laying
301        // off the ♥T would break T-J-Q into pure deadwood.
302        let melded: Hand = "..TJQ.".parse().expect("a valid hand");
303        let sets = [Meld::set(Rank::T, Some(Suit::Hearts))];
304        assert_eq!(greedy_layoff(melded, sets.iter().copied()), None);
305    }
306
307    #[test]
308    fn chained_layoffs_terminate() {
309        let mut spread = [Meld::run(Suit::Clubs, Rank::new(5), Rank::new(7))];
310        // ♣8 ♣9 are two loose cards: each extends the run once the other
311        // has stretched it.
312        let mut hand: Hand = "89...".parse().expect("a valid hand");
313        let mut laid = Vec::new();
314        while let Some((card, index)) = greedy_layoff(hand, spread.iter().copied()) {
315            spread[index] = spread[index].extended(card).expect("a legal extension");
316            hand.remove(card);
317            laid.push(card);
318        }
319        assert_eq!(laid, ["♣8", "♣9"].map(card));
320        assert!(hand.is_empty());
321    }
322
323    #[test]
324    fn score_awareness_shifts_the_knock_threshold() {
325        use crate::Table;
326        use gin_rummy::{Player, Round, Rules};
327
328        let deck: Vec<Card> = Hand::ALL.iter().collect();
329        let hands = [
330            deck.iter().step_by(2).take(10).copied().collect::<Hand>(),
331            deck.iter().skip(1).step_by(2).take(10).copied().collect(),
332        ];
333        let round = Round::from_deal(
334            Rules::default(),
335            Player::One,
336            hands,
337            deck[20],
338            deck[21..].to_vec(),
339        )
340        .expect("a partitioned deck");
341
342        // Base 6, target 100: with a 60-point margin and the leader 40
343        // points from the line the shift is 32 * 60 / 40 = 48, clamped
344        // into knock-limit range.
345        let bot = HeuristicBot::with_config(HeuristicConfig {
346            knock_threshold: 6,
347            score_awareness: 32,
348            ..HeuristicConfig::default()
349        });
350
351        let ahead = Table::new(round.clone()).scores([60, 0]);
352        let level = Table::new(round.clone());
353        let behind = Table::new(round.clone()).scores([0, 60]);
354
355        // Level score leaves the base untouched; ahead raises it, behind
356        // drops it toward zero (hold out for gin).
357        assert_eq!(bot.knock_threshold(&level.view(Player::One)), 6);
358        assert!(
359            bot.knock_threshold(&ahead.view(Player::One))
360                > bot.knock_threshold(&level.view(Player::One))
361        );
362        assert!(bot.knock_threshold(&behind.view(Player::One)) < 6);
363
364        // Proximity to the winning line, not the raw margin, drives the
365        // shift: the same 10-point lead bends the threshold far more when
366        // the leader is a hand from the target (denominator 10) than early
367        // in the game (denominator 90).  The old target-normalized formula
368        // scored these two equal.
369        let near_line = Table::new(round.clone()).scores([90, 80]);
370        let early = Table::new(round).scores([10, 0]);
371        assert!(
372            bot.knock_threshold(&near_line.view(Player::One))
373                > bot.knock_threshold(&early.view(Player::One))
374        );
375
376        // A score-blind bot ignores the margin entirely.
377        let blind = HeuristicBot::with_config(HeuristicConfig {
378            knock_threshold: 6,
379            score_awareness: 0,
380            ..HeuristicConfig::default()
381        });
382        assert_eq!(blind.knock_threshold(&ahead.view(Player::One)), 6);
383        assert_eq!(blind.knock_threshold(&behind.view(Player::One)), 6);
384    }
385}