Skip to main content

quantik_core/
board.rs

1use crate::bitboard::Bitboard;
2use crate::constants::MAX_PIECES_PER_SHAPE;
3use crate::game::{check_winner, current_player, WinStatus};
4use crate::moves::{apply_move, generate_legal_moves, is_move_legal, Move};
5use crate::qfen::{bb_from_qfen, bb_to_qfen};
6use crate::state::State;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum GameResult {
10    Ongoing,
11    Player0Wins,
12    Player1Wins,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct PlayerInventory {
17    pub remaining: [u8; 4], // shapes A, B, C, D
18}
19
20impl PlayerInventory {
21    pub fn full() -> Self {
22        Self {
23            remaining: [MAX_PIECES_PER_SHAPE; 4],
24        }
25    }
26
27    pub fn total(&self) -> u8 {
28        self.remaining.iter().sum()
29    }
30
31    pub fn has_shape(&self, shape: u8) -> bool {
32        self.remaining[shape as usize] > 0
33    }
34
35    pub fn use_shape(&mut self, shape: u8) {
36        debug_assert!(self.remaining[shape as usize] > 0);
37        self.remaining[shape as usize] -= 1;
38    }
39
40    pub fn return_shape(&mut self, shape: u8) {
41        debug_assert!(self.remaining[shape as usize] < MAX_PIECES_PER_SHAPE);
42        self.remaining[shape as usize] += 1;
43    }
44}
45
46struct MoveRecord {
47    mv: Move,
48    prev_bb: Bitboard,
49    prev_inventories: [PlayerInventory; 2],
50}
51
52/// High-level Quantik board with inventory tracking, move history, and undo.
53pub struct QuantikBoard {
54    bb: Bitboard,
55    inventories: [PlayerInventory; 2],
56    current_player: u8,
57    history: Vec<MoveRecord>,
58}
59
60impl QuantikBoard {
61    pub fn new() -> Self {
62        Self {
63            bb: Bitboard::EMPTY,
64            inventories: [PlayerInventory::full(), PlayerInventory::full()],
65            current_player: 0,
66            history: Vec::new(),
67        }
68    }
69
70    pub fn from_bitboard(bb: Bitboard) -> Result<Self, String> {
71        let cp = current_player(&bb).ok_or("Invalid turn balance")?;
72        let mut invs = [PlayerInventory::full(), PlayerInventory::full()];
73        for player in 0..2u8 {
74            for shape in 0..4u8 {
75                let used = bb.shape_piece_count(player, shape) as u8;
76                if used > MAX_PIECES_PER_SHAPE {
77                    return Err(format!(
78                        "Player {} has {} pieces of shape {} (max {})",
79                        player, used, shape, MAX_PIECES_PER_SHAPE
80                    ));
81                }
82                invs[player as usize].remaining[shape as usize] = MAX_PIECES_PER_SHAPE - used;
83            }
84        }
85        Ok(Self {
86            bb,
87            inventories: invs,
88            current_player: cp,
89            history: Vec::new(),
90        })
91    }
92
93    pub fn from_qfen(qfen: &str) -> Result<Self, String> {
94        let bb = bb_from_qfen(qfen)?;
95        Self::from_bitboard(bb)
96    }
97
98    // ── accessors ────────────────────────────────────────────────────
99
100    pub fn bitboard(&self) -> &Bitboard {
101        &self.bb
102    }
103
104    pub fn state(&self) -> State {
105        State::new(self.bb)
106    }
107
108    pub fn current_player(&self) -> u8 {
109        self.current_player
110    }
111
112    pub fn inventories(&self) -> &[PlayerInventory; 2] {
113        &self.inventories
114    }
115
116    pub fn move_count(&self) -> usize {
117        self.history.len()
118    }
119
120    pub fn last_move(&self) -> Option<&Move> {
121        self.history.last().map(|r| &r.mv)
122    }
123
124    pub fn to_qfen(&self) -> String {
125        bb_to_qfen(&self.bb)
126    }
127
128    // ── game status ──────────────────────────────────────────────────
129
130    pub fn game_result(&self) -> GameResult {
131        match check_winner(&self.bb) {
132            WinStatus::Player0Wins => return GameResult::Player0Wins,
133            WinStatus::Player1Wins => return GameResult::Player1Wins,
134            WinStatus::NoWin => {}
135        }
136        if self.legal_moves().is_empty() {
137            // stalemate → the player who cannot move loses
138            if self.current_player == 0 {
139                GameResult::Player1Wins
140            } else {
141                GameResult::Player0Wins
142            }
143        } else {
144            GameResult::Ongoing
145        }
146    }
147
148    pub fn is_game_over(&self) -> bool {
149        self.game_result() != GameResult::Ongoing
150    }
151
152    // ── move generation ──────────────────────────────────────────────
153
154    pub fn legal_moves(&self) -> Vec<Move> {
155        let raw = generate_legal_moves(&self.bb);
156        raw.into_iter()
157            .filter(|m| self.inventories[m.player as usize].has_shape(m.shape))
158            .collect()
159    }
160
161    pub fn is_legal(&self, mv: &Move) -> bool {
162        mv.player == self.current_player
163            && self.inventories[mv.player as usize].has_shape(mv.shape)
164            && is_move_legal(&self.bb, mv.player, mv.shape, mv.position)
165    }
166
167    // ── play / undo ──────────────────────────────────────────────────
168
169    pub fn play_move(&mut self, mv: Move) -> Result<(), String> {
170        if self.is_game_over() {
171            return Err("Game is already over".into());
172        }
173        if !self.is_legal(&mv) {
174            return Err("Illegal move".into());
175        }
176
177        let record = MoveRecord {
178            mv,
179            prev_bb: self.bb,
180            prev_inventories: self.inventories,
181        };
182
183        self.bb = apply_move(&self.bb, &mv);
184        self.inventories[mv.player as usize].use_shape(mv.shape);
185        self.current_player = 1 - self.current_player;
186        self.history.push(record);
187        Ok(())
188    }
189
190    pub fn undo_move(&mut self) -> bool {
191        if let Some(record) = self.history.pop() {
192            self.bb = record.prev_bb;
193            self.inventories = record.prev_inventories;
194            self.current_player = record.mv.player;
195            true
196        } else {
197            false
198        }
199    }
200
201    pub fn undo_moves(&mut self, count: usize) -> usize {
202        let mut undone = 0;
203        for _ in 0..count {
204            if self.undo_move() {
205                undone += 1;
206            } else {
207                break;
208            }
209        }
210        undone
211    }
212}
213
214impl Default for QuantikBoard {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220impl std::fmt::Display for QuantikBoard {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        writeln!(f, "QFEN: {}", self.to_qfen())?;
223        writeln!(f, "Current player: {}", self.current_player)?;
224        writeln!(f, "Move count: {}", self.move_count())?;
225        for p in 0..2 {
226            let inv = &self.inventories[p];
227            writeln!(
228                f,
229                "Player {} inventory: A={} B={} C={} D={}",
230                p, inv.remaining[0], inv.remaining[1], inv.remaining[2], inv.remaining[3]
231            )?;
232        }
233        let result = self.game_result();
234        if result != GameResult::Ongoing {
235            writeln!(f, "Game result: {:?}", result)?;
236        }
237        Ok(())
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn new_board_is_empty() {
247        let board = QuantikBoard::new();
248        assert_eq!(board.to_qfen(), "..../..../..../....");
249        assert_eq!(board.current_player(), 0);
250        assert_eq!(board.move_count(), 0);
251        assert!(!board.is_game_over());
252    }
253
254    #[test]
255    fn play_and_undo() {
256        let mut board = QuantikBoard::new();
257        let mv = Move::new(0, 0, 0);
258        board.play_move(mv).unwrap();
259        assert_eq!(board.current_player(), 1);
260        assert_eq!(board.move_count(), 1);
261        assert!(board.undo_move());
262        assert_eq!(board.current_player(), 0);
263        assert_eq!(board.move_count(), 0);
264    }
265
266    #[test]
267    fn inventory_decreases_on_play() {
268        let mut board = QuantikBoard::new();
269        assert_eq!(board.inventories()[0].remaining[0], 2);
270        board.play_move(Move::new(0, 0, 0)).unwrap();
271        assert_eq!(board.inventories()[0].remaining[0], 1);
272    }
273
274    #[test]
275    fn illegal_move_rejected() {
276        let mut board = QuantikBoard::new();
277        // wrong player
278        let mv = Move::new(1, 0, 0);
279        assert!(board.play_move(mv).is_err());
280    }
281
282    #[test]
283    fn from_qfen_round_trip() {
284        let board = QuantikBoard::from_qfen("A.../..../..../....").unwrap();
285        assert_eq!(board.current_player(), 1);
286        assert_eq!(board.inventories()[0].remaining[0], 1);
287    }
288
289    #[test]
290    fn win_detection() {
291        let mut board = QuantikBoard::new();
292        // Build a row-0 win: A(0,0) b(1,1) C(0,2) d(1,3)
293        // But we need to place them on row 0 without conflicting lines.
294        // p0 A@0, p1 b@1, p0 C@2, p1 d@3
295        board.play_move(Move::new(0, 0, 0)).unwrap(); // A at 0
296        board.play_move(Move::new(1, 1, 1)).unwrap(); // b at 1
297        board.play_move(Move::new(0, 2, 2)).unwrap(); // C at 2
298        board.play_move(Move::new(1, 3, 3)).unwrap(); // d at 3
299        assert!(board.is_game_over());
300    }
301}