1use crate::{DrawAction, Layoff, Strategy, TurnAction, UpcardAction, View};
11use gin_rummy::{Card, Hand, Meld, Rank, Suit, best_melds, deadwood};
12
13pub(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
26pub(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
34pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[non_exhaustive]
62pub struct HeuristicConfig {
63 pub knock_threshold: u8,
72 pub safety_weight: u8,
77 pub score_awareness: u8,
91}
92
93impl Default for HeuristicConfig {
94 fn default() -> Self {
95 Self {
101 knock_threshold: 4,
102 safety_weight: 1,
103 score_awareness: 40,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, Default)]
113pub struct HeuristicBot {
114 config: HeuristicConfig,
115}
116
117impl HeuristicBot {
118 #[must_use]
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 #[must_use]
126 pub const fn with_config(config: HeuristicConfig) -> Self {
127 Self { config }
128 }
129
130 fn adjoiners(card: Card) -> Hand {
133 let mut mask = Hand::EMPTY;
134 for suit in Suit::ASC {
135 if suit != card.suit {
136 mask.insert(Card {
137 suit,
138 rank: card.rank,
139 });
140 }
141 }
142 let pivot = card.rank.get();
143 for rank in pivot.saturating_sub(2).max(1)..=(pivot + 2).min(13) {
144 if rank != pivot {
145 mask.insert(Card {
146 suit: card.suit,
147 rank: Rank::new(rank),
148 });
149 }
150 }
151 mask
152 }
153
154 fn danger(view: &View<'_>, card: Card) -> i32 {
161 let mask = Self::adjoiners(card);
162 let known = (mask & view.opponent_known()).len() as i32;
163 let unseen = (mask & view.unseen()).len() as i32;
164 let cold = (mask & (view.opponent_shed() | view.opponent_passed())).len() as i32;
165 2 * known + unseen - 2 * cold
166 }
167
168 fn knock_threshold(&self, view: &View<'_>) -> u8 {
176 let base = i32::from(self.config.knock_threshold);
177 let [mine, theirs] = view.game_scores().map(i32::from);
178 let remaining = i32::from(view.rules().game_target) - mine.max(theirs);
181 let bias = i32::from(self.config.score_awareness) * (mine - theirs) / remaining.max(1);
182 (base + bias).clamp(0, i32::from(u8::MAX)) as u8
183 }
184
185 fn choose_shed(&self, view: &View<'_>) -> (Card, u8) {
187 let hand = view.hand();
188 let taken = view.taken_discard();
189 let weight = i32::from(self.config.safety_weight);
190 hand.iter()
191 .filter(|&card| Some(card) != taken)
192 .map(|card| (card, deadwood(hand - card.into())))
193 .min_by_key(|&(card, rest)| {
194 (
195 rest,
196 weight * Self::danger(view, card),
197 u8::MAX - card.rank.deadwood(),
198 )
199 })
200 .expect("an 11-card hand always has a legal discard")
201 }
202}
203
204impl Strategy for HeuristicBot {
205 fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
206 let top = view.upcard().expect("the upcard offer has an upcard");
207 if improves(view.hand(), top) {
208 UpcardAction::Take
209 } else {
210 UpcardAction::Pass
211 }
212 }
213
214 fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
215 let top = view.upcard().expect("the pile is never empty on a draw");
216 if improves(view.hand(), top) {
217 DrawAction::TakeDiscard
218 } else {
219 DrawAction::Stock
220 }
221 }
222
223 fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
224 let hand = view.hand();
225 if deadwood(hand) == 0 && view.rules().big_gin_bonus.is_some() {
226 return TurnAction::BigGin(best_melds(hand));
227 }
228
229 let (card, rest) = self.choose_shed(view);
230 if rest <= view.knock_limit().min(self.knock_threshold(view)) {
231 TurnAction::Knock {
232 discard: card,
233 melds: best_melds(hand - card.into()),
234 }
235 } else {
236 TurnAction::Discard(card)
237 }
238 }
239
240 fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
241 greedy_layoff(view.hand(), view.spread()).map(|(card, meld)| Layoff { card, meld })
242 }
243
244 fn name(&self) -> &str {
245 "greedy"
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use gin_rummy::Meld;
253
254 fn card(text: &str) -> Card {
255 text.parse().expect("a valid card")
256 }
257
258 #[test]
259 fn best_shed_minimizes_deadwood_then_dumps_pips() {
260 let hand: Hand = "A23.456.789.5K".parse().expect("a valid hand");
262 assert_eq!(best_shed(hand, None), (card("♠K"), 5));
263 assert_eq!(best_shed(hand, Some(card("♠K"))), (card("♠5"), 10));
265 }
266
267 #[test]
268 fn improves_is_strict() {
269 let hand: Hand = "A2.456.789.5K".parse().expect("a valid hand");
270 assert!(improves(hand, card("♣3")));
272 assert!(!improves(hand, card("♦T")));
274 }
275
276 #[test]
277 fn adjoiners_cover_sets_and_run_neighbors() {
278 let mask = HeuristicBot::adjoiners(card("♦7"));
279 for adjoining in ["♣7", "♥7", "♠7", "♦5", "♦6", "♦8", "♦9"] {
280 assert!(mask.contains(card(adjoining)), "{adjoining} adjoins ♦7");
281 }
282 assert_eq!(mask.len(), 7);
283 assert_eq!(HeuristicBot::adjoiners(card("♣A")).len(), 5);
285 }
286
287 #[test]
288 fn greedy_layoff_extends_runs_but_never_breaks_melds() {
289 let spread = [
290 Meld::run(Suit::Clubs, Rank::new(5), Rank::new(7)),
291 Meld::set(Rank::new(9), Some(Suit::Spades)),
292 ];
293 let hand: Hand = "8...9TJQ".parse().expect("a valid hand");
296 assert_eq!(
297 greedy_layoff(hand, spread.iter().copied()),
298 Some((card("♣8"), 0)),
299 );
300
301 let melded: Hand = "..TJQ.".parse().expect("a valid hand");
304 let sets = [Meld::set(Rank::T, Some(Suit::Hearts))];
305 assert_eq!(greedy_layoff(melded, sets.iter().copied()), None);
306 }
307
308 #[test]
309 fn chained_layoffs_terminate() {
310 let mut spread = [Meld::run(Suit::Clubs, Rank::new(5), Rank::new(7))];
311 let mut hand: Hand = "89...".parse().expect("a valid hand");
314 let mut laid = Vec::new();
315 while let Some((card, index)) = greedy_layoff(hand, spread.iter().copied()) {
316 spread[index] = spread[index].extended(card).expect("a legal extension");
317 hand.remove(card);
318 laid.push(card);
319 }
320 assert_eq!(laid, ["♣8", "♣9"].map(card));
321 assert!(hand.is_empty());
322 }
323
324 #[test]
325 fn score_awareness_shifts_the_knock_threshold() {
326 use crate::Table;
327 use gin_rummy::{Player, Round, Rules};
328
329 let deck: Vec<Card> = Hand::ALL.iter().collect();
330 let hands = [
331 deck.iter().step_by(2).take(10).copied().collect::<Hand>(),
332 deck.iter().skip(1).step_by(2).take(10).copied().collect(),
333 ];
334 let round = Round::from_deal(
335 Rules::default(),
336 Player::One,
337 hands,
338 deck[20],
339 deck[21..].to_vec(),
340 )
341 .expect("a partitioned deck");
342
343 let bot = HeuristicBot::with_config(HeuristicConfig {
347 knock_threshold: 6,
348 score_awareness: 32,
349 ..HeuristicConfig::default()
350 });
351
352 let ahead = Table::new(round.clone()).scores([60, 0]);
353 let level = Table::new(round.clone());
354 let behind = Table::new(round.clone()).scores([0, 60]);
355
356 assert_eq!(bot.knock_threshold(&level.view(Player::One)), 6);
359 assert!(
360 bot.knock_threshold(&ahead.view(Player::One))
361 > bot.knock_threshold(&level.view(Player::One))
362 );
363 assert!(bot.knock_threshold(&behind.view(Player::One)) < 6);
364
365 let near_line = Table::new(round.clone()).scores([90, 80]);
371 let early = Table::new(round).scores([10, 0]);
372 assert!(
373 bot.knock_threshold(&near_line.view(Player::One))
374 > bot.knock_threshold(&early.view(Player::One))
375 );
376
377 let blind = HeuristicBot::with_config(HeuristicConfig {
379 knock_threshold: 6,
380 score_awareness: 0,
381 ..HeuristicConfig::default()
382 });
383 assert_eq!(blind.knock_threshold(&ahead.view(Player::One)), 6);
384 assert_eq!(blind.knock_threshold(&behind.view(Player::One)), 6);
385 }
386}