Skip to main content

gin_rummy/
round.rs

1//! One deal of gin rummy, from the opening upcard to the showdown.
2//!
3//! A [`Round`] is a runtime-checked state machine.  Accessors expose the
4//! whole position — including both hands and the stock order, so bots and
5//! UIs enforce their own information hygiene — and action methods mutate it,
6//! returning [`RoundError`] on illegal moves.
7//!
8//! The flow of a round:
9//!
10//! 1. **Upcard** ([`Phase::Upcard`]): the non-dealer, then the dealer, may
11//!    [`take_discard`](Round::take_discard) the upcard or
12//!    [`pass`](Round::pass).  If both pass, the non-dealer must draw from
13//!    the stock.
14//! 2. **Turns** ([`Phase::Draw`] then [`Phase::Discard`]): draw from the
15//!    stock or the discard pile, then [`discard`](Round::discard),
16//!    [`knock`](Round::knock), or
17//!    [`declare_big_gin`](Round::declare_big_gin).
18//! 3. **Layoffs** ([`Phase::Layoff`], skipped after gin): the defender may
19//!    [`lay_off`](Round::lay_off) deadwood onto the knocker's spread, then
20//!    [`finish_layoffs`](Round::finish_layoffs) settles the score.
21//!
22//! The round ends ([`Phase::Finished`]) with a [`RoundResult`], also
23//! covering the *dead hand*: a discard that leaves two cards in the stock
24//! without a knock voids the deal.
25
26use crate::meld::Melds;
27use crate::{Card, Hand, Meld, Player, Rules, deadwood};
28use core::fmt;
29use thiserror::Error;
30
31/// The phase of a [`Round`]
32///
33/// UIs are expected to match on all phases, so this enum is exhaustive.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub enum Phase {
37    /// The initial upcard is offered: take it or pass
38    Upcard,
39    /// The player to move draws from the stock or the discard pile
40    Draw,
41    /// The player to move discards, knocks, or declares big gin
42    Discard,
43    /// The defender may lay off cards onto the knocker's spread
44    Layoff,
45    /// The round is over; see [`Round::result`]
46    Finished,
47}
48
49impl fmt::Display for Phase {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(match self {
52            Self::Upcard => "upcard",
53            Self::Draw => "draw",
54            Self::Discard => "discard",
55            Self::Layoff => "layoff",
56            Self::Finished => "finished",
57        })
58    }
59}
60
61/// The outcome of a round
62///
63/// A result records facts; [`RoundResult::points`] prices them under a
64/// [`Rules`].  The enum is non-exhaustive to leave room for future variants
65/// (e.g. an Oklahoma hand multiplier).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68#[non_exhaustive]
69pub enum RoundResult {
70    /// The stock ran down to two cards without a knock: nobody scores, and
71    /// the same dealer redeals
72    Dead,
73    /// A knock won by `margin`, the difference in deadwood
74    Knock {
75        /// The knocker
76        winner: Player,
77        /// The deadwood difference
78        margin: u8,
79    },
80    /// The defender matched or beat the knocker's deadwood
81    Undercut {
82        /// The defender
83        winner: Player,
84        /// The deadwood difference
85        margin: u8,
86    },
87    /// A knock with zero deadwood; layoffs are not allowed against it
88    Gin {
89        /// The knocker
90        winner: Player,
91        /// The loser's deadwood
92        deadwood: u8,
93    },
94    /// Eleven melded cards declared instead of discarding
95    BigGin {
96        /// The declarer
97        winner: Player,
98        /// The loser's deadwood
99        deadwood: u8,
100    },
101}
102
103impl RoundResult {
104    /// The player who scores this result, or `None` for a dead hand
105    #[must_use]
106    pub const fn winner(self) -> Option<Player> {
107        match self {
108            Self::Dead => None,
109            Self::Knock { winner, .. }
110            | Self::Undercut { winner, .. }
111            | Self::Gin { winner, .. }
112            | Self::BigGin { winner, .. } => Some(winner),
113        }
114    }
115
116    /// The points this result awards its winner under the given rules
117    ///
118    /// The box bonus and game-level bonuses are accounted by
119    /// [`Game`](crate::Game), not here.
120    #[must_use]
121    pub const fn points(self, rules: &Rules) -> u16 {
122        match self {
123            Self::Dead => 0,
124            Self::Knock { margin, .. } => margin as u16,
125            Self::Undercut { margin, .. } => margin as u16 + rules.undercut_bonus,
126            Self::Gin { deadwood, .. } => deadwood as u16 + rules.gin_bonus,
127            // A big gin recorded under rules without one is at least a gin.
128            Self::BigGin { deadwood, .. } => {
129                deadwood as u16
130                    + match rules.big_gin_bonus {
131                        Some(bonus) => bonus,
132                        None => rules.gin_bonus,
133                    }
134            }
135        }
136    }
137}
138
139/// Error returned when constructing a [`Round`] from an invalid deal
140#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)]
141#[non_exhaustive]
142pub enum DealError {
143    /// Each player is dealt exactly 10 cards
144    #[error("Each player is dealt exactly 10 cards")]
145    WrongHandSize,
146
147    /// The stock holds the remaining 31 cards
148    #[error("The stock holds the remaining 31 cards")]
149    WrongStockSize,
150
151    /// A card appears twice across the hands, upcard, and stock
152    #[error("A card appears twice across the hands, upcard, and stock")]
153    RepeatedCard,
154}
155
156/// Error returned when a [`Round`] action is illegal
157#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)]
158#[non_exhaustive]
159pub enum RoundError {
160    /// The action does not belong to the current phase
161    #[error("This action is not available in the {0} phase")]
162    WrongPhase(Phase),
163
164    /// After both players pass the upcard, the first draw must come from
165    /// the stock
166    #[error("After both players pass the upcard, the first draw must come from the stock")]
167    MustDrawFromStock,
168
169    /// The card is not in the acting player's hand
170    #[error("{0} is not in the hand")]
171    NotInHand(Card),
172
173    /// The card was taken from the discard pile this turn
174    #[error("{0} was taken from the discard pile this turn and cannot be discarded")]
175    DiscardJustTaken(Card),
176
177    /// The arrangement's deadwood exceeds the knock limit
178    #[error("Deadwood {deadwood} exceeds the knock limit {limit}")]
179    TooMuchDeadwood {
180        /// The deadwood of the offered arrangement
181        deadwood: u8,
182        /// The knock limit in effect
183        limit: u8,
184    },
185
186    /// The melds do not arrange the knocker's remaining hand
187    #[error("The melds do not arrange the knocker's remaining hand")]
188    MeldsMismatch,
189
190    /// The hand is not fully melded
191    #[error("The hand is not fully melded")]
192    NotBigGin,
193
194    /// Big gin is disabled by the rules
195    #[error("Big gin is disabled by the rules")]
196    BigGinDisabled,
197
198    /// No meld at this index in the spread
199    #[error("No meld at index {0} in the spread")]
200    NoSuchMeld(usize),
201
202    /// The card extends no end of the meld
203    #[error("{0} does not fit the meld")]
204    CannotLayOff(Card),
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208struct KnockState {
209    knocker: Player,
210    spread: [Option<Meld>; 3],
211    knocker_deadwood: u8,
212    laid_off: Hand,
213}
214
215/// One deal of gin rummy
216///
217/// See the [module documentation](self) for the flow.  Construct with
218/// [`Round::from_deal`] or, under the `rand` feature, [`Round::deal`].
219///
220/// With the `serde` feature, a `Round` serializes to a plain snapshot of
221/// its position, and deserialization re-validates every invariant — a
222/// corrupt snapshot is rejected, never trusted.
223#[derive(Debug, Clone, PartialEq, Eq)]
224#[cfg_attr(
225    feature = "serde",
226    derive(serde::Serialize, serde::Deserialize),
227    serde(into = "repr::RoundRepr", try_from = "repr::RoundRepr")
228)]
229pub struct Round {
230    rules: Rules,
231    dealer: Player,
232    hands: [Hand; 2],
233    /// Face-down stock; the top card is the last element.
234    stock: Vec<Card>,
235    /// Face-up discard pile; the top card is the last element.
236    discards: Vec<Card>,
237    /// The opening upcard, remembered for the Oklahoma knock limit.
238    initial_upcard: Card,
239    phase: Phase,
240    turn: Player,
241    /// Upcard offers declined so far (0..=2).
242    passes: u8,
243    /// Both players passed the upcard; the next draw must hit the stock.
244    forced_stock: bool,
245    /// The card taken from the discard pile this turn, if any.
246    taken_discard: Option<Card>,
247    knock: Option<KnockState>,
248    result: Option<RoundResult>,
249}
250
251impl Round {
252    /// Construct a round from a fixed initial deal
253    ///
254    /// `stock` is face-down draw order: the *last* element is drawn first.
255    /// The upcard starts the discard pile, and the non-dealer moves first.
256    ///
257    /// # Errors
258    ///
259    /// When the hands are not 10 cards each, the stock is not the remaining
260    /// 31 cards, or any card appears twice.
261    pub fn from_deal(
262        rules: Rules,
263        dealer: Player,
264        hands: [Hand; 2],
265        upcard: Card,
266        stock: Vec<Card>,
267    ) -> Result<Self, DealError> {
268        if hands[0].len() != 10 || hands[1].len() != 10 {
269            return Err(DealError::WrongHandSize);
270        }
271        if stock.len() != 31 {
272            return Err(DealError::WrongStockSize);
273        }
274
275        let mut seen = hands[0];
276        if !(seen & hands[1]).is_empty() {
277            return Err(DealError::RepeatedCard);
278        }
279        seen |= hands[1];
280        if !seen.insert(upcard) {
281            return Err(DealError::RepeatedCard);
282        }
283        if stock.iter().any(|&card| !seen.insert(card)) {
284            return Err(DealError::RepeatedCard);
285        }
286
287        Ok(Self {
288            rules,
289            dealer,
290            hands,
291            stock,
292            discards: vec![upcard],
293            initial_upcard: upcard,
294            phase: Phase::Upcard,
295            turn: dealer.opponent(),
296            passes: 0,
297            forced_stock: false,
298            taken_discard: None,
299            knock: None,
300            result: None,
301        })
302    }
303
304    /// Shuffle and deal a fresh round: 10 cards to each player, an upcard,
305    /// and a randomly ordered 31-card stock
306    // The deal is disjoint by construction, so `from_deal` cannot fail.
307    #[allow(clippy::missing_panics_doc)]
308    #[cfg(feature = "rand")]
309    #[must_use]
310    pub fn deal(rules: Rules, dealer: Player, rng: &mut (impl rand::Rng + ?Sized)) -> Self {
311        let mut deck = crate::deck::Deck::ALL;
312        let hands = [deck.draw(rng, 10), deck.draw(rng, 10)];
313        let upcard = deck.pop(rng).expect("32 cards remain after the hands");
314
315        let mut stock = Vec::with_capacity(31);
316        while let Some(card) = deck.pop(rng) {
317            stock.push(card);
318        }
319
320        Self::from_deal(rules, dealer, hands, upcard, stock)
321            .expect("a fresh deck deals disjoint cards")
322    }
323
324    /// The scoring rules of this round
325    #[must_use]
326    #[inline]
327    pub const fn rules(&self) -> &Rules {
328        &self.rules
329    }
330
331    /// The dealer of this round
332    #[must_use]
333    #[inline]
334    pub const fn dealer(&self) -> Player {
335        self.dealer
336    }
337
338    /// The non-dealer, who receives the first upcard offer
339    #[must_use]
340    #[inline]
341    pub const fn non_dealer(&self) -> Player {
342        self.dealer.opponent()
343    }
344
345    /// The current phase
346    #[must_use]
347    #[inline]
348    pub const fn phase(&self) -> Phase {
349        self.phase
350    }
351
352    /// The player to act, or `None` once the round is finished
353    ///
354    /// During the layoff phase this is the defender.
355    #[must_use]
356    pub const fn turn(&self) -> Option<Player> {
357        match self.phase {
358            Phase::Finished => None,
359            _ => Some(self.turn),
360        }
361    }
362
363    /// The hand of a player
364    ///
365    /// Both hands are visible by design; bots enforce their own information
366    /// hygiene.
367    #[must_use]
368    #[inline]
369    pub const fn hand(&self, player: Player) -> Hand {
370        self.hands[player as usize]
371    }
372
373    /// The discard pile; the top card is the last element
374    #[must_use]
375    #[inline]
376    pub fn discard_pile(&self) -> &[Card] {
377        &self.discards
378    }
379
380    /// The face-down stock; the top card is the last element
381    ///
382    /// The full order is visible by design; bots enforce their own
383    /// information hygiene.
384    #[must_use]
385    #[inline]
386    pub fn stock(&self) -> &[Card] {
387        &self.stock
388    }
389
390    /// The knock limit in effect for this round
391    ///
392    /// [`Rules::knock_limit`], capped by the opening upcard's value under
393    /// an Oklahoma ruleset ([`Rules::oklahoma`]).  Read the limit here
394    /// rather than from the rules.
395    #[must_use]
396    #[inline]
397    pub const fn knock_limit(&self) -> u8 {
398        self.rules.knock_limit_for(self.initial_upcard)
399    }
400
401    /// The opening upcard that seeded the discard pile
402    ///
403    /// Remembered even after the card is drawn; under [`Rules::oklahoma`]
404    /// it sets [`knock_limit`](Self::knock_limit).
405    #[must_use]
406    #[inline]
407    pub const fn initial_upcard(&self) -> Card {
408        self.initial_upcard
409    }
410
411    /// The player who knocked or declared big gin, if any
412    #[must_use]
413    pub const fn knocker(&self) -> Option<Player> {
414        match &self.knock {
415            Some(state) => Some(state.knocker),
416            None => None,
417        }
418    }
419
420    /// The knocker's spread, extended by any layoffs so far
421    ///
422    /// Empty before a knock.
423    pub fn spread(&self) -> impl Iterator<Item = Meld> + '_ {
424        self.knock
425            .iter()
426            .flat_map(|state| state.spread.into_iter().flatten())
427    }
428
429    /// The cards the defender has laid off onto the spread
430    #[must_use]
431    pub fn laid_off(&self) -> Hand {
432        self.knock.map_or(Hand::EMPTY, |state| state.laid_off)
433    }
434
435    /// The outcome, or `None` while the round is in play
436    #[must_use]
437    #[inline]
438    pub const fn result(&self) -> Option<RoundResult> {
439        self.result
440    }
441
442    const fn expect_phase(&self, phase: Phase) -> Result<(), RoundError> {
443        if self.phase as u8 == phase as u8 {
444            Ok(())
445        } else {
446            Err(RoundError::WrongPhase(self.phase))
447        }
448    }
449
450    /// Decline the initial upcard
451    ///
452    /// The offer moves from the non-dealer to the dealer; when both pass,
453    /// the non-dealer must open by drawing from the stock.
454    ///
455    /// # Errors
456    ///
457    /// [`RoundError::WrongPhase`] outside the upcard phase.
458    pub fn pass(&mut self) -> Result<(), RoundError> {
459        self.expect_phase(Phase::Upcard)?;
460        self.passes += 1;
461        if self.passes == 2 {
462            self.turn = self.non_dealer();
463            self.phase = Phase::Draw;
464            self.forced_stock = true;
465        } else {
466            self.turn = self.dealer;
467        }
468        Ok(())
469    }
470
471    /// Take the top of the discard pile, returning the card
472    ///
473    /// Available as the upcard decision and as the draw of a normal turn.
474    ///
475    /// # Errors
476    ///
477    /// [`RoundError::WrongPhase`] outside the upcard and draw phases, and
478    /// [`RoundError::MustDrawFromStock`] on the forced stock draw after both
479    /// players passed the upcard.
480    // The expect is unreachable: a draw decision implies a non-empty pile.
481    #[allow(clippy::missing_panics_doc)]
482    pub fn take_discard(&mut self) -> Result<Card, RoundError> {
483        match self.phase {
484            Phase::Draw if self.forced_stock => return Err(RoundError::MustDrawFromStock),
485            Phase::Upcard | Phase::Draw => {}
486            phase => return Err(RoundError::WrongPhase(phase)),
487        }
488
489        let card = self
490            .discards
491            .pop()
492            .expect("the discard pile is never empty when it may be drawn from");
493        self.hands[self.turn as usize].insert(card);
494        self.taken_discard = Some(card);
495        self.phase = Phase::Discard;
496        Ok(card)
497    }
498
499    /// Draw the top of the stock, returning the card
500    ///
501    /// # Errors
502    ///
503    /// [`RoundError::WrongPhase`] outside the draw phase.
504    // The expect is unreachable: the dead-hand rule ends the round before
505    // the stock can empty.
506    #[allow(clippy::missing_panics_doc)]
507    pub fn draw_stock(&mut self) -> Result<Card, RoundError> {
508        self.expect_phase(Phase::Draw)?;
509        let card = self
510            .stock
511            .pop()
512            .expect("the dead-hand rule keeps the stock non-empty");
513        self.hands[self.turn as usize].insert(card);
514        self.forced_stock = false;
515        self.phase = Phase::Discard;
516        Ok(card)
517    }
518
519    /// Check that `card` may leave the hand as this turn's discard.
520    fn expect_sheddable(&self, card: Card) -> Result<(), RoundError> {
521        if !self.hands[self.turn as usize].contains(card) {
522            return Err(RoundError::NotInHand(card));
523        }
524        if self.taken_discard == Some(card) {
525            return Err(RoundError::DiscardJustTaken(card));
526        }
527        Ok(())
528    }
529
530    /// Move `card` from the acting hand to the discard pile.
531    fn shed(&mut self, card: Card) {
532        self.hands[self.turn as usize].remove(card);
533        self.discards.push(card);
534        self.taken_discard = None;
535    }
536
537    /// Discard a card, ending the turn
538    ///
539    /// If this leaves two cards in the stock, the round ends as a dead
540    /// hand: nobody scores and the same dealer redeals.
541    ///
542    /// # Errors
543    ///
544    /// [`RoundError::WrongPhase`] outside the discard phase,
545    /// [`RoundError::NotInHand`], and [`RoundError::DiscardJustTaken`] for
546    /// the card drawn from the discard pile this turn.
547    pub fn discard(&mut self, card: Card) -> Result<(), RoundError> {
548        self.expect_phase(Phase::Discard)?;
549        self.expect_sheddable(card)?;
550        self.shed(card);
551
552        if self.stock.len() == 2 {
553            self.result = Some(RoundResult::Dead);
554            self.phase = Phase::Finished;
555        } else {
556            self.turn = self.turn.opponent();
557            self.phase = Phase::Draw;
558        }
559        Ok(())
560    }
561
562    /// Discard a card and knock, spreading the given arrangement
563    ///
564    /// The knocker chooses the arrangement — it decides what the defender
565    /// may lay off — so it is passed explicitly; `best_melds(hand - card)`
566    /// recovers the automatic choice:
567    ///
568    /// ```
569    /// # use gin_rummy::{best_melds, Round};
570    /// # fn knock_optimally(round: &mut Round, card: gin_rummy::Card)
571    /// # -> Result<(), gin_rummy::round::RoundError> {
572    /// let hand = round.hand(round.turn().unwrap());
573    /// round.knock(card, best_melds(hand - card.into()))
574    /// # }
575    /// ```
576    ///
577    /// An arrangement with zero deadwood is **gin**: the defender may not
578    /// lay off, and the round finishes immediately.  Otherwise the round
579    /// enters the layoff phase.
580    ///
581    /// # Errors
582    ///
583    /// [`RoundError::WrongPhase`] outside the discard phase,
584    /// [`RoundError::NotInHand`], [`RoundError::DiscardJustTaken`],
585    /// [`RoundError::MeldsMismatch`] when `melds` does not arrange exactly
586    /// the hand minus the discard, and [`RoundError::TooMuchDeadwood`] over
587    /// the knock limit.
588    pub fn knock(&mut self, card: Card, melds: Melds) -> Result<(), RoundError> {
589        self.expect_phase(Phase::Discard)?;
590        self.expect_sheddable(card)?;
591        if melds.hand() != self.hands[self.turn as usize] - card.into() {
592            return Err(RoundError::MeldsMismatch);
593        }
594
595        let knocker_deadwood = melds.deadwood();
596        if knocker_deadwood > self.knock_limit() {
597            return Err(RoundError::TooMuchDeadwood {
598                deadwood: knocker_deadwood,
599                limit: self.knock_limit(),
600            });
601        }
602
603        self.shed(card);
604        let knocker = self.turn;
605        self.knock = Some(KnockState {
606            knocker,
607            spread: melds.into_array(),
608            knocker_deadwood,
609            laid_off: Hand::EMPTY,
610        });
611
612        if knocker_deadwood == 0 {
613            let loser = deadwood(self.hands[knocker.opponent() as usize]);
614            self.result = Some(RoundResult::Gin {
615                winner: knocker,
616                deadwood: loser,
617            });
618            self.phase = Phase::Finished;
619        } else {
620            self.turn = knocker.opponent();
621            self.phase = Phase::Layoff;
622        }
623        Ok(())
624    }
625
626    /// Declare big gin: all 11 cards melded, no discard
627    ///
628    /// The defender may not lay off, and the round finishes immediately.
629    /// Declaring is never forced — nor ever a trap: an 11-card fully-melded
630    /// hand always contains a meld of four or more cards, which can shed a
631    /// card, so plain gin remains available when the rules disable big gin.
632    ///
633    /// # Errors
634    ///
635    /// [`RoundError::WrongPhase`] outside the discard phase,
636    /// [`RoundError::BigGinDisabled`] when [`Rules::big_gin_bonus`] is
637    /// `None`, [`RoundError::MeldsMismatch`] when `melds` does not arrange
638    /// the full hand, and [`RoundError::NotBigGin`] on any deadwood.
639    pub fn declare_big_gin(&mut self, melds: Melds) -> Result<(), RoundError> {
640        self.expect_phase(Phase::Discard)?;
641        if self.rules.big_gin_bonus.is_none() {
642            return Err(RoundError::BigGinDisabled);
643        }
644        if melds.hand() != self.hands[self.turn as usize] {
645            return Err(RoundError::MeldsMismatch);
646        }
647        if melds.deadwood() != 0 {
648            return Err(RoundError::NotBigGin);
649        }
650
651        let winner = self.turn;
652        self.taken_discard = None;
653        self.knock = Some(KnockState {
654            knocker: winner,
655            spread: melds.into_array(),
656            knocker_deadwood: 0,
657            laid_off: Hand::EMPTY,
658        });
659        self.result = Some(RoundResult::BigGin {
660            winner,
661            deadwood: deadwood(self.hands[winner.opponent() as usize]),
662        });
663        self.phase = Phase::Finished;
664        Ok(())
665    }
666
667    /// Lay off a card onto the meld at `index` in the knocker's spread
668    ///
669    /// Indices are stable across layoffs, so chained extensions — the ♠8
670    /// then the ♠9 onto a 5-6-7 of spades — address the same meld.
671    ///
672    /// # Errors
673    ///
674    /// [`RoundError::WrongPhase`] outside the layoff phase,
675    /// [`RoundError::NotInHand`], [`RoundError::NoSuchMeld`], and
676    /// [`RoundError::CannotLayOff`] when the card extends neither end of a
677    /// run nor completes a set.
678    // The expect is unreachable: the layoff phase implies a knock.
679    #[allow(clippy::missing_panics_doc)]
680    pub fn lay_off(&mut self, card: Card, index: usize) -> Result<(), RoundError> {
681        self.expect_phase(Phase::Layoff)?;
682        if !self.hands[self.turn as usize].contains(card) {
683            return Err(RoundError::NotInHand(card));
684        }
685
686        let state = self.knock.as_mut().expect("a layoff follows a knock");
687        let meld = match state.spread.get(index) {
688            Some(Some(meld)) => *meld,
689            _ => return Err(RoundError::NoSuchMeld(index)),
690        };
691        let extended = meld.extended(card).ok_or(RoundError::CannotLayOff(card))?;
692
693        state.spread[index] = Some(extended);
694        state.laid_off.insert(card);
695        self.hands[self.turn as usize].remove(card);
696        Ok(())
697    }
698
699    /// End the layoff phase and settle the round
700    ///
701    /// The defender's remaining cards are melded optimally.  The defender
702    /// wins an undercut on less deadwood than the knocker kept — or equal
703    /// deadwood under [`Rules::undercut_on_tie`]; otherwise the knocker
704    /// wins the deadwood difference.
705    ///
706    /// # Errors
707    ///
708    /// [`RoundError::WrongPhase`] outside the layoff phase.
709    // The expect is unreachable: the layoff phase implies a knock.
710    #[allow(clippy::missing_panics_doc)]
711    pub fn finish_layoffs(&mut self) -> Result<RoundResult, RoundError> {
712        self.expect_phase(Phase::Layoff)?;
713        let state = self.knock.as_ref().expect("a layoff follows a knock");
714        let knocker = state.knocker;
715        let knocker_deadwood = state.knocker_deadwood;
716        let defender_deadwood = deadwood(self.hands[knocker.opponent() as usize]);
717
718        let undercut = defender_deadwood < knocker_deadwood
719            || (defender_deadwood == knocker_deadwood && self.rules.undercut_on_tie);
720        let result = if undercut {
721            RoundResult::Undercut {
722                winner: knocker.opponent(),
723                margin: knocker_deadwood - defender_deadwood,
724            }
725        } else {
726            RoundResult::Knock {
727                winner: knocker,
728                margin: defender_deadwood - knocker_deadwood,
729            }
730        };
731
732        self.result = Some(result);
733        self.phase = Phase::Finished;
734        Ok(result)
735    }
736}
737
738/// The serialized form of a [`Round`], re-validated on deserialization
739#[cfg(feature = "serde")]
740mod repr {
741    use super::{KnockState, Phase, Round, RoundResult};
742    use crate::{Card, Hand, Meld, Player, Rules, deadwood, pip_sum};
743    use thiserror::Error;
744
745    #[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
746    pub enum InvalidRound {
747        #[error(
748            "The hands, stock, discard pile, and layoffs must hold each of the 52 cards exactly once"
749        )]
750        NotAPartition,
751        #[error("Card counts are inconsistent with the phase")]
752        WrongCounts,
753        #[error("The pass counter is out of range or contradicts the acting player")]
754        BadUpcardState,
755        #[error("The initial upcard contradicts the discard pile")]
756        BadInitialUpcard,
757        #[error("A flag or component contradicts the phase")]
758        PhaseMismatch,
759        #[error("The spread is not disjoint melds over the knocker's cards and the layoffs")]
760        BadSpread,
761        #[error("The knocker's deadwood violates the knock limit")]
762        BadDeadwood,
763        #[error("The result does not match the recomputed score")]
764        BadResult,
765    }
766
767    #[derive(Clone, serde::Serialize, serde::Deserialize)]
768    pub struct KnockRepr {
769        knocker: Player,
770        spread: Vec<Meld>,
771        laid_off: Hand,
772    }
773
774    #[derive(Clone, serde::Serialize, serde::Deserialize)]
775    pub struct RoundRepr {
776        rules: Rules,
777        dealer: Player,
778        hands: [Hand; 2],
779        stock: Vec<Card>,
780        discards: Vec<Card>,
781        initial_upcard: Card,
782        phase: Phase,
783        turn: Player,
784        passes: u8,
785        forced_stock: bool,
786        taken_discard: Option<Card>,
787        knock: Option<KnockRepr>,
788        result: Option<RoundResult>,
789    }
790
791    impl From<Round> for RoundRepr {
792        fn from(round: Round) -> Self {
793            Self {
794                rules: round.rules,
795                dealer: round.dealer,
796                hands: round.hands,
797                stock: round.stock,
798                discards: round.discards,
799                initial_upcard: round.initial_upcard,
800                phase: round.phase,
801                turn: round.turn,
802                passes: round.passes,
803                forced_stock: round.forced_stock,
804                taken_discard: round.taken_discard,
805                knock: round.knock.map(|state| KnockRepr {
806                    knocker: state.knocker,
807                    spread: state.spread.into_iter().flatten().collect(),
808                    laid_off: state.laid_off,
809                }),
810                result: round.result,
811            }
812        }
813    }
814
815    /// Rebuild the knock state, checking the spread against the knocker's
816    /// cards and computing the deadwood frozen at knock time.
817    fn validate_knock(repr: &KnockRepr, hands: &[Hand; 2]) -> Result<KnockState, InvalidRound> {
818        if repr.spread.len() > 3 {
819            return Err(InvalidRound::BadSpread);
820        }
821
822        let mut union = Hand::EMPTY;
823        let mut spread = [None; 3];
824        for (slot, &meld) in spread.iter_mut().zip(&repr.spread) {
825            if !(union & meld.cards()).is_empty() {
826                return Err(InvalidRound::BadSpread);
827            }
828            union |= meld.cards();
829            *slot = Some(meld);
830        }
831
832        // Laid-off cards extend spread melds; the rest of the spread is the
833        // knocker's own melded cards.
834        let own = union - repr.laid_off;
835        if repr.laid_off & union != repr.laid_off || own & hands[repr.knocker as usize] != own {
836            return Err(InvalidRound::BadSpread);
837        }
838
839        // The knocker holds at most 11 cards here, so the sum fits `u8`.
840        let knocker_deadwood = pip_sum(hands[repr.knocker as usize] - union) as u8;
841        Ok(KnockState {
842            knocker: repr.knocker,
843            spread,
844            knocker_deadwood,
845            laid_off: repr.laid_off,
846        })
847    }
848
849    impl TryFrom<RoundRepr> for Round {
850        type Error = InvalidRound;
851
852        #[allow(clippy::too_many_lines)]
853        fn try_from(repr: RoundRepr) -> Result<Self, InvalidRound> {
854            // Every card lives in exactly one place.
855            let mut seen = repr.hands[0];
856            if !(seen & repr.hands[1]).is_empty() {
857                return Err(InvalidRound::NotAPartition);
858            }
859            seen |= repr.hands[1];
860            for &card in repr.stock.iter().chain(&repr.discards) {
861                if !seen.insert(card) {
862                    return Err(InvalidRound::NotAPartition);
863                }
864            }
865            if let Some(knock) = &repr.knock {
866                if !(seen & knock.laid_off).is_empty() {
867                    return Err(InvalidRound::NotAPartition);
868                }
869                seen |= knock.laid_off;
870            }
871            if seen != Hand::ALL {
872                return Err(InvalidRound::NotAPartition);
873            }
874
875            let len = |player: Player| repr.hands[player as usize].len();
876            let turn = repr.turn;
877            if repr.passes > 2 {
878                return Err(InvalidRound::BadUpcardState);
879            }
880
881            // The forced stock draw exists only right after both players
882            // pass the upcard, and nothing else may linger across phases it
883            // does not belong to.
884            if repr.forced_stock
885                && !(repr.phase == Phase::Draw && repr.passes == 2 && repr.stock.len() == 31)
886            {
887                return Err(InvalidRound::PhaseMismatch);
888            }
889            if repr.taken_discard.is_some() && repr.phase != Phase::Discard {
890                return Err(InvalidRound::PhaseMismatch);
891            }
892            if repr.result.is_some() != (repr.phase == Phase::Finished) {
893                return Err(InvalidRound::PhaseMismatch);
894            }
895
896            // While the upcard is on offer the pile is exactly that card,
897            // and once both players pass it, it is buried for good: the
898            // pile never shrinks across turns, so it stays `discards[0]`.
899            // After a take the field is unverifiable and trusted.
900            if (repr.phase == Phase::Upcard || repr.passes == 2)
901                && repr.discards.first() != Some(&repr.initial_upcard)
902            {
903                return Err(InvalidRound::BadInitialUpcard);
904            }
905            let knock_limit = repr.rules.knock_limit_for(repr.initial_upcard);
906
907            let knock = match repr.phase {
908                Phase::Upcard => {
909                    if repr.passes > 1
910                        || turn
911                            != if repr.passes == 0 {
912                                repr.dealer.opponent()
913                            } else {
914                                repr.dealer
915                            }
916                    {
917                        return Err(InvalidRound::BadUpcardState);
918                    }
919                    if repr.stock.len() != 31
920                        || repr.discards.len() != 1
921                        || len(Player::One) != 10
922                        || len(Player::Two) != 10
923                    {
924                        return Err(InvalidRound::WrongCounts);
925                    }
926                    if repr.knock.is_some() {
927                        return Err(InvalidRound::PhaseMismatch);
928                    }
929                    None
930                }
931                Phase::Draw => {
932                    if len(Player::One) != 10
933                        || len(Player::Two) != 10
934                        || repr.stock.len() < 3
935                        || repr.discards.is_empty()
936                    {
937                        return Err(InvalidRound::WrongCounts);
938                    }
939                    if repr.knock.is_some() {
940                        return Err(InvalidRound::PhaseMismatch);
941                    }
942                    None
943                }
944                Phase::Discard => {
945                    if len(turn) != 11 || len(turn.opponent()) != 10 || repr.stock.len() < 2 {
946                        return Err(InvalidRound::WrongCounts);
947                    }
948                    if repr.knock.is_some() {
949                        return Err(InvalidRound::PhaseMismatch);
950                    }
951                    if let Some(card) = repr.taken_discard
952                        && !repr.hands[turn as usize].contains(card)
953                    {
954                        return Err(InvalidRound::PhaseMismatch);
955                    }
956                    None
957                }
958                Phase::Layoff => {
959                    let knock = repr.knock.as_ref().ok_or(InvalidRound::PhaseMismatch)?;
960                    let state = validate_knock(knock, &repr.hands)?;
961                    // Gin skips layoffs, so a live layoff phase means the
962                    // knocker kept deadwood within the limit.
963                    if state.knocker_deadwood == 0 {
964                        return Err(InvalidRound::PhaseMismatch);
965                    }
966                    if state.knocker_deadwood > knock_limit {
967                        return Err(InvalidRound::BadDeadwood);
968                    }
969                    if turn != state.knocker.opponent() {
970                        return Err(InvalidRound::PhaseMismatch);
971                    }
972                    if len(state.knocker) != 10
973                        || len(turn) + state.laid_off.len() != 10
974                        || repr.stock.len() < 2
975                        || repr.discards.is_empty()
976                    {
977                        return Err(InvalidRound::WrongCounts);
978                    }
979                    Some(state)
980                }
981                Phase::Finished => {
982                    let result = repr.result.ok_or(InvalidRound::PhaseMismatch)?;
983                    match result {
984                        RoundResult::Dead => {
985                            if repr.knock.is_some() {
986                                return Err(InvalidRound::PhaseMismatch);
987                            }
988                            if repr.stock.len() != 2
989                                || len(Player::One) != 10
990                                || len(Player::Two) != 10
991                            {
992                                return Err(InvalidRound::WrongCounts);
993                            }
994                            None
995                        }
996                        RoundResult::Knock { winner, margin }
997                        | RoundResult::Undercut { winner, margin } => {
998                            let knock = repr.knock.as_ref().ok_or(InvalidRound::PhaseMismatch)?;
999                            let state = validate_knock(knock, &repr.hands)?;
1000                            let undercut = matches!(result, RoundResult::Undercut { .. });
1001                            let knocker = if undercut { winner.opponent() } else { winner };
1002                            if state.knocker != knocker || state.knocker_deadwood == 0 {
1003                                return Err(InvalidRound::BadResult);
1004                            }
1005                            if state.knocker_deadwood > knock_limit {
1006                                return Err(InvalidRound::BadDeadwood);
1007                            }
1008                            let defender = knocker.opponent();
1009                            if len(knocker) != 10
1010                                || len(defender) + state.laid_off.len() != 10
1011                                || repr.stock.len() < 2
1012                            {
1013                                return Err(InvalidRound::WrongCounts);
1014                            }
1015
1016                            // Wrapping subtraction: a corrupt snapshot may
1017                            // put the margin on the wrong side, which the
1018                            // `undercut != expected` test rejects anyway.
1019                            let defender_deadwood = deadwood(repr.hands[defender as usize]);
1020                            let expected = defender_deadwood < state.knocker_deadwood
1021                                || (defender_deadwood == state.knocker_deadwood
1022                                    && repr.rules.undercut_on_tie);
1023                            let expected_margin = if undercut {
1024                                state.knocker_deadwood.wrapping_sub(defender_deadwood)
1025                            } else {
1026                                defender_deadwood.wrapping_sub(state.knocker_deadwood)
1027                            };
1028                            if undercut != expected || margin != expected_margin {
1029                                return Err(InvalidRound::BadResult);
1030                            }
1031                            Some(state)
1032                        }
1033                        RoundResult::Gin {
1034                            winner,
1035                            deadwood: loser,
1036                        }
1037                        | RoundResult::BigGin {
1038                            winner,
1039                            deadwood: loser,
1040                        } => {
1041                            let knock = repr.knock.as_ref().ok_or(InvalidRound::PhaseMismatch)?;
1042                            let state = validate_knock(knock, &repr.hands)?;
1043                            let big = matches!(result, RoundResult::BigGin { .. });
1044                            let expected_len = if big { 11 } else { 10 };
1045                            if state.knocker != winner
1046                                || state.knocker_deadwood != 0
1047                                || !state.laid_off.is_empty()
1048                            {
1049                                return Err(InvalidRound::BadResult);
1050                            }
1051                            if big && repr.rules.big_gin_bonus.is_none() {
1052                                return Err(InvalidRound::BadResult);
1053                            }
1054                            if len(winner) != expected_len
1055                                || len(winner.opponent()) != 10
1056                                || repr.stock.len() < 2
1057                            {
1058                                return Err(InvalidRound::WrongCounts);
1059                            }
1060                            if loser != deadwood(repr.hands[winner.opponent() as usize]) {
1061                                return Err(InvalidRound::BadResult);
1062                            }
1063                            Some(state)
1064                        }
1065                    }
1066                }
1067            };
1068
1069            Ok(Self {
1070                rules: repr.rules,
1071                dealer: repr.dealer,
1072                hands: repr.hands,
1073                stock: repr.stock,
1074                discards: repr.discards,
1075                initial_upcard: repr.initial_upcard,
1076                phase: repr.phase,
1077                turn: repr.turn,
1078                passes: repr.passes,
1079                forced_stock: repr.forced_stock,
1080                taken_discard: repr.taken_discard,
1081                knock,
1082                result: repr.result,
1083            })
1084        }
1085    }
1086}