Skip to main content

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}