Skip to main content

gin_rummy_engine/
driver.rs

1//! The [`Table`] driver: applies strategy decisions to a round
2//!
3//! The driver owns the [`Round`] and both seats' [`Knowledge`], asks the
4//! acting strategy for one decision at a time, validates it, applies it, and
5//! keeps every observer's knowledge current — so information hygiene holds
6//! by construction for any [`Strategy`].
7
8use crate::view::Knowledge;
9use crate::{DrawAction, Strategy, TurnAction, UpcardAction, View};
10use gin_rummy::round::RoundError;
11use gin_rummy::{Phase, Player, Round, RoundResult};
12use thiserror::Error;
13
14/// An error while driving a round
15#[derive(Debug, Error)]
16#[non_exhaustive]
17pub enum EngineError {
18    /// A strategy chose an action the round rejected
19    #[error("{seat} chose an illegal action")]
20    IllegalAction {
21        /// The seat whose strategy misbehaved
22        seat: Player,
23        /// The round's rejection
24        #[source]
25        source: RoundError,
26    },
27}
28
29/// A round in progress, with per-seat knowledge tracking
30///
31/// [`Table::round`] intentionally exposes the full position — user
32/// interfaces and loggers need it — but strategies only ever receive the
33/// [`View`]s handed to them by [`Table::step`].
34#[derive(Debug, Clone)]
35pub struct Table {
36    round: Round,
37    knowledge: [Knowledge; 2],
38    scores: [u16; 2],
39}
40
41impl Table {
42    /// Wrap a freshly dealt round
43    ///
44    /// The round must still be in the upcard phase: knowledge accumulated
45    /// during earlier play cannot be reconstructed from a mid-game round.
46    ///
47    /// The game score defaults to level (both seats at zero), so a
48    /// standalone round reports [`game_scores`](View::game_scores) of
49    /// `[0, 0]` and a [`game_margin`](View::game_margin) of zero.  Set it
50    /// with [`Table::scores`] when driving a round within a game.
51    #[must_use]
52    pub fn new(round: Round) -> Self {
53        debug_assert_eq!(round.phase(), Phase::Upcard);
54        Self {
55            round,
56            knowledge: [Knowledge::default(); 2],
57            scores: [0; 2],
58        }
59    }
60
61    /// Set the running game totals, indexed by [`Player`]
62    ///
63    /// Each seat's [`View`] then reports the seat-relative
64    /// [`game_scores`](View::game_scores) and
65    /// [`game_margin`](View::game_margin), letting score-aware strategies
66    /// bank a lead or gamble from behind.
67    #[must_use]
68    pub const fn scores(mut self, scores: [u16; 2]) -> Self {
69        self.scores = scores;
70        self
71    }
72
73    /// Shuffle and deal a fresh round onto a new table
74    #[cfg(feature = "rand")]
75    #[must_use]
76    pub fn deal(
77        rules: gin_rummy::Rules,
78        dealer: Player,
79        rng: &mut (impl rand::Rng + ?Sized),
80    ) -> Self {
81        Self::new(Round::deal(rules, dealer, rng))
82    }
83
84    /// The underlying round, fully visible
85    #[must_use]
86    pub const fn round(&self) -> &Round {
87        &self.round
88    }
89
90    /// The player to act, or `None` when the round is finished
91    #[must_use]
92    pub const fn turn(&self) -> Option<Player> {
93        self.round.turn()
94    }
95
96    /// The legally visible information for one seat
97    #[must_use]
98    pub const fn view(&self, seat: Player) -> View<'_> {
99        let scores = [
100            self.scores[seat as usize],
101            self.scores[seat.opponent() as usize],
102        ];
103        View::new(&self.round, seat, &self.knowledge[seat as usize], scores)
104    }
105
106    /// Ask `strategy` — which must belong to the seat to act — for one
107    /// decision and apply it
108    ///
109    /// Returns the result once the round finishes, `None` while it
110    /// continues.  On the forced stock draw after both players pass the
111    /// upcard, the strategy is not consulted; the draw is applied directly.
112    ///
113    /// # Errors
114    ///
115    /// [`EngineError::IllegalAction`] when the round rejects the strategy's
116    /// choice.  The table is left unchanged, so an interactive caller may
117    /// retry the same decision.
118    pub fn step(
119        &mut self,
120        strategy: &mut dyn Strategy,
121    ) -> Result<Option<RoundResult>, EngineError> {
122        let Some(seat) = self.round.turn() else {
123            return Ok(self.round.result());
124        };
125        let reject = |source| EngineError::IllegalAction { seat, source };
126
127        match self.round.phase() {
128            Phase::Upcard => {
129                let top = self.round.discard_pile().last().copied();
130                match strategy.offer_upcard(&self.view(seat)) {
131                    UpcardAction::Take => {
132                        let card = self.round.take_discard().map_err(reject)?;
133                        self.knowledge[seat as usize].taken_discard = Some(card);
134                        self.knowledge[seat.opponent() as usize]
135                            .opponent_known
136                            .insert(card);
137                    }
138                    UpcardAction::Pass => {
139                        self.round.pass().map_err(reject)?;
140                        if let Some(card) = top {
141                            self.knowledge[seat.opponent() as usize]
142                                .opponent_passed
143                                .insert(card);
144                        }
145                        // The second pass forces the non-dealer's stock draw.
146                        if self.round.phase() == Phase::Draw {
147                            self.knowledge[self.round.non_dealer() as usize].forced_stock = true;
148                        }
149                    }
150                }
151            }
152            Phase::Draw => {
153                let action = if self.knowledge[seat as usize].forced_stock {
154                    DrawAction::Stock
155                } else {
156                    strategy.choose_draw(&self.view(seat))
157                };
158                match action {
159                    DrawAction::Stock => {
160                        self.round.draw_stock().map_err(reject)?;
161                        self.knowledge[seat as usize].forced_stock = false;
162                    }
163                    DrawAction::TakeDiscard => {
164                        let card = self.round.take_discard().map_err(reject)?;
165                        self.knowledge[seat as usize].taken_discard = Some(card);
166                        self.knowledge[seat.opponent() as usize]
167                            .opponent_known
168                            .insert(card);
169                    }
170                }
171            }
172            Phase::Discard => {
173                let shed = match strategy.play_turn(&self.view(seat)) {
174                    TurnAction::Discard(card) => {
175                        self.round.discard(card).map_err(reject)?;
176                        Some(card)
177                    }
178                    TurnAction::Knock { discard, melds } => {
179                        self.round.knock(discard, melds).map_err(reject)?;
180                        Some(discard)
181                    }
182                    TurnAction::BigGin(melds) => {
183                        self.round.declare_big_gin(melds).map_err(reject)?;
184                        None
185                    }
186                };
187                self.knowledge[seat as usize].taken_discard = None;
188                if let Some(card) = shed {
189                    let observer = &mut self.knowledge[seat.opponent() as usize];
190                    observer.opponent_known.remove(card);
191                    observer.opponent_shed.insert(card);
192                }
193            }
194            Phase::Layoff => match strategy.choose_layoff(&self.view(seat)) {
195                Some(layoff) => {
196                    self.round
197                        .lay_off(layoff.card, layoff.meld)
198                        .map_err(reject)?;
199                    // The card is public on the spread now, not in the hand.
200                    self.knowledge[seat.opponent() as usize]
201                        .opponent_known
202                        .remove(layoff.card);
203                }
204                None => {
205                    self.round.finish_layoffs().map_err(reject)?;
206                }
207            },
208            Phase::Finished => {}
209        }
210        Ok(self.round.result())
211    }
212
213    /// Drive the round to completion, one strategy per seat
214    ///
215    /// # Errors
216    ///
217    /// [`EngineError::IllegalAction`] when a strategy's choice is rejected.
218    pub fn play(&mut self, strategies: [&mut dyn Strategy; 2]) -> Result<RoundResult, EngineError> {
219        loop {
220            let Some(seat) = self.round.turn() else {
221                return Ok(self.round.result().expect("a turnless round is finished"));
222            };
223            if let Some(result) = self.step(&mut *strategies[seat as usize])? {
224                return Ok(result);
225            }
226        }
227    }
228}
229
230/// Play one round to completion, one strategy per seat, indexed by
231/// [`Player`]
232///
233/// # Errors
234///
235/// [`EngineError::IllegalAction`] when a strategy's choice is rejected.
236pub fn play_round(
237    round: Round,
238    strategies: [&mut dyn Strategy; 2],
239) -> Result<RoundResult, EngineError> {
240    Table::new(round).play(strategies)
241}
242
243/// Deal and play rounds until the game is over, returning the settled score
244///
245/// # Errors
246///
247/// [`EngineError::IllegalAction`] when a strategy's choice is rejected.
248/// The game keeps the rounds recorded so far.
249#[cfg(feature = "rand")]
250pub fn play_game(
251    game: &mut gin_rummy::Game,
252    strategies: [&mut dyn Strategy; 2],
253    rng: &mut (impl rand::Rng + ?Sized),
254) -> Result<gin_rummy::FinalScore, EngineError> {
255    let [one, two] = strategies;
256    while !game.is_over() {
257        let scores = [game.score(Player::One), game.score(Player::Two)];
258        let mut table = Table::new(game.deal(rng)).scores(scores);
259        let result = table.play([&mut *one, &mut *two])?;
260        game.record(result)
261            .expect("a result produced by the round it was dealt for records cleanly");
262    }
263    Ok(game.final_score().expect("a game that is over settles"))
264}