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,
90}
91
92impl Default for HeuristicConfig {
93 fn default() -> Self {
94 Self {
100 knock_threshold: 4,
101 safety_weight: 1,
102 score_awareness: 40,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, Default)]
112pub struct HeuristicBot {
113 config: HeuristicConfig,
114}
115
116impl HeuristicBot {
117 #[must_use]
119 pub fn new() -> Self {
120 Self::default()
121 }
122
123 #[must_use]
125 pub const fn with_config(config: HeuristicConfig) -> Self {
126 Self { config }
127 }
128
129 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 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 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 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 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 let hand: Hand = "A23.456.789.5K".parse().expect("a valid hand");
261 assert_eq!(best_shed(hand, None), (card("♠K"), 5));
262 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 assert!(improves(hand, card("♣3")));
271 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 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 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 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 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 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 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 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 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}