Skip to main content

gin_rummy/
rules.rs

1//! Scoring configuration.
2//!
3//! Gin rummy bonuses vary by rule school, so every value is an independent
4//! knob on [`Rules`].  Three presets cover the common schools: modern
5//! tournament values ([`Rules::new`], the default), the classic Bicycle
6//! rules ([`Rules::classic`]), and the Gin Rummy Palace app
7//! ([`Rules::palace`]).
8//!
9//! `Rules` is `#[non_exhaustive]`: start from a preset and adjust fields,
10//! e.g. `Rules { game_target: 250, ..Rules::default() }` does not compile,
11//! but mutating `rules.game_target = 250;` does.  This keeps room for future
12//! variants (an Oklahoma spade multiplier, Hollywood scoring) without
13//! breakage.
14//!
15//! Two variants ride on existing knobs: Oklahoma gin is
16//! [`Rules::oklahoma`], and straight gin — no knocking short of gin — is
17//! exactly `knock_limit: 0`.
18
19use crate::Card;
20
21/// What happens to the winner's total when the loser scored nothing
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[non_exhaustive]
25pub enum Shutout {
26    /// The winner's final total is doubled (modern schools)
27    Double,
28    /// A flat bonus is added, e.g. 100 in the classic rules — `Flat(0)`
29    /// disables the shutout rule entirely
30    Flat(u16),
31}
32
33/// How an ace upcard sets the knock limit in Oklahoma gin
34///
35/// Pagat gives the base rule as the upcard's value, so an ace allows a
36/// 1-point knock, and records that some tables instead demand gin
37/// outright.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40#[non_exhaustive]
41pub enum OklahomaAce {
42    /// The ace counts its pip value: knock at 1 or less
43    One,
44    /// The ace demands gin: the knock limit is 0
45    GinOnly,
46}
47
48/// The scoring rules of a game
49///
50/// All fields are public knobs; see the [module documentation](self) for the
51/// preset-and-adjust pattern.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[non_exhaustive]
55pub struct Rules {
56    /// The most deadwood a knocker may keep: 10 in every common school,
57    /// and `0` plays straight gin — only a gin knock ends the round
58    pub knock_limit: u8,
59
60    /// Oklahoma gin: the opening upcard caps the knock limit at its
61    /// deadwood value (pictures 10, aces per [`OklahomaAce`]), or `None`
62    /// for a fixed [`knock_limit`](Self::knock_limit)
63    ///
64    /// No preset turns this on; enable it atop any of them.  Some tables
65    /// also double the hand score on a spade upcard — not modeled yet.
66    #[cfg_attr(feature = "serde", serde(default))]
67    pub oklahoma: Option<OklahomaAce>,
68
69    /// Bonus for going gin: 25 modern and palace, 20 classic
70    pub gin_bonus: u16,
71
72    /// Bonus for big gin — 11 melded cards declared instead of discarding —
73    /// or `None` where the variant is not played (classic, palace)
74    pub big_gin_bonus: Option<u16>,
75
76    /// Bonus for undercutting the knocker: 25 modern, 10 classic, 20 palace
77    pub undercut_bonus: u16,
78
79    /// Whether the defender undercuts on equal deadwood (all three presets
80    /// say yes; some traditional tables require strictly less)
81    pub undercut_on_tie: bool,
82
83    /// Bonus per won hand: 25 modern, 20 classic, 10 palace
84    pub box_bonus: u16,
85
86    /// When `true`, the box bonus is credited to the running score as each
87    /// hand is won, counting toward [`game_target`](Self::game_target) (Gin
88    /// Rummy Palace); when `false`, boxes are tallied only at game end
89    /// (traditional)
90    pub immediate_boxes: bool,
91
92    /// Bonus to the winner of the game: 100 traditionally, 0 palace
93    pub game_bonus: u16,
94
95    /// The score that ends the game: 100 traditionally; house games play to
96    /// 150 or 250, and Gin Rummy Palace offers 10 through 500
97    pub game_target: u16,
98
99    /// The shutout (blitz, schneider) rule for games whose loser never
100    /// scored
101    pub shutout: Shutout,
102}
103
104impl Rules {
105    /// Modern tournament scoring, the default
106    #[must_use]
107    #[inline]
108    pub const fn new() -> Self {
109        Self {
110            knock_limit: 10,
111            oklahoma: None,
112            gin_bonus: 25,
113            big_gin_bonus: Some(31),
114            undercut_bonus: 25,
115            undercut_on_tie: true,
116            box_bonus: 25,
117            immediate_boxes: false,
118            game_bonus: 100,
119            game_target: 100,
120            shutout: Shutout::Double,
121        }
122    }
123
124    /// Classic scoring per Bicycle: smaller bonuses, no big gin, and a flat
125    /// 100-point shutout bonus
126    #[must_use]
127    #[inline]
128    pub const fn classic() -> Self {
129        Self {
130            gin_bonus: 20,
131            big_gin_bonus: None,
132            undercut_bonus: 10,
133            box_bonus: 20,
134            shutout: Shutout::Flat(100),
135            ..Self::new()
136        }
137    }
138
139    /// Gin Rummy Palace app scoring: boxes of 10 credited immediately, no
140    /// game or shutout bonus
141    ///
142    /// The app lets players set the target between 10 and 500; adjust
143    /// [`game_target`](Self::game_target) to taste.
144    #[must_use]
145    #[inline]
146    pub const fn palace() -> Self {
147        Self {
148            big_gin_bonus: None,
149            undercut_bonus: 20,
150            box_bonus: 10,
151            immediate_boxes: true,
152            game_bonus: 0,
153            shutout: Shutout::Flat(0),
154            ..Self::new()
155        }
156    }
157
158    /// The knock limit in effect for a round opened by `initial_upcard`
159    ///
160    /// Under [`oklahoma`](Self::oklahoma) the upcard's deadwood value caps
161    /// [`knock_limit`](Self::knock_limit) — the house limit only ever
162    /// tightens, never widens; without Oklahoma it passes through
163    /// unchanged.  [`Round::knock_limit`](crate::Round::knock_limit)
164    /// resolves this per round.
165    #[must_use]
166    #[inline]
167    pub const fn knock_limit_for(&self, initial_upcard: Card) -> u8 {
168        let value = match self.oklahoma {
169            None => return self.knock_limit,
170            Some(OklahomaAce::GinOnly) if initial_upcard.rank.get() == 1 => 0,
171            Some(_) => initial_upcard.rank.deadwood(),
172        };
173        if value < self.knock_limit {
174            value
175        } else {
176            self.knock_limit
177        }
178    }
179}
180
181impl Default for Rules {
182    #[inline]
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn presets() {
194        let modern = Rules::default();
195        assert_eq!(modern, Rules::new());
196        assert_eq!(modern.knock_limit, 10);
197        assert_eq!(modern.gin_bonus, 25);
198        assert_eq!(modern.big_gin_bonus, Some(31));
199        assert_eq!(modern.undercut_bonus, 25);
200        assert_eq!(modern.box_bonus, 25);
201        assert!(!modern.immediate_boxes);
202        assert_eq!(modern.game_bonus, 100);
203        assert_eq!(modern.game_target, 100);
204        assert_eq!(modern.shutout, Shutout::Double);
205
206        let classic = Rules::classic();
207        assert_eq!(classic.gin_bonus, 20);
208        assert_eq!(classic.big_gin_bonus, None);
209        assert_eq!(classic.undercut_bonus, 10);
210        assert_eq!(classic.box_bonus, 20);
211        assert!(!classic.immediate_boxes);
212        assert_eq!(classic.shutout, Shutout::Flat(100));
213
214        let palace = Rules::palace();
215        assert_eq!(palace.gin_bonus, 25);
216        assert_eq!(palace.big_gin_bonus, None);
217        assert_eq!(palace.undercut_bonus, 20);
218        assert_eq!(palace.box_bonus, 10);
219        assert!(palace.immediate_boxes);
220        assert_eq!(palace.game_bonus, 0);
221        assert_eq!(palace.shutout, Shutout::Flat(0));
222
223        for rules in [modern, classic, palace] {
224            assert_eq!(rules.knock_limit, 10);
225            assert_eq!(rules.oklahoma, None);
226            assert!(rules.undercut_on_tie);
227            assert_eq!(rules.game_target, 100);
228        }
229    }
230
231    #[test]
232    fn oklahoma_knock_limits() {
233        let card = |s: &str| s.parse::<Card>().unwrap();
234        let mut rules = Rules::default();
235        assert_eq!(rules.knock_limit_for(card("7♦")), 10);
236
237        rules.oklahoma = Some(OklahomaAce::One);
238        assert_eq!(rules.knock_limit_for(card("7♦")), 7);
239        assert_eq!(rules.knock_limit_for(card("Q♥")), 10);
240        assert_eq!(rules.knock_limit_for(card("A♣")), 1);
241
242        rules.oklahoma = Some(OklahomaAce::GinOnly);
243        assert_eq!(rules.knock_limit_for(card("7♦")), 7);
244        assert_eq!(rules.knock_limit_for(card("A♣")), 0);
245
246        // The upcard only caps the limit; a stricter house limit stays.
247        rules.knock_limit = 3;
248        assert_eq!(rules.knock_limit_for(card("7♦")), 3);
249    }
250}