Skip to main content

dds_bridge/
contract.rs

1//! Bidding, penalties, and scoring in contract bridge.
2//!
3//! [`Level`] wraps a `1..=7` contract level, [`Bid`] pairs a level with a
4//! [`Strain`], and [`Contract`] adds a [`Penalty`] (undoubled, doubled,
5//! redoubled).  [`Contract::score`] returns the duplicate score for a given
6//! trick count and vulnerability.
7//!
8//! # Panic policy
9//!
10//! The safe constructors that take raw levels — [`Level::new`], [`Bid::new`],
11//! and [`Contract::new`] — panic when the level is outside `1..=7`, and have
12//! [`Level::try_new`] for fallible construction.  In const contexts the panic
13//! becomes a compile-time error.
14
15use crate::Strain;
16use core::fmt::{self, Write as _};
17use core::num::NonZero;
18use core::str::FromStr;
19use thiserror::Error;
20
21/// Error indicating an invalid level
22///
23/// The level of a contract must be in `1..=7`
24#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
25#[error("{0} is not a valid level (1..=7)")]
26pub struct InvalidLevel(u8);
27
28/// The level of a contract, from 1 to 7
29///
30/// The number of tricks (adding the book of 6 tricks) to take to fulfill
31/// the contract
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[cfg_attr(feature = "serde", serde(transparent))]
35#[repr(transparent)]
36pub struct Level(NonZero<u8>);
37
38impl Level {
39    /// Create a level from a number of tricks
40    ///
41    /// # Panics
42    ///
43    /// When the level is not in `1..=7`.  In const contexts, this is a
44    /// compile-time error.
45    #[must_use]
46    #[inline]
47    pub const fn new(level: u8) -> Self {
48        match Self::try_new(level) {
49            Ok(l) => l,
50            Err(_) => panic!("level must be in 1..=7"),
51        }
52    }
53
54    /// Try to create a level from a number of tricks
55    ///
56    /// # Errors
57    ///
58    /// When the level is not in `1..=7`.
59    #[inline]
60    pub const fn try_new(level: u8) -> Result<Self, InvalidLevel> {
61        match NonZero::new(level) {
62            Some(nonzero) if level <= 7 => Ok(Self(nonzero)),
63            _ => Err(InvalidLevel(level)),
64        }
65    }
66
67    /// Get the stored level as [`u8`]
68    #[must_use]
69    #[inline]
70    pub const fn get(self) -> u8 {
71        self.0.get()
72    }
73}
74
75impl fmt::Display for Level {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        self.get().fmt(f)
78    }
79}
80
81/// Error returned when parsing a [`Level`] fails
82#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
83#[error("Invalid level: expected 1-7")]
84pub struct ParseLevelError;
85
86impl FromStr for Level {
87    type Err = ParseLevelError;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        let s = s.as_bytes();
91
92        match (s.len(), s.first()) {
93            (1, Some(b'1'..=b'7')) => Ok(Self::new(s[0] - b'0')),
94            _ => Err(ParseLevelError),
95        }
96    }
97}
98
99/// A call that proposes a contract
100///
101/// The order of the fields ensures natural ordering by deriving [`PartialOrd`]
102/// and [`Ord`].
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
104#[cfg_attr(
105    feature = "serde",
106    derive(serde_with::SerializeDisplay, serde_with::DeserializeFromStr)
107)]
108pub struct Bid {
109    /// The level of the contract
110    pub level: Level,
111    /// The strain of the contract
112    pub strain: Strain,
113}
114
115impl Bid {
116    /// Create a bid from a level and a strain
117    ///
118    /// # Panics
119    ///
120    /// When the level is not in `1..=7`.  In const contexts, this is a
121    /// compile-time error.
122    #[must_use]
123    #[inline]
124    pub const fn new(level: u8, strain: Strain) -> Self {
125        Self {
126            level: Level::new(level),
127            strain,
128        }
129    }
130}
131
132impl fmt::Display for Bid {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(f, "{}{}", self.level, self.strain)
135    }
136}
137
138/// Error returned when parsing a [`Bid`] fails
139#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
140#[error("Invalid bid: expected <level><strain>, e.g. 1N, 3♠, 7NT")]
141pub struct ParseBidError;
142
143impl FromStr for Bid {
144    type Err = ParseBidError;
145
146    fn from_str(s: &str) -> Result<Self, Self::Err> {
147        if s.len() < 2 {
148            return Err(ParseBidError);
149        }
150        // Level is always 1 char, strain is the rest
151        let (level, strain) = s.split_at(1);
152        let level: Level = level.parse().map_err(|_| ParseBidError)?;
153        let strain: Strain = strain.parse().map_err(|_| ParseBidError)?;
154        Ok(Self { level, strain })
155    }
156}
157
158/// Penalty inflicted on a contract
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
160#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
161#[repr(u8)]
162pub enum Penalty {
163    /// No penalty
164    Undoubled,
165    /// Penalty by doubling
166    Doubled,
167    /// Penalty by redoubling
168    Redoubled,
169}
170
171impl fmt::Display for Penalty {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        match self {
174            Self::Undoubled => Ok(()),
175            Self::Doubled => f.write_char('x'),
176            Self::Redoubled => f.write_str("xx"),
177        }
178    }
179}
180
181/// Error returned when parsing [`Penalty`] fails
182#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
183#[error("Invalid penalty: expected '', 'x'/'X', or 'xx'/'XX'")]
184pub struct ParsePenaltyError;
185
186impl FromStr for Penalty {
187    type Err = ParsePenaltyError;
188
189    fn from_str(s: &str) -> Result<Self, Self::Err> {
190        match s.to_ascii_lowercase().as_str() {
191            "" => Ok(Self::Undoubled),
192            "x" => Ok(Self::Doubled),
193            "xx" => Ok(Self::Redoubled),
194            _ => Err(ParsePenaltyError),
195        }
196    }
197}
198
199/// The statement of the pair winning the bidding that they will take at least
200/// the number of tricks (in addition to the book of 6 tricks), and the strain
201/// denotes the trump suit.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
203#[cfg_attr(
204    feature = "serde",
205    derive(serde_with::SerializeDisplay, serde_with::DeserializeFromStr)
206)]
207pub struct Contract {
208    /// The basic part of a contract
209    pub bid: Bid,
210    /// The penalty inflicted on the contract
211    pub penalty: Penalty,
212}
213
214impl fmt::Display for Contract {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        write!(f, "{}{}", self.bid, self.penalty)
217    }
218}
219
220impl From<Bid> for Contract {
221    fn from(bid: Bid) -> Self {
222        Self {
223            bid,
224            penalty: Penalty::Undoubled,
225        }
226    }
227}
228
229const fn compute_doubled_penalty(undertricks: i32, vulnerable: bool) -> i32 {
230    match undertricks + vulnerable as i32 {
231        1 => 100,
232        2 => {
233            if vulnerable {
234                200
235            } else {
236                300
237            }
238        }
239        many => 300 * many - 400,
240    }
241}
242
243impl Contract {
244    /// Create a contract from a level, strain, and penalty
245    ///
246    /// # Panics
247    ///
248    /// When the level is not in `1..=7`.  In const contexts, this is a
249    /// compile-time error.
250    #[must_use]
251    #[inline]
252    pub const fn new(level: u8, strain: Strain, penalty: Penalty) -> Self {
253        Self {
254            bid: Bid::new(level, strain),
255            penalty,
256        }
257    }
258
259    /// Base score for making this contract
260    ///
261    /// <https://en.wikipedia.org/wiki/Bridge_scoring#Contract_points>
262    #[must_use]
263    #[inline]
264    pub const fn contract_points(self) -> i32 {
265        let level = self.bid.level.get() as i32;
266        let per_trick = self.bid.strain.is_minor() as i32 * -10 + 30;
267        let notrump = self.bid.strain.is_notrump() as i32 * 10;
268        (per_trick * level + notrump) << (self.penalty as u8)
269    }
270
271    /// Score for this contract given the number of taken tricks and
272    /// vulnerability
273    ///
274    /// The `vulnerable` parameter refers to the *declaring* side's
275    /// vulnerability, not the defenders'.
276    ///
277    /// The score is positive if the declarer makes the contract, and negative
278    /// if the declarer fails.
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use dds_bridge::{Contract, Penalty, Strain};
284    ///
285    /// // 4♠ making exactly, not vulnerable: 120 (contract) + 300 (game) = 420
286    /// let four_spades = Contract::new(4, Strain::Spades, Penalty::Undoubled);
287    /// assert_eq!(four_spades.score(10, false), 420);
288    ///
289    /// // 3NT with one overtrick, vulnerable: 100 + 500 (game) + 30 (overtrick) = 630
290    /// let three_nt = Contract::new(3, Strain::Notrump, Penalty::Undoubled);
291    /// assert_eq!(three_nt.score(10, true), 630);
292    ///
293    /// // 3NT doubled, down one, vulnerable: -200
294    /// let three_nt_x = Contract::new(3, Strain::Notrump, Penalty::Doubled);
295    /// assert_eq!(three_nt_x.score(8, true), -200);
296    /// ```
297    #[must_use]
298    #[inline]
299    pub const fn score(self, tricks: u8, vulnerable: bool) -> i32 {
300        let overtricks = tricks as i32 - self.bid.level.get() as i32 - 6;
301
302        if overtricks >= 0 {
303            let base = self.contract_points();
304            let game = if base < 100 {
305                50
306            } else if vulnerable {
307                500
308            } else {
309                300
310            };
311            let doubled = self.penalty as i32 * 50;
312
313            let slam = match self.bid.level.get() {
314                6 => (vulnerable as i32 + 2) * 250,
315                7 => (vulnerable as i32 + 2) * 500,
316                _ => 0,
317            };
318
319            let per_trick = match self.penalty {
320                Penalty::Undoubled => self.bid.strain.is_minor() as i32 * -10 + 30,
321                penalty => penalty as i32 * if vulnerable { 200 } else { 100 },
322            };
323
324            base + game + slam + doubled + overtricks * per_trick
325        } else {
326            match self.penalty {
327                Penalty::Undoubled => overtricks * if vulnerable { 100 } else { 50 },
328                penalty => penalty as i32 * -compute_doubled_penalty(-overtricks, vulnerable),
329            }
330        }
331    }
332}
333
334/// Error returned when parsing a [`Contract`] fails
335#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
336#[error("Invalid contract: expected <bid><penalty>, e.g. 1♥, 3♠x, 7NTxx")]
337pub struct ParseContractError;
338
339impl FromStr for Contract {
340    type Err = ParseContractError;
341
342    fn from_str(s: &str) -> Result<Self, Self::Err> {
343        let x_count = s
344            .bytes()
345            .rev()
346            .take_while(|c| b'x'.eq_ignore_ascii_case(c))
347            .take(3)
348            .count();
349
350        let penalty = match x_count {
351            0 => Penalty::Undoubled,
352            1 => Penalty::Doubled,
353            2 => Penalty::Redoubled,
354            _ => return Err(ParseContractError),
355        };
356
357        s[..s.len() - x_count]
358            .parse()
359            .map_or(Err(ParseContractError), |bid| Ok(Self { bid, penalty }))
360    }
361}