gin-rummy-engine 0.2.0

Gin rummy bots: heuristic and Monte Carlo strategies over an information-hygienic view
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
//! Play gin rummy against a bot in the terminal.
//!
//! ```console
//! cargo run --release --example play
//! cargo run --release --example play -- --bot mc:64 --rules classic --seed 7
//! ```
//!
//! You are seated as Player One and see only legal information: your hand,
//! the pile, the stock count, and what the bot has revealed.  Cards parse
//! leniently: `S10`, `♠10`, `st`, and `♠T` all name the ten of spades, and a
//! lone rank or suit (`5`, `♠`) resolves when your hand holds just one such
//! card.  To move, type a card to discard it or `knock` (or `n`) to knock
//! the smallest deadwood.  A fully-melded hand declares big gin on its own.
//! Type `view` (or `sort`, or `v`) at any prompt to flip the loose-deadwood
//! ordering between by-rank and by-suit.  Type `hint` (or `h`, except on the
//! discard prompt where a lone `h` names a heart) for the Monte Carlo
//! solver's read on the current decision: every candidate move with its
//! chance to win the game and its expected points.

use anyhow::{Context as _, Result, bail};
use gin_rummy::{Card, Hand, Phase, Player, Rank, RoundResult, Rules, Suit, best_melds, deadwood};
use gin_rummy_engine::{
    Assessment, DrawAction, EaaiSimpleBot, EngineError, HeuristicBot, HeuristicConfig, Layoff,
    MonteCarloBot, Strategy, Table, TurnAction, UpcardAction, View,
};
use rand::rngs::StdRng;
use rand::{RngExt as _, SeedableRng};
use std::io::Write as _;

const HUMAN: Player = Player::One;

fn parse_args() -> Result<(String, Rules, Option<u64>)> {
    let mut bot = "mc".to_string();
    let mut rules = Rules::default();
    let mut seed = None;
    let mut args = std::env::args().skip(1);
    while let Some(flag) = args.next() {
        let mut value = || args.next().with_context(|| format!("{flag} needs a value"));
        match flag.as_str() {
            "--bot" => bot = value()?,
            "--seed" => seed = Some(value()?.parse()?),
            "--rules" => {
                rules = match value()?.as_str() {
                    "modern" => Rules::new(),
                    "classic" => Rules::classic(),
                    "palace" => Rules::palace(),
                    other => bail!("unknown rules preset {other:?}"),
                }
            }
            other => bail!("unknown flag {other:?} (--bot/--seed/--rules)"),
        }
    }
    Ok((bot, rules, seed))
}

fn make_bot(spec: &str, rng: &mut StdRng) -> Result<Box<dyn Strategy>> {
    let (kind, samples) = match spec.split_once(':') {
        Some((kind, samples)) => (kind, Some(samples.parse::<u32>()?)),
        None => (spec, None),
    };
    match kind {
        // A newcomer: knocks at the first legal chance, and is blind both to
        // the game score and to what a discard hands the opponent.
        "newbie" => {
            let mut config = HeuristicConfig::default();
            config.knock_threshold = 10;
            config.safety_weight = 0;
            config.score_awareness = 0;
            Ok(Box::new(HeuristicBot::with_config(config)))
        }
        "greedy" => Ok(Box::new(HeuristicBot::new())),
        // The EAAI-2021 challenge baseline, the cross-engine yardstick.
        "eaai" => Ok(Box::new(EaaiSimpleBot::new(StdRng::seed_from_u64(
            rng.random(),
        )))),
        "mc" => Ok(Box::new(
            MonteCarloBot::new(StdRng::seed_from_u64(rng.random())).samples(samples.unwrap_or(64)),
        )),
        other => bail!("unknown bot {other:?} (newbie | greedy | eaai | mc[:samples])"),
    }
}

/// Read one trimmed lowercase line, or `None` on end of input
fn read_command(prompt: &str) -> Option<String> {
    print!("{prompt} ");
    std::io::stdout().flush().ok()?;
    let mut line = String::new();
    match std::io::stdin().read_line(&mut line) {
        // EOF (Ctrl-D) leaves the cursor mid-prompt; close the line first.
        Ok(0) | Err(_) => {
            println!();
            None
        }
        Ok(_) => Some(line.trim().to_lowercase()),
    }
}

/// Resolve user text to a card.  A full name (`S10`, `♠T`) is taken as
/// written; a lone rank (`5`) or suit (`♠`, `s`) resolves only when the hand
/// holds exactly one matching card, so the common case needs no full name.
fn resolve_card(text: &str, hand: Hand) -> Option<Card> {
    if let Ok(card) = text.parse::<Card>() {
        return Some(card);
    }
    let matches: Vec<Card> = if let Ok(rank) = text.parse::<Rank>() {
        hand.iter().filter(|c| c.rank == rank).collect()
    } else if let Ok(suit) = text.parse::<Suit>() {
        hand.iter().filter(|c| c.suit == suit).collect()
    } else {
        Vec::new()
    };
    match matches.as_slice() {
        [card] => Some(*card),
        [] => {
            println!("Cannot read {text:?} as a card; try forms like S10 or ♠T.");
            None
        }
        many => {
            let names: Vec<String> = many.iter().map(Card::to_string).collect();
            println!("{text:?} matches several cards: {}.", names.join(" "));
            None
        }
    }
}

/// The largest-pip deadwood card, skipping the just-taken card: shedding it
/// leaves the smallest residual to knock on, so it is the card to auto-knock.
///
// ponytail: mirrors the crate-private greedy `best_shed`, inlined because
// examples see only the public API.
fn best_shed(hand: Hand, taken: Option<Card>) -> Card {
    hand.iter()
        .filter(|&c| Some(c) != taken)
        .min_by_key(|&c| (deadwood(hand - c.into()), u8::MAX - c.rank.deadwood()))
        .expect("a full hand always has a legal discard")
}

/// Wrap a card's name in ANSI reverse video wherever it appears in `text`,
/// so the just-drawn card is easy to pick out of the hand.  A card's name is
/// unique within a hand, so the plain substring replace never mis-hits.
fn emphasize(text: &str, card: Card) -> String {
    let name = card.to_string();
    text.replace(&name, &format!("\x1b[7m{name}\x1b[0m"))
}

/// A card set as a spaced list, friendlier than the dotted suit groups
/// for the short sets shown here
fn list(cards: gin_rummy::Hand) -> String {
    cards
        .iter()
        .map(|card| card.to_string())
        .collect::<Vec<_>>()
        .join(" ")
}

/// A hand as a spaced list.  `Hand::iter` already runs by suit (clubs first,
/// ascending); the default reorders by rank across suits, which is how most
/// players scan a hand.
fn sorted(cards: gin_rummy::Hand, by_suit: bool) -> String {
    let mut cards: Vec<Card> = cards.iter().collect();
    if !by_suit {
        cards.sort_by_key(|card| (card.rank, card.suit));
    }
    cards
        .iter()
        .map(|card| card.to_string())
        .collect::<Vec<_>>()
        .join(" ")
}

/// The player's own hand on one line: the best meld arrangement, then the
/// loose deadwood ordered by rank (the default) or by suit.  `drawn`, if set,
/// is the just-drawn card, highlighted so the human can track it.
fn print_hand(view: &View<'_>, by_suit: bool, drawn: Option<Card>) {
    let melds = view.best_melds();
    let arranged = melds
        .iter()
        .map(|m| m.to_string())
        .collect::<Vec<_>>()
        .join(" ");
    let loose = melds.deadwood_cards();
    // Mirror `Melds` Display: the `|` only separates two non-empty sides.
    let sep = if !arranged.is_empty() && !loose.is_empty() {
        " | "
    } else {
        ""
    };
    let line = format!(
        "Your hand: {arranged}{sep}{} ({} deadwood)",
        sorted(loose, by_suit),
        melds.deadwood(),
    );
    match drawn {
        Some(card) => println!("{}", emphasize(&line, card)),
        None => println!("{line}"),
    }
}

/// Show everything the human may see before a decision
fn show_position(view: &View<'_>, by_suit: bool, drawn: Option<Card>) {
    println!();
    print_hand(view, by_suit, drawn);
    match view.upcard() {
        Some(top) => println!(
            "Pile top: {top} (pile of {}), stock: {} cards",
            view.discard_pile().len(),
            view.stock_len(),
        ),
        None => println!("Pile empty, stock: {} cards", view.stock_len()),
    }
    if !view.opponent_known().is_empty() {
        println!("Bot is holding: {}", list(view.opponent_known()));
    }
}

/// Print the Monte Carlo solver's read on the current decision: each
/// candidate with its win-the-game equity and expected round points, ranked
/// by equity and the bot's own pick highlighted.  A display-only command.
fn print_hints(rows: &[Assessment]) {
    if rows.is_empty() {
        println!("Nothing to weigh here — the move is forced.");
        return;
    }
    println!("Solver — equity is your chance to win the game, EV your expected round points:");
    // The bot deviates from its greedy default only on a statistically clear
    // equity gain, so its pick can sit below a higher point-estimate that is
    // really just sampling noise.  Say so only when it actually happens.
    if !rows[0].recommended {
        println!("(Equities within the sampling noise are ties; the bot holds its default move.)");
    }
    for row in rows {
        let mark = if row.recommended { "" } else { " " };
        let line = format!(
            "  {mark} {:<15} {:>5.1}%   EV {:+5.1}",
            row.action,
            row.equity * 100.0,
            row.ev,
        );
        // Reverse-video the pick, the same accent the drawn card uses.
        if row.recommended {
            println!("\x1b[7m{line}\x1b[0m");
        } else {
            println!("{line}");
        }
    }
}

/// Interactive human seat.  `by_suit` toggles the deadwood ordering between
/// by-rank (the default) and by-suit via the `view`/`sort`/`v` command.
struct HumanCli {
    by_suit: bool,
    /// The hand just before the current draw, so `drawn` can name the card
    /// the draw added.
    predraw: Option<Hand>,
    /// A private Monte Carlo bot that answers the `hint` command, seeded
    /// apart from the deal so it never perturbs the game's own randomness.
    hinter: MonteCarloBot<StdRng>,
}

impl HumanCli {
    /// Flip the hand ordering and reprint it; a display-only command.
    fn toggle_sort(&mut self, view: &View<'_>) {
        self.by_suit = !self.by_suit;
        print_hand(view, self.by_suit, self.drawn(view));
    }

    /// The card added since the last draw decision, if any — the one to
    /// highlight so the human can track what they just drew.  Empty (`None`)
    /// during the draw itself, when the snapshot equals the current hand.
    fn drawn(&self, view: &View<'_>) -> Option<Card> {
        self.predraw
            .and_then(|before| (view.hand() - before).iter().next())
    }
}

impl Strategy for HumanCli {
    fn offer_upcard(&mut self, view: &View<'_>) -> UpcardAction {
        self.predraw = Some(view.hand());
        show_position(view, self.by_suit, self.drawn(view));
        loop {
            match read_command(
                "Take the upcard or pass? [take/pass/\x1b[7mh\x1b[0mint/\x1b[7mv\x1b[0miew]",
            )
            .unwrap_or_else(|| "quit".into())
            .as_str()
            {
                "take" | "t" => return UpcardAction::Take,
                "pass" | "p" => return UpcardAction::Pass,
                "hint" | "h" => print_hints(&self.hinter.assess(view)),
                "sort" | "view" | "v" => self.toggle_sort(view),
                "quit" => std::process::exit(0),
                _ => println!("Commands: take, pass, hint, sort/view, quit."),
            }
        }
    }

    fn choose_draw(&mut self, view: &View<'_>) -> DrawAction {
        self.predraw = Some(view.hand());
        show_position(view, self.by_suit, self.drawn(view));
        loop {
            match read_command(
                "Draw from the stock or take the pile top? [draw/take/\x1b[7mh\x1b[0mint/\x1b[7mv\x1b[0miew]",
            )
            .unwrap_or_else(|| "quit".into())
            .as_str()
            {
                "draw" | "d" => return DrawAction::Stock,
                "take" | "t" => return DrawAction::TakeDiscard,
                "hint" | "h" => print_hints(&self.hinter.assess(view)),
                "sort" | "view" | "v" => self.toggle_sort(view),
                "quit" => std::process::exit(0),
                _ => println!("Commands: draw, take, hint, sort/view, quit."),
            }
        }
    }

    fn play_turn(&mut self, view: &View<'_>) -> TurnAction {
        show_position(view, self.by_suit, self.drawn(view));
        // Big gin — all eleven cards melded — is strictly dominant, so
        // declare it without asking, as the bots do.  Rulesets without a
        // big-gin bonus fall through to a normal gin knock.
        if view.deadwood() == 0 && view.rules().big_gin_bonus.is_some() {
            println!("You have BIG GIN!");
            return TurnAction::BigGin(view.best_melds());
        }
        if let Some(card) = view.taken_discard() {
            println!("(The just-taken {card} may not be shed this turn.)");
        }
        loop {
            match read_command(
                "Your move [<card> to discard / k\x1b[7mn\x1b[0mock / hint / \x1b[7mv\x1b[0miew]:",
            )
            .unwrap_or_else(|| "quit".into())
            .as_str()
            {
                // Knock is `n` (a bare `k` names your only king).  The shed is
                // forced, not chosen: it goes face down, so it never reaches
                // the opponent, leaving the knocker's own deadwood as the only
                // objective — and `best_shed` minimizes it.
                "knock" | "n" => {
                    let discard = best_shed(view.hand(), view.taken_discard());
                    return TurnAction::Knock {
                        discard,
                        melds: best_melds(view.hand() - discard.into()),
                    };
                }
                // Only the full word: a bare `h` names your lone heart.
                "hint" => print_hints(&self.hinter.assess(view)),
                "sort" | "view" | "v" => self.toggle_sort(view),
                // Quit has no `q` shortcut: a bare `q` names your only queen.
                "quit" => std::process::exit(0),
                // A bare card is a discard; no `discard` command needed.
                text => {
                    if let Some(card) = resolve_card(text, view.hand()) {
                        return TurnAction::Discard(card);
                    }
                }
            }
        }
    }

    fn choose_layoff(&mut self, view: &View<'_>) -> Option<Layoff> {
        // ponytail: reuse the bot's greedy layoff; the human need not choose.
        let layoff = HeuristicBot::new().choose_layoff(view);
        if let Some(l) = &layoff {
            println!("You lay off {}.", l.card);
        }
        layoff
    }

    fn name(&self) -> &str {
        "you"
    }
}

/// Narrate what the bot just did from public information alone
fn narrate_bot(before_phase: Phase, before_top: Option<Card>, before_pile: usize, table: &Table) {
    let round = table.round();
    match before_phase {
        Phase::Upcard => {
            if round.phase() == Phase::Discard {
                println!(
                    "Bot takes the {}.",
                    before_top.expect("a take implies a pile top"),
                );
            } else {
                println!("Bot passes.");
            }
        }
        Phase::Draw => {
            if round.discard_pile().len() < before_pile {
                println!(
                    "Bot takes the {}.",
                    before_top.expect("a take implies a pile top"),
                );
            } else {
                println!("Bot draws from the stock.");
            }
        }
        Phase::Discard => {
            let top = round.discard_pile().last().copied();
            match round.result() {
                Some(RoundResult::BigGin { .. }) => println!("Bot declares BIG GIN!"),
                Some(RoundResult::Gin { .. }) => {
                    println!(
                        "Bot discards {} and goes gin!",
                        top.expect("gin sheds a discard"),
                    );
                }
                _ if round.knocker() == Some(Player::Two) => {
                    println!(
                        "Bot discards {} and knocks.",
                        top.expect("a knock sheds a discard"),
                    );
                    let spread = table
                        .view(HUMAN)
                        .spread()
                        .map(|meld| meld.to_string())
                        .collect::<Vec<_>>()
                        .join(" ");
                    println!("Bot spreads: {spread}");
                }
                _ => println!("Bot discards {}.", top.expect("a turn ends with a discard")),
            }
        }
        Phase::Layoff => {
            let laid = round.laid_off();
            if round.phase() == Phase::Layoff {
                println!("Bot lays off (so far: {}).", list(laid));
            } else if laid.is_empty() {
                println!("Bot lays nothing off.");
            } else {
                println!("Bot lays off {}.", list(laid));
            }
        }
        Phase::Finished => {}
    }
}

/// Narrate a finished round.  Bonus'd results spell out the score as
/// `earned + bonus = total` — the opponent's deadwood (or the undercut margin)
/// plus the fixed bonus — so the printed number matches the score change.  The
/// bonus is `points − earned` rather than a rules field so it can never drift
/// from the real pricing.
fn describe(result: RoundResult, rules: &Rules) -> String {
    let pts = result.points(rules);
    match result {
        RoundResult::Dead => "dead hand, nobody scores".into(),
        RoundResult::Knock { winner, margin } => format!("{} knock for {margin}", who(winner)),
        RoundResult::Undercut { winner, margin } => {
            format!(
                "{} undercut ({margin} + {} = {pts})",
                who(winner),
                pts - u16::from(margin)
            )
        }
        RoundResult::Gin { winner, deadwood } => {
            format!(
                "{} gin ({deadwood} + {} = {pts})",
                who(winner),
                pts - u16::from(deadwood)
            )
        }
        RoundResult::BigGin { winner, deadwood } => {
            format!(
                "{} BIG gin ({deadwood} + {} = {pts})",
                who(winner),
                pts - u16::from(deadwood)
            )
        }
        _ => format!("{result:?}"),
    }
}

fn who(player: Player) -> &'static str {
    if player == HUMAN { "You" } else { "Bot" }
}

fn main() -> Result<()> {
    let (spec, rules, seed) = parse_args()?;
    let mut rng = match seed {
        Some(seed) => StdRng::seed_from_u64(seed),
        None => StdRng::from_rng(&mut rand::rng()),
    };
    let mut bot = make_bot(&spec, &mut rng)?;
    let mut human = HumanCli {
        by_suit: false,
        predraw: None,
        // A separate seed stream, so pressing `hint` never disturbs the deal.
        hinter: MonteCarloBot::new(StdRng::seed_from_u64(seed.unwrap_or(0))),
    };
    let mut game = gin_rummy::Game::new(rules, HUMAN);

    println!("Gin rummy to {} points — you vs {spec}.", rules.game_target);

    for number in 1.. {
        if game.is_over() {
            break;
        }
        println!();
        println!(
            "=== Round {number} ({} deal) ===",
            if game.next_dealer() == HUMAN {
                "your"
            } else {
                "bot's"
            },
        );
        let scores = [game.score(Player::One), game.score(Player::Two)];
        let mut table = Table::new(game.deal(&mut rng)).scores(scores);

        while let Some(seat) = table.turn() {
            if seat == HUMAN {
                if let Err(EngineError::IllegalAction { source, .. }) = table.step(&mut human) {
                    println!("Illegal: {source}");
                }
            } else {
                let phase = table.round().phase();
                let top = table.round().discard_pile().last().copied();
                let pile = table.round().discard_pile().len();
                table
                    .step(&mut *bot)
                    .expect("the bot always chooses legal actions");
                narrate_bot(phase, top, pile, &table);
            }
        }

        let result = table.round().result().expect("a turnless round finished");
        game.record(result)?;
        println!();
        println!("Round {number}: {}", describe(result, &rules));
        println!(
            "Score — you {} : {} bot",
            game.score(HUMAN),
            game.score(HUMAN.opponent()),
        );
    }

    let settled = game.final_score().expect("the game just ended");
    println!();
    println!(
        "{} the game, {} : {}{}",
        if settled.winner == HUMAN {
            "You win"
        } else {
            "Bot wins"
        },
        settled.totals[settled.winner as usize],
        settled.totals[settled.winner.opponent() as usize],
        if settled.shutout {
            " — a shutout!"
        } else {
            ""
        },
    );
    Ok(())
}