dds_bridge/solver/board.rs
1//! Solving input: boards, tricks-in-progress, targets, and objectives
2
3use super::tricks::TrickCount;
4use crate::deal::PartialDeal;
5use crate::hand::{Card, Hand};
6use crate::seat::Seat;
7use crate::{Strain, Suit};
8
9use arrayvec::ArrayVec;
10use dds_bridge_sys as sys;
11use thiserror::Error;
12
13use core::ffi::c_int;
14
15/// Target tricks and number of solutions to find
16///
17/// Corresponds to the `target` and `solutions` arguments of
18/// [`sys::SolveBoard`]. The associated `Option<TrickCount>` selects between a
19/// minimum target (`Some`) and "find the most tricks" (`None`); the FFI `-1`
20/// sentinel is produced by [`Target::target`] and is not part of the public
21/// payload.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Target {
24 /// Find any card that fulfills the target
25 ///
26 /// - `Some(tc)`: any card scoring at least `tc` tricks
27 /// - `None`: any card scoring the most tricks
28 Any(Option<TrickCount>),
29
30 /// Find all cards that fulfill the target
31 ///
32 /// - `Some(tc)`: all cards scoring at least `tc` tricks
33 /// - `None`: all cards scoring the most tricks
34 All(Option<TrickCount>),
35
36 /// Solve for all legal plays
37 ///
38 /// Cards are sorted with their scores in descending order.
39 Legal,
40}
41
42impl Target {
43 /// Get the `target` argument for [`sys::SolveBoard`]
44 #[must_use]
45 #[inline]
46 pub const fn target(self) -> c_int {
47 match self {
48 Self::Any(Some(tc)) | Self::All(Some(tc)) => tc.get() as c_int,
49 Self::Any(None) | Self::All(None) | Self::Legal => -1,
50 }
51 }
52
53 /// Get the `solutions` argument for [`sys::SolveBoard`]
54 #[must_use]
55 #[inline]
56 pub const fn solutions(self) -> c_int {
57 match self {
58 Self::Any(_) => 1,
59 Self::All(_) => 2,
60 Self::Legal => 3,
61 }
62 }
63}
64
65/// Position of the revoking card within the current trick
66///
67/// The lead (first card) cannot revoke; these variants represent the
68/// subsequent seats in playing order.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum RevokePosition {
71 /// Second card of the trick (index 1)
72 Second,
73 /// Third card of the trick (index 2)
74 Third,
75 /// Fourth card of the trick (index 3)
76 Fourth,
77}
78
79impl core::fmt::Display for RevokePosition {
80 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
81 match self {
82 Self::Second => f.write_str("second"),
83 Self::Third => f.write_str("third"),
84 Self::Fourth => f.write_str("fourth"),
85 }
86 }
87}
88
89/// Error returned when constructing a [`Board`] with invalid invariants
90#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)]
91#[non_exhaustive]
92pub enum BoardError {
93 /// A card on the table is still present in one of the remaining hands
94 #[error("A played card is also present in a remaining hand")]
95 PlayedCardInHand,
96 /// The remaining hand sizes do not match the number of played cards
97 ///
98 /// With `k` cards on the table, exactly the `k` seats starting from
99 /// `leader` (in playing order) must have one fewer card than the other
100 /// seats; all other seats must share a common size.
101 #[error(
102 "Remaining hand sizes do not match the played-count pattern \
103 (the k seats from leader must have size m-1; others m)"
104 )]
105 InconsistentHandSizes,
106 /// A played card does not follow suit though the player held the led suit
107 #[error("Played card at {position} position is a revoke — player held the led suit")]
108 Revoke {
109 /// Position of the revoking card within the current trick
110 position: RevokePosition,
111 },
112}
113
114/// Error returned when pushing cards to a [`CurrentTrick`]
115#[derive(Debug, Error, Clone, Copy, PartialEq, Eq, Hash)]
116#[non_exhaustive]
117pub enum CurrentTrickError {
118 /// More than three cards are on the table
119 #[error("A trick can hold at most 3 cards on the table before it completes")]
120 TooManyPlayed,
121 /// The same card appears twice among the played cards
122 #[error("Duplicate card in the played cards on the table")]
123 DuplicatePlayedCard,
124}
125
126/// Trick-in-progress — 0 to 3 cards played, in playing order
127///
128/// Cards are played by the seats starting at [`leader`](Self::leader) in playing
129/// order: the first card by `leader`, the second by `leader.lho()`, and so on.
130///
131/// # Invariants
132///
133/// 1. At most 3 cards are stored (enforced by the backing `ArrayVec<Card, 3>`).
134/// 2. The stored cards are pairwise distinct.
135#[derive(Debug, Clone, PartialEq, Eq, Hash)]
136pub struct CurrentTrick {
137 trump: Strain,
138 leader: Seat,
139 cards: ArrayVec<Card, 3>,
140 seen: Hand,
141}
142
143impl CurrentTrick {
144 /// Empty trick led by `leader` under `trump`
145 #[must_use]
146 #[inline]
147 pub const fn new(trump: Strain, leader: Seat) -> Self {
148 Self {
149 trump,
150 leader,
151 cards: ArrayVec::new_const(),
152 seen: Hand::EMPTY,
153 }
154 }
155
156 /// Build from a slice, validating the 0–3-card length and pairwise
157 /// disjointness invariants.
158 ///
159 /// # Errors
160 ///
161 /// Returns a [`CurrentTrickError`] if the slice has more than 3 entries or
162 /// contains a duplicate card.
163 pub fn from_slice(
164 trump: Strain,
165 leader: Seat,
166 played: &[Card],
167 ) -> Result<Self, CurrentTrickError> {
168 let mut trick = Self::new(trump, leader);
169 for &card in played {
170 trick.try_push(card)?;
171 }
172 Ok(trick)
173 }
174
175 /// Append one card to the trick.
176 ///
177 /// # Errors
178 ///
179 /// Returns [`CurrentTrickError::TooManyPlayed`] if the trick already holds
180 /// 3 cards, or [`CurrentTrickError::DuplicatePlayedCard`] if `card` is
181 /// already in the trick.
182 pub fn try_push(&mut self, card: Card) -> Result<(), CurrentTrickError> {
183 if self.cards.is_full() {
184 return Err(CurrentTrickError::TooManyPlayed);
185 }
186 if !self.seen.insert(card) {
187 return Err(CurrentTrickError::DuplicatePlayedCard);
188 }
189 self.cards.push(card);
190 Ok(())
191 }
192
193 /// Strain of the contract governing this trick
194 #[must_use]
195 #[inline]
196 pub const fn trump(&self) -> Strain {
197 self.trump
198 }
199
200 /// Seat that led this trick
201 #[must_use]
202 #[inline]
203 pub const fn leader(&self) -> Seat {
204 self.leader
205 }
206
207 /// Cards played so far, in playing order
208 #[must_use]
209 #[inline]
210 pub fn cards(&self) -> &[Card] {
211 &self.cards
212 }
213
214 /// Number of cards played so far (0 to 3)
215 #[must_use]
216 #[inline]
217 pub const fn len(&self) -> usize {
218 self.cards.len()
219 }
220
221 /// Whether no cards have been played yet
222 #[must_use]
223 #[inline]
224 pub const fn is_empty(&self) -> bool {
225 self.cards.is_empty()
226 }
227
228 /// Bitmask union of the cards played so far
229 #[must_use]
230 #[inline]
231 pub const fn seen(&self) -> Hand {
232 self.seen
233 }
234
235 /// Suit led this trick, or `None` if no card has been played yet
236 #[must_use]
237 #[inline]
238 pub fn led_suit(&self) -> Option<Suit> {
239 self.cards.first().map(|c| c.suit)
240 }
241}
242
243/// A snapshot of a board
244///
245/// Construct via [`Board::try_new`], which handles both start-of-trick
246/// (use [`CurrentTrick::new`]) and mid-trick (0–3 played cards) cases. The
247/// invariants below are enforced by the constructor.
248///
249/// # Invariants
250///
251/// 1. `remaining` is a valid [`PartialDeal`] (≤13 cards per hand, pairwise
252/// disjoint).
253/// 2. Each card in the current trick is absent from every remaining hand (the
254/// "already played" invariant).
255/// 3. **Uniform-size-after-restoration**: putting the
256/// `k = current_trick.len()` table cards back into their players' hands
257/// yields a subset where all four hands share a common size `m`.
258/// Equivalently, the `k` seats starting at `current_trick.leader()` (in
259/// playing order: `leader`, `leader.lho()`, …) have size `m − 1` and the
260/// remaining `4 − k` seats have size `m`.
261#[derive(Debug, Clone, PartialEq, Eq, Hash)]
262pub struct Board {
263 current_trick: CurrentTrick,
264 remaining: PartialDeal,
265}
266
267impl Board {
268 /// Construct a mid-trick board from a pre-validated [`CurrentTrick`] and
269 /// the cards remaining in each hand.
270 ///
271 /// # Errors
272 ///
273 /// Returns a [`BoardError`] if the invariants documented on [`Board`] do
274 /// not hold.
275 pub fn try_new(
276 remaining: PartialDeal,
277 current_trick: CurrentTrick,
278 ) -> Result<Self, BoardError> {
279 if !(current_trick.seen() & remaining.collected()).is_empty() {
280 return Err(BoardError::PlayedCardInHand);
281 }
282
283 let leader = current_trick.leader();
284 let seats = [leader, leader.lho(), leader.partner(), leader.rho()];
285 let index = current_trick.len();
286 // Leader's RHO has not yet played this trick, so its hand length is the
287 // common "full" length we expect.
288 let full_len = remaining[leader.rho()].len();
289 for (j, &seat) in seats.iter().enumerate() {
290 if remaining[seat].len() + usize::from(j < index) != full_len {
291 return Err(BoardError::InconsistentHandSizes);
292 }
293 }
294
295 if let Some(led_suit) = current_trick.led_suit() {
296 for (j, played_card) in current_trick.cards().iter().enumerate().skip(1) {
297 if played_card.suit != led_suit && !remaining[seats[j]][led_suit].is_empty() {
298 return Err(BoardError::Revoke {
299 position: match j {
300 1 => RevokePosition::Second,
301 2 => RevokePosition::Third,
302 _ => RevokePosition::Fourth,
303 },
304 });
305 }
306 }
307 }
308
309 Ok(Self {
310 current_trick,
311 remaining,
312 })
313 }
314
315 /// Strain of the contract
316 #[must_use]
317 #[inline]
318 pub const fn trump(&self) -> Strain {
319 self.current_trick.trump()
320 }
321
322 /// Seat leading the current trick
323 #[must_use]
324 #[inline]
325 pub const fn leader(&self) -> Seat {
326 self.current_trick.leader()
327 }
328
329 /// Cards already played to the current trick, in playing order
330 #[must_use]
331 #[inline]
332 pub fn current_cards(&self) -> &[Card] {
333 self.current_trick.cards()
334 }
335
336 /// The current trick — cards played so far plus trump and leader
337 #[must_use]
338 #[inline]
339 pub const fn current_trick(&self) -> &CurrentTrick {
340 &self.current_trick
341 }
342
343 /// Remaining cards in each hand
344 #[must_use]
345 #[inline]
346 pub const fn remaining(&self) -> &PartialDeal {
347 &self.remaining
348 }
349}
350
351impl From<Board> for sys::deal {
352 fn from(board: Board) -> Self {
353 let mut suits = [0; 3];
354 let mut ranks = [0; 3];
355
356 for (i, card) in board.current_trick.cards().iter().enumerate() {
357 suits[i] = 3 - card.suit as c_int;
358 ranks[i] = c_int::from(card.rank.get());
359 }
360
361 Self {
362 trump: match board.current_trick.trump() {
363 Strain::Spades => 0,
364 Strain::Hearts => 1,
365 Strain::Diamonds => 2,
366 Strain::Clubs => 3,
367 Strain::Notrump => 4,
368 },
369 first: board.current_trick.leader() as c_int,
370 currentTrickSuit: suits,
371 currentTrickRank: ranks,
372 remainCards: sys::ddTableDeal::from(board.remaining).cards,
373 }
374 }
375}
376
377/// A board and its solving target
378#[derive(Debug, Clone, PartialEq, Eq, Hash)]
379pub struct Objective {
380 /// The board to solve
381 pub board: Board,
382 /// The target tricks and number of solutions to find
383 pub target: Target,
384}