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]`.  Set it with [`Table::scores`] when driving a round
50    /// 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), letting score-aware strategies
65    /// bank a lead or gamble from behind.
66    #[must_use]
67    pub const fn scores(mut self, scores: [u16; 2]) -> Self {
68        self.scores = scores;
69        self
70    }
71
72    /// Shuffle and deal a fresh round onto a new table
73    #[cfg(feature = "rand")]
74    #[must_use]
75    pub fn deal(
76        rules: gin_rummy::Rules,
77        dealer: Player,
78        rng: &mut (impl rand::Rng + ?Sized),
79    ) -> Self {
80        Self::new(Round::deal(rules, dealer, rng))
81    }
82
83    /// The underlying round, fully visible
84    #[must_use]
85    pub const fn round(&self) -> &Round {
86        &self.round
87    }
88
89    /// The player to act, or `None` when the round is finished
90    #[must_use]
91    pub const fn turn(&self) -> Option<Player> {
92        self.round.turn()
93    }
94
95    /// The legally visible information for one seat
96    #[must_use]
97    pub const fn view(&self, seat: Player) -> View<'_> {
98        let scores = [
99            self.scores[seat as usize],
100            self.scores[seat.opponent() as usize],
101        ];
102        View::new(&self.round, seat, &self.knowledge[seat as usize], scores)
103    }
104
105    /// Ask `strategy` — which must belong to the seat to act — for one
106    /// decision and apply it
107    ///
108    /// Returns the result once the round finishes, `None` while it
109    /// continues.  On the forced stock draw after both players pass the
110    /// upcard, the strategy is not consulted; the draw is applied directly.
111    ///
112    /// # Errors
113    ///
114    /// [`EngineError::IllegalAction`] when the round rejects the strategy's
115    /// choice.  The table is left unchanged, so an interactive caller may
116    /// retry the same decision.
117    pub fn step(
118        &mut self,
119        strategy: &mut dyn Strategy,
120    ) -> Result<Option<RoundResult>, EngineError> {
121        let Some(seat) = self.round.turn() else {
122            return Ok(self.round.result());
123        };
124        let reject = |source| EngineError::IllegalAction { seat, source };
125
126        match self.round.phase() {
127            Phase::Upcard => {
128                let top = self.round.discard_pile().last().copied();
129                match strategy.offer_upcard(&self.view(seat)) {
130                    UpcardAction::Take => {
131                        let card = self.round.take_discard().map_err(reject)?;
132                        self.knowledge[seat as usize].taken_discard = Some(card);
133                        self.knowledge[seat.opponent() as usize]
134                            .opponent_known
135                            .insert(card);
136                    }
137                    UpcardAction::Pass => {
138                        self.round.pass().map_err(reject)?;
139                        if let Some(card) = top {
140                            self.knowledge[seat.opponent() as usize]
141                                .opponent_passed
142                                .insert(card);
143                        }
144                        // The second pass forces the non-dealer's stock draw.
145                        if self.round.phase() == Phase::Draw {
146                            self.knowledge[self.round.non_dealer() as usize].forced_stock = true;
147                        }
148                    }
149                }
150            }
151            Phase::Draw => {
152                let action = if self.knowledge[seat as usize].forced_stock {
153                    DrawAction::Stock
154                } else {
155                    strategy.choose_draw(&self.view(seat))
156                };
157                match action {
158                    DrawAction::Stock => {
159                        self.round.draw_stock().map_err(reject)?;
160                        self.knowledge[seat as usize].forced_stock = false;
161                    }
162                    DrawAction::TakeDiscard => {
163                        let card = self.round.take_discard().map_err(reject)?;
164                        self.knowledge[seat as usize].taken_discard = Some(card);
165                        self.knowledge[seat.opponent() as usize]
166                            .opponent_known
167                            .insert(card);
168                    }
169                }
170            }
171            Phase::Discard => {
172                let shed = match strategy.play_turn(&self.view(seat)) {
173                    TurnAction::Discard(card) => {
174                        self.round.discard(card).map_err(reject)?;
175                        Some(card)
176                    }
177                    TurnAction::Knock { discard, melds } => {
178                        self.round.knock(discard, melds).map_err(reject)?;
179                        Some(discard)
180                    }
181                    TurnAction::BigGin(melds) => {
182                        self.round.declare_big_gin(melds).map_err(reject)?;
183                        None
184                    }
185                };
186                self.knowledge[seat as usize].taken_discard = None;
187                if let Some(card) = shed {
188                    let observer = &mut self.knowledge[seat.opponent() as usize];
189                    observer.opponent_known.remove(card);
190                    observer.opponent_shed.insert(card);
191                }
192            }
193            Phase::Layoff => match strategy.choose_layoff(&self.view(seat)) {
194                Some(layoff) => {
195                    self.round
196                        .lay_off(layoff.card, layoff.meld)
197                        .map_err(reject)?;
198                    // The card is public on the spread now, not in the hand.
199                    self.knowledge[seat.opponent() as usize]
200                        .opponent_known
201                        .remove(layoff.card);
202                }
203                None => {
204                    self.round.finish_layoffs().map_err(reject)?;
205                }
206            },
207            Phase::Finished => {}
208        }
209        Ok(self.round.result())
210    }
211
212    /// Drive the round to completion, one strategy per seat
213    ///
214    /// # Errors
215    ///
216    /// [`EngineError::IllegalAction`] when a strategy's choice is rejected.
217    pub fn play(&mut self, strategies: [&mut dyn Strategy; 2]) -> Result<RoundResult, EngineError> {
218        loop {
219            let Some(seat) = self.round.turn() else {
220                return Ok(self.round.result().expect("a turnless round is finished"));
221            };
222            if let Some(result) = self.step(&mut *strategies[seat as usize])? {
223                return Ok(result);
224            }
225        }
226    }
227}
228
229/// Play one round to completion, one strategy per seat, indexed by
230/// [`Player`]
231///
232/// # Errors
233///
234/// [`EngineError::IllegalAction`] when a strategy's choice is rejected.
235pub fn play_round(
236    round: Round,
237    strategies: [&mut dyn Strategy; 2],
238) -> Result<RoundResult, EngineError> {
239    Table::new(round).play(strategies)
240}
241
242/// Deal and play rounds until the game is over, returning the settled score
243///
244/// # Errors
245///
246/// [`EngineError::IllegalAction`] when a strategy's choice is rejected.
247/// The game keeps the rounds recorded so far.
248#[cfg(feature = "rand")]
249pub fn play_game(
250    game: &mut gin_rummy::Game,
251    strategies: [&mut dyn Strategy; 2],
252    rng: &mut (impl rand::Rng + ?Sized),
253) -> Result<gin_rummy::FinalScore, EngineError> {
254    let [one, two] = strategies;
255    while !game.is_over() {
256        let scores = [game.score(Player::One), game.score(Player::Two)];
257        let mut table = Table::new(game.deal(rng)).scores(scores);
258        let result = table.play([&mut *one, &mut *two])?;
259        game.record(result)
260            .expect("a result produced by the round it was dealt for records cleanly");
261    }
262    Ok(game.final_score().expect("a game that is over settles"))
263}