use crate::meld::Melds;
use crate::{Card, Hand, Meld, Player, Rules, deadwood};
use core::fmt;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Phase {
Upcard,
Draw,
Discard,
Layoff,
Finished,
}
impl fmt::Display for Phase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Upcard => "upcard",
Self::Draw => "draw",
Self::Discard => "discard",
Self::Layoff => "layoff",
Self::Finished => "finished",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RoundResult {
Dead,
Knock {
winner: Player,
margin: u8,
},
Undercut {
winner: Player,
margin: u8,
},
Gin {
winner: Player,
deadwood: u8,
},
BigGin {
winner: Player,
deadwood: u8,
},
}
impl RoundResult {
#[must_use]
pub const fn winner(self) -> Option<Player> {
match self {
Self::Dead => None,
Self::Knock { winner, .. }
| Self::Undercut { winner, .. }
| Self::Gin { winner, .. }
| Self::BigGin { winner, .. } => Some(winner),
}
}
#[must_use]
pub const fn points(self, rules: &Rules) -> u16 {
match self {
Self::Dead => 0,
Self::Knock { margin, .. } => margin as u16,
Self::Undercut { margin, .. } => margin as u16 + rules.undercut_bonus,
Self::Gin { deadwood, .. } => deadwood as u16 + rules.gin_bonus,
Self::BigGin { deadwood, .. } => {
deadwood as u16
+ match rules.big_gin_bonus {
Some(bonus) => bonus,
None => rules.gin_bonus,
}
}
}
}
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DealError {
#[error("Each player is dealt exactly 10 cards")]
WrongHandSize,
#[error("The stock holds the remaining 31 cards")]
WrongStockSize,
#[error("A card appears twice across the hands, upcard, and stock")]
RepeatedCard,
}
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RoundError {
#[error("This action is not available in the {0} phase")]
WrongPhase(Phase),
#[error("After both players pass the upcard, the first draw must come from the stock")]
MustDrawFromStock,
#[error("{0} is not in the hand")]
NotInHand(Card),
#[error("{0} was taken from the discard pile this turn and cannot be discarded")]
DiscardJustTaken(Card),
#[error("Deadwood {deadwood} exceeds the knock limit {limit}")]
TooMuchDeadwood {
deadwood: u8,
limit: u8,
},
#[error("The melds do not arrange the knocker's remaining hand")]
MeldsMismatch,
#[error("The hand is not fully melded")]
NotBigGin,
#[error("Big gin is disabled by the rules")]
BigGinDisabled,
#[error("No meld at index {0} in the spread")]
NoSuchMeld(usize),
#[error("{0} does not fit the meld")]
CannotLayOff(Card),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct KnockState {
knocker: Player,
spread: [Option<Meld>; 3],
knocker_deadwood: u8,
laid_off: Hand,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(into = "repr::RoundRepr", try_from = "repr::RoundRepr")
)]
pub struct Round {
rules: Rules,
dealer: Player,
hands: [Hand; 2],
stock: Vec<Card>,
discards: Vec<Card>,
phase: Phase,
turn: Player,
passes: u8,
forced_stock: bool,
taken_discard: Option<Card>,
knock: Option<KnockState>,
result: Option<RoundResult>,
}
impl Round {
pub fn from_deal(
rules: Rules,
dealer: Player,
hands: [Hand; 2],
upcard: Card,
stock: Vec<Card>,
) -> Result<Self, DealError> {
if hands[0].len() != 10 || hands[1].len() != 10 {
return Err(DealError::WrongHandSize);
}
if stock.len() != 31 {
return Err(DealError::WrongStockSize);
}
let mut seen = hands[0];
if !(seen & hands[1]).is_empty() {
return Err(DealError::RepeatedCard);
}
seen |= hands[1];
if !seen.insert(upcard) {
return Err(DealError::RepeatedCard);
}
if stock.iter().any(|&card| !seen.insert(card)) {
return Err(DealError::RepeatedCard);
}
Ok(Self {
rules,
dealer,
hands,
stock,
discards: vec![upcard],
phase: Phase::Upcard,
turn: dealer.opponent(),
passes: 0,
forced_stock: false,
taken_discard: None,
knock: None,
result: None,
})
}
#[allow(clippy::missing_panics_doc)]
#[cfg(feature = "rand")]
#[must_use]
pub fn deal(rules: Rules, dealer: Player, rng: &mut (impl rand::Rng + ?Sized)) -> Self {
let mut deck = crate::deck::Deck::ALL;
let hands = [deck.draw(rng, 10), deck.draw(rng, 10)];
let upcard = deck.pop(rng).expect("32 cards remain after the hands");
let mut stock = Vec::with_capacity(31);
while let Some(card) = deck.pop(rng) {
stock.push(card);
}
Self::from_deal(rules, dealer, hands, upcard, stock)
.expect("a fresh deck deals disjoint cards")
}
#[must_use]
#[inline]
pub const fn rules(&self) -> &Rules {
&self.rules
}
#[must_use]
#[inline]
pub const fn dealer(&self) -> Player {
self.dealer
}
#[must_use]
#[inline]
pub const fn non_dealer(&self) -> Player {
self.dealer.opponent()
}
#[must_use]
#[inline]
pub const fn phase(&self) -> Phase {
self.phase
}
#[must_use]
pub const fn turn(&self) -> Option<Player> {
match self.phase {
Phase::Finished => None,
_ => Some(self.turn),
}
}
#[must_use]
#[inline]
pub const fn hand(&self, player: Player) -> Hand {
self.hands[player as usize]
}
#[must_use]
#[inline]
pub fn discard_pile(&self) -> &[Card] {
&self.discards
}
#[must_use]
#[inline]
pub fn stock(&self) -> &[Card] {
&self.stock
}
#[must_use]
#[inline]
pub const fn knock_limit(&self) -> u8 {
self.rules.knock_limit
}
#[must_use]
pub const fn knocker(&self) -> Option<Player> {
match &self.knock {
Some(state) => Some(state.knocker),
None => None,
}
}
pub fn spread(&self) -> impl Iterator<Item = Meld> + '_ {
self.knock
.iter()
.flat_map(|state| state.spread.into_iter().flatten())
}
#[must_use]
pub fn laid_off(&self) -> Hand {
self.knock.map_or(Hand::EMPTY, |state| state.laid_off)
}
#[must_use]
#[inline]
pub const fn result(&self) -> Option<RoundResult> {
self.result
}
const fn expect_phase(&self, phase: Phase) -> Result<(), RoundError> {
if self.phase as u8 == phase as u8 {
Ok(())
} else {
Err(RoundError::WrongPhase(self.phase))
}
}
pub fn pass(&mut self) -> Result<(), RoundError> {
self.expect_phase(Phase::Upcard)?;
self.passes += 1;
if self.passes == 2 {
self.turn = self.non_dealer();
self.phase = Phase::Draw;
self.forced_stock = true;
} else {
self.turn = self.dealer;
}
Ok(())
}
#[allow(clippy::missing_panics_doc)]
pub fn take_discard(&mut self) -> Result<Card, RoundError> {
match self.phase {
Phase::Draw if self.forced_stock => return Err(RoundError::MustDrawFromStock),
Phase::Upcard | Phase::Draw => {}
phase => return Err(RoundError::WrongPhase(phase)),
}
let card = self
.discards
.pop()
.expect("the discard pile is never empty when it may be drawn from");
self.hands[self.turn as usize].insert(card);
self.taken_discard = Some(card);
self.phase = Phase::Discard;
Ok(card)
}
#[allow(clippy::missing_panics_doc)]
pub fn draw_stock(&mut self) -> Result<Card, RoundError> {
self.expect_phase(Phase::Draw)?;
let card = self
.stock
.pop()
.expect("the dead-hand rule keeps the stock non-empty");
self.hands[self.turn as usize].insert(card);
self.forced_stock = false;
self.phase = Phase::Discard;
Ok(card)
}
fn expect_sheddable(&self, card: Card) -> Result<(), RoundError> {
if !self.hands[self.turn as usize].contains(card) {
return Err(RoundError::NotInHand(card));
}
if self.taken_discard == Some(card) {
return Err(RoundError::DiscardJustTaken(card));
}
Ok(())
}
fn shed(&mut self, card: Card) {
self.hands[self.turn as usize].remove(card);
self.discards.push(card);
self.taken_discard = None;
}
pub fn discard(&mut self, card: Card) -> Result<(), RoundError> {
self.expect_phase(Phase::Discard)?;
self.expect_sheddable(card)?;
self.shed(card);
if self.stock.len() == 2 {
self.result = Some(RoundResult::Dead);
self.phase = Phase::Finished;
} else {
self.turn = self.turn.opponent();
self.phase = Phase::Draw;
}
Ok(())
}
pub fn knock(&mut self, card: Card, melds: Melds) -> Result<(), RoundError> {
self.expect_phase(Phase::Discard)?;
self.expect_sheddable(card)?;
if melds.hand() != self.hands[self.turn as usize] - card.into() {
return Err(RoundError::MeldsMismatch);
}
let knocker_deadwood = melds.deadwood();
if knocker_deadwood > self.knock_limit() {
return Err(RoundError::TooMuchDeadwood {
deadwood: knocker_deadwood,
limit: self.knock_limit(),
});
}
self.shed(card);
let knocker = self.turn;
self.knock = Some(KnockState {
knocker,
spread: melds.into_array(),
knocker_deadwood,
laid_off: Hand::EMPTY,
});
if knocker_deadwood == 0 {
let loser = deadwood(self.hands[knocker.opponent() as usize]);
self.result = Some(RoundResult::Gin {
winner: knocker,
deadwood: loser,
});
self.phase = Phase::Finished;
} else {
self.turn = knocker.opponent();
self.phase = Phase::Layoff;
}
Ok(())
}
pub fn declare_big_gin(&mut self, melds: Melds) -> Result<(), RoundError> {
self.expect_phase(Phase::Discard)?;
if self.rules.big_gin_bonus.is_none() {
return Err(RoundError::BigGinDisabled);
}
if melds.hand() != self.hands[self.turn as usize] {
return Err(RoundError::MeldsMismatch);
}
if melds.deadwood() != 0 {
return Err(RoundError::NotBigGin);
}
let winner = self.turn;
self.taken_discard = None;
self.knock = Some(KnockState {
knocker: winner,
spread: melds.into_array(),
knocker_deadwood: 0,
laid_off: Hand::EMPTY,
});
self.result = Some(RoundResult::BigGin {
winner,
deadwood: deadwood(self.hands[winner.opponent() as usize]),
});
self.phase = Phase::Finished;
Ok(())
}
#[allow(clippy::missing_panics_doc)]
pub fn lay_off(&mut self, card: Card, index: usize) -> Result<(), RoundError> {
self.expect_phase(Phase::Layoff)?;
if !self.hands[self.turn as usize].contains(card) {
return Err(RoundError::NotInHand(card));
}
let state = self.knock.as_mut().expect("a layoff follows a knock");
let meld = match state.spread.get(index) {
Some(Some(meld)) => *meld,
_ => return Err(RoundError::NoSuchMeld(index)),
};
let extended = meld.extended(card).ok_or(RoundError::CannotLayOff(card))?;
state.spread[index] = Some(extended);
state.laid_off.insert(card);
self.hands[self.turn as usize].remove(card);
Ok(())
}
#[allow(clippy::missing_panics_doc)]
pub fn finish_layoffs(&mut self) -> Result<RoundResult, RoundError> {
self.expect_phase(Phase::Layoff)?;
let state = self.knock.as_ref().expect("a layoff follows a knock");
let knocker = state.knocker;
let knocker_deadwood = state.knocker_deadwood;
let defender_deadwood = deadwood(self.hands[knocker.opponent() as usize]);
let undercut = defender_deadwood < knocker_deadwood
|| (defender_deadwood == knocker_deadwood && self.rules.undercut_on_tie);
let result = if undercut {
RoundResult::Undercut {
winner: knocker.opponent(),
margin: knocker_deadwood - defender_deadwood,
}
} else {
RoundResult::Knock {
winner: knocker,
margin: defender_deadwood - knocker_deadwood,
}
};
self.result = Some(result);
self.phase = Phase::Finished;
Ok(result)
}
}
#[cfg(feature = "serde")]
mod repr {
use super::{KnockState, Phase, Round, RoundResult};
use crate::{Card, Hand, Meld, Player, Rules, deadwood, pip_sum};
use thiserror::Error;
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum InvalidRound {
#[error(
"The hands, stock, discard pile, and layoffs must hold each of the 52 cards exactly once"
)]
NotAPartition,
#[error("Card counts are inconsistent with the phase")]
WrongCounts,
#[error("The pass counter is out of range or contradicts the acting player")]
BadUpcardState,
#[error("A flag or component contradicts the phase")]
PhaseMismatch,
#[error("The spread is not disjoint melds over the knocker's cards and the layoffs")]
BadSpread,
#[error("The knocker's deadwood violates the knock limit")]
BadDeadwood,
#[error("The result does not match the recomputed score")]
BadResult,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct KnockRepr {
knocker: Player,
spread: Vec<Meld>,
laid_off: Hand,
}
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct RoundRepr {
rules: Rules,
dealer: Player,
hands: [Hand; 2],
stock: Vec<Card>,
discards: Vec<Card>,
phase: Phase,
turn: Player,
passes: u8,
forced_stock: bool,
taken_discard: Option<Card>,
knock: Option<KnockRepr>,
result: Option<RoundResult>,
}
impl From<Round> for RoundRepr {
fn from(round: Round) -> Self {
Self {
rules: round.rules,
dealer: round.dealer,
hands: round.hands,
stock: round.stock,
discards: round.discards,
phase: round.phase,
turn: round.turn,
passes: round.passes,
forced_stock: round.forced_stock,
taken_discard: round.taken_discard,
knock: round.knock.map(|state| KnockRepr {
knocker: state.knocker,
spread: state.spread.into_iter().flatten().collect(),
laid_off: state.laid_off,
}),
result: round.result,
}
}
}
fn validate_knock(repr: &KnockRepr, hands: &[Hand; 2]) -> Result<KnockState, InvalidRound> {
if repr.spread.len() > 3 {
return Err(InvalidRound::BadSpread);
}
let mut union = Hand::EMPTY;
let mut spread = [None; 3];
for (slot, &meld) in spread.iter_mut().zip(&repr.spread) {
if !(union & meld.cards()).is_empty() {
return Err(InvalidRound::BadSpread);
}
union |= meld.cards();
*slot = Some(meld);
}
let own = union - repr.laid_off;
if repr.laid_off & union != repr.laid_off || own & hands[repr.knocker as usize] != own {
return Err(InvalidRound::BadSpread);
}
let knocker_deadwood = pip_sum(hands[repr.knocker as usize] - union) as u8;
Ok(KnockState {
knocker: repr.knocker,
spread,
knocker_deadwood,
laid_off: repr.laid_off,
})
}
impl TryFrom<RoundRepr> for Round {
type Error = InvalidRound;
#[allow(clippy::too_many_lines)]
fn try_from(repr: RoundRepr) -> Result<Self, InvalidRound> {
let mut seen = repr.hands[0];
if !(seen & repr.hands[1]).is_empty() {
return Err(InvalidRound::NotAPartition);
}
seen |= repr.hands[1];
for &card in repr.stock.iter().chain(&repr.discards) {
if !seen.insert(card) {
return Err(InvalidRound::NotAPartition);
}
}
if let Some(knock) = &repr.knock {
if !(seen & knock.laid_off).is_empty() {
return Err(InvalidRound::NotAPartition);
}
seen |= knock.laid_off;
}
if seen != Hand::ALL {
return Err(InvalidRound::NotAPartition);
}
let len = |player: Player| repr.hands[player as usize].len();
let turn = repr.turn;
if repr.passes > 2 {
return Err(InvalidRound::BadUpcardState);
}
if repr.forced_stock
&& !(repr.phase == Phase::Draw && repr.passes == 2 && repr.stock.len() == 31)
{
return Err(InvalidRound::PhaseMismatch);
}
if repr.taken_discard.is_some() && repr.phase != Phase::Discard {
return Err(InvalidRound::PhaseMismatch);
}
if repr.result.is_some() != (repr.phase == Phase::Finished) {
return Err(InvalidRound::PhaseMismatch);
}
let knock = match repr.phase {
Phase::Upcard => {
if repr.passes > 1
|| turn
!= if repr.passes == 0 {
repr.dealer.opponent()
} else {
repr.dealer
}
{
return Err(InvalidRound::BadUpcardState);
}
if repr.stock.len() != 31
|| repr.discards.len() != 1
|| len(Player::One) != 10
|| len(Player::Two) != 10
{
return Err(InvalidRound::WrongCounts);
}
if repr.knock.is_some() {
return Err(InvalidRound::PhaseMismatch);
}
None
}
Phase::Draw => {
if len(Player::One) != 10
|| len(Player::Two) != 10
|| repr.stock.len() < 3
|| repr.discards.is_empty()
{
return Err(InvalidRound::WrongCounts);
}
if repr.knock.is_some() {
return Err(InvalidRound::PhaseMismatch);
}
None
}
Phase::Discard => {
if len(turn) != 11 || len(turn.opponent()) != 10 || repr.stock.len() < 2 {
return Err(InvalidRound::WrongCounts);
}
if repr.knock.is_some() {
return Err(InvalidRound::PhaseMismatch);
}
if let Some(card) = repr.taken_discard
&& !repr.hands[turn as usize].contains(card)
{
return Err(InvalidRound::PhaseMismatch);
}
None
}
Phase::Layoff => {
let knock = repr.knock.as_ref().ok_or(InvalidRound::PhaseMismatch)?;
let state = validate_knock(knock, &repr.hands)?;
if state.knocker_deadwood == 0 {
return Err(InvalidRound::PhaseMismatch);
}
if state.knocker_deadwood > repr.rules.knock_limit {
return Err(InvalidRound::BadDeadwood);
}
if turn != state.knocker.opponent() {
return Err(InvalidRound::PhaseMismatch);
}
if len(state.knocker) != 10
|| len(turn) + state.laid_off.len() != 10
|| repr.stock.len() < 2
|| repr.discards.is_empty()
{
return Err(InvalidRound::WrongCounts);
}
Some(state)
}
Phase::Finished => {
let result = repr.result.ok_or(InvalidRound::PhaseMismatch)?;
match result {
RoundResult::Dead => {
if repr.knock.is_some() {
return Err(InvalidRound::PhaseMismatch);
}
if repr.stock.len() != 2
|| len(Player::One) != 10
|| len(Player::Two) != 10
{
return Err(InvalidRound::WrongCounts);
}
None
}
RoundResult::Knock { winner, margin }
| RoundResult::Undercut { winner, margin } => {
let knock = repr.knock.as_ref().ok_or(InvalidRound::PhaseMismatch)?;
let state = validate_knock(knock, &repr.hands)?;
let undercut = matches!(result, RoundResult::Undercut { .. });
let knocker = if undercut { winner.opponent() } else { winner };
if state.knocker != knocker || state.knocker_deadwood == 0 {
return Err(InvalidRound::BadResult);
}
if state.knocker_deadwood > repr.rules.knock_limit {
return Err(InvalidRound::BadDeadwood);
}
let defender = knocker.opponent();
if len(knocker) != 10
|| len(defender) + state.laid_off.len() != 10
|| repr.stock.len() < 2
{
return Err(InvalidRound::WrongCounts);
}
let defender_deadwood = deadwood(repr.hands[defender as usize]);
let expected = defender_deadwood < state.knocker_deadwood
|| (defender_deadwood == state.knocker_deadwood
&& repr.rules.undercut_on_tie);
let expected_margin = if undercut {
state.knocker_deadwood.wrapping_sub(defender_deadwood)
} else {
defender_deadwood.wrapping_sub(state.knocker_deadwood)
};
if undercut != expected || margin != expected_margin {
return Err(InvalidRound::BadResult);
}
Some(state)
}
RoundResult::Gin {
winner,
deadwood: loser,
}
| RoundResult::BigGin {
winner,
deadwood: loser,
} => {
let knock = repr.knock.as_ref().ok_or(InvalidRound::PhaseMismatch)?;
let state = validate_knock(knock, &repr.hands)?;
let big = matches!(result, RoundResult::BigGin { .. });
let expected_len = if big { 11 } else { 10 };
if state.knocker != winner
|| state.knocker_deadwood != 0
|| !state.laid_off.is_empty()
{
return Err(InvalidRound::BadResult);
}
if big && repr.rules.big_gin_bonus.is_none() {
return Err(InvalidRound::BadResult);
}
if len(winner) != expected_len
|| len(winner.opponent()) != 10
|| repr.stock.len() < 2
{
return Err(InvalidRound::WrongCounts);
}
if loser != deadwood(repr.hands[winner.opponent() as usize]) {
return Err(InvalidRound::BadResult);
}
Some(state)
}
}
}
};
Ok(Self {
rules: repr.rules,
dealer: repr.dealer,
hands: repr.hands,
stock: repr.stock,
discards: repr.discards,
phase: repr.phase,
turn: repr.turn,
passes: repr.passes,
forced_stock: repr.forced_stock,
taken_discard: repr.taken_discard,
knock,
result: repr.result,
})
}
}
}