gin_rummy_engine/view.rs
1//! The [`View`]: what one seat may legally see of a round
2//!
3//! [`Round`] exposes the whole position — both hands and the stock order —
4//! and leaves information hygiene to its consumers. This module is that
5//! hygiene: a [`View`] borrows the round privately and re-exposes only the
6//! whitelist of legally visible information, paired with the per-seat
7//! [`Knowledge`] that the driver accumulates and that no round snapshot can
8//! recover (which discards the opponent took, what they declined, whether
9//! this turn's draw is forced from the stock).
10
11use gin_rummy::{Card, Hand, Meld, Melds, Phase, Player, Round, Rules, best_melds, deadwood};
12
13/// What a seat has learned beyond the public table state
14///
15/// Owned and updated by the driver as actions are applied; a `Round`
16/// snapshot alone cannot reconstruct it.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub(crate) struct Knowledge {
19 /// Cards known to be in the opponent's hand: taken from the pile and
20 /// not yet shed or laid off
21 pub(crate) opponent_known: Hand,
22 /// Every card the opponent has discarded, whether or not it is still
23 /// in the pile
24 pub(crate) opponent_shed: Hand,
25 /// Upcards the opponent declined during the upcard phase
26 pub(crate) opponent_passed: Hand,
27 /// The card this seat took from the pile this turn, which may not be
28 /// shed until the next turn
29 pub(crate) taken_discard: Option<Card>,
30 /// The next draw must come from the stock (both players passed the
31 /// upcard)
32 pub(crate) forced_stock: bool,
33}
34
35/// What one seat may legally see of a round
36///
37/// The wrapped [`Round`] is private, and these accessors are the whitelist:
38/// the opponent's hand and the stock order are structurally unreachable.
39pub struct View<'a> {
40 round: &'a Round,
41 seat: Player,
42 know: &'a Knowledge,
43 /// Running game totals, this seat's first
44 scores: [u16; 2],
45}
46
47impl<'a> View<'a> {
48 pub(crate) const fn new(
49 round: &'a Round,
50 seat: Player,
51 know: &'a Knowledge,
52 scores: [u16; 2],
53 ) -> Self {
54 Self {
55 round,
56 seat,
57 know,
58 scores,
59 }
60 }
61
62 /// The seat this view belongs to
63 #[must_use]
64 pub const fn seat(&self) -> Player {
65 self.seat
66 }
67
68 /// This seat's lead in the game score: its running total minus the
69 /// opponent's, in points
70 ///
71 /// Positive when ahead in the game, negative when behind. Always zero
72 /// for a standalone round played outside a [`Game`](gin_rummy::Game),
73 /// which has no scoreboard. The game score is public — both players
74 /// see it — so it is part of the legal whitelist. The target to win
75 /// is [`rules().game_target`](gin_rummy::Rules::game_target).
76 #[must_use]
77 pub const fn game_margin(&self) -> i32 {
78 self.scores[0] as i32 - self.scores[1] as i32
79 }
80
81 /// The running game totals, this seat's first: `[mine, theirs]`
82 ///
83 /// Note the order: seat-relative, unlike [`Table::scores`], which is
84 /// indexed by [`Player`]. Both totals sit on the scoreboard in plain
85 /// sight, so they are part of the legal whitelist. `[0, 0]` for a
86 /// standalone round played outside a [`Game`](gin_rummy::Game); the
87 /// target to win is
88 /// [`rules().game_target`](gin_rummy::Rules::game_target).
89 ///
90 /// [`Table::scores`]: crate::Table::scores
91 #[must_use]
92 pub const fn game_scores(&self) -> [u16; 2] {
93 self.scores
94 }
95
96 /// The scoring rules of the round
97 #[must_use]
98 pub const fn rules(&self) -> &'a Rules {
99 self.round.rules()
100 }
101
102 /// The dealer of the round
103 #[must_use]
104 pub const fn dealer(&self) -> Player {
105 self.round.dealer()
106 }
107
108 /// The knock limit in effect
109 #[must_use]
110 pub const fn knock_limit(&self) -> u8 {
111 self.round.knock_limit()
112 }
113
114 /// The current phase of the round
115 #[must_use]
116 pub const fn phase(&self) -> Phase {
117 self.round.phase()
118 }
119
120 /// The discard pile, oldest first — the last card is the top
121 #[must_use]
122 pub fn discard_pile(&self) -> &'a [Card] {
123 self.round.discard_pile()
124 }
125
126 /// The top of the discard pile
127 #[must_use]
128 pub fn upcard(&self) -> Option<Card> {
129 self.round.discard_pile().last().copied()
130 }
131
132 /// How many cards remain in the stock — the order is never visible
133 #[must_use]
134 pub fn stock_len(&self) -> usize {
135 self.round.stock().len()
136 }
137
138 /// The knocker's spread, extended by any layoffs so far
139 ///
140 /// Empty before a knock. Enumeration order gives the meld indices that
141 /// [`Layoff::meld`](crate::Layoff::meld) addresses.
142 pub fn spread(&self) -> impl Iterator<Item = Meld> + 'a {
143 self.round.spread()
144 }
145
146 /// The knocker, if the round has reached a knock
147 #[must_use]
148 pub const fn knocker(&self) -> Option<Player> {
149 self.round.knocker()
150 }
151
152 /// This seat's hand
153 #[must_use]
154 pub const fn hand(&self) -> Hand {
155 self.round.hand(self.seat)
156 }
157
158 /// The card this seat took from the pile this turn, which may not be
159 /// shed until the next turn
160 #[must_use]
161 pub const fn taken_discard(&self) -> Option<Card> {
162 self.know.taken_discard
163 }
164
165 /// Whether taking the top of the discard pile is available
166 ///
167 /// True at the upcard offer and on a normal draw, false on the forced
168 /// stock draw after both players pass the upcard.
169 #[must_use]
170 pub const fn can_take_discard(&self) -> bool {
171 match self.round.phase() {
172 Phase::Upcard => true,
173 Phase::Draw => !self.know.forced_stock,
174 Phase::Discard | Phase::Layoff | Phase::Finished => false,
175 }
176 }
177
178 /// Cards known to be in the opponent's hand: taken from the pile and
179 /// not yet shed or laid off
180 #[must_use]
181 pub const fn opponent_known(&self) -> Hand {
182 self.know.opponent_known
183 }
184
185 /// Every card the opponent has discarded, whether or not it is still in
186 /// the pile
187 #[must_use]
188 pub const fn opponent_shed(&self) -> Hand {
189 self.know.opponent_shed
190 }
191
192 /// Upcards the opponent declined during the upcard phase
193 #[must_use]
194 pub const fn opponent_passed(&self) -> Hand {
195 self.know.opponent_passed
196 }
197
198 /// How many cards the opponent holds — 10, or 11 mid-turn
199 #[must_use]
200 pub const fn opponent_hand_len(&self) -> usize {
201 self.round.hand(self.seat.opponent()).len()
202 }
203
204 /// The cards this seat cannot locate: the whole deck minus its own
205 /// hand, the discard pile, the opponent's known cards, and the spread
206 ///
207 /// Exactly the cards a determinizing bot must distribute between the
208 /// stock and the hidden part of the opponent's hand. Until a knock
209 /// reveals the spread,
210 /// `unseen().len() == stock_len() + opponent_hand_len() -
211 /// opponent_known().len()`; afterwards the spread also counts as seen.
212 #[must_use]
213 pub fn unseen(&self) -> Hand {
214 let seen = self
215 .round
216 .discard_pile()
217 .iter()
218 .fold(self.hand() | self.know.opponent_known, |acc, &card| {
219 acc | card.into()
220 });
221 let seen = self
222 .round
223 .spread()
224 .fold(seen, |acc, meld| acc | meld.cards());
225 Hand::ALL - seen
226 }
227
228 /// The minimum deadwood of this seat's hand
229 #[must_use]
230 pub fn deadwood(&self) -> u8 {
231 deadwood(self.hand())
232 }
233
234 /// A deadwood-minimizing arrangement of this seat's hand
235 #[must_use]
236 pub fn best_melds(&self) -> Melds {
237 best_melds(self.hand())
238 }
239}