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}