Skip to main content

chess/
id.rs

1pub mod base58;
2pub mod basis;
3pub use basis::{Basis, POLYGLOT, STANDARD};
4
5// Our "standard basis" has 802 "consistent" Ids:
6// - board: 64 * 2 * 6 = 768
7// - turn: 2
8// - castle: 2 * 8 = 16
9// - en-passant: 16 squares
10//
11// Polyglot basis has 781 "random" IDs:
12// - pieces: 12 * 64 = 768
13// - side to move: 1
14// - castling: 4
15// - en-passant files: 8
16// We set zeros/repeat to achieve compatibility.
17
18use crate::{
19    board::{Board, Player, Role},
20    game::Game,
21    position::{Castles, EnPassant, Position, Side},
22};
23
24/// Type of globally unique identifiers.
25#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
26pub struct Id(pub u128);
27
28impl Id {
29    pub const fn xor(self, other: Id) -> Id {
30        Id(self.0 ^ other.0)
31    }
32
33    pub const fn u128(self) -> u128 {
34        self.0
35    }
36
37    pub fn nonce() -> Self {
38        let nonce = base58::Base58::random_str(12);
39        let bytes = nonce.as_bytes();
40        let mut id = [0; 16];
41        let start = id.len() - bytes.len();
42        id[start..].copy_from_slice(bytes);
43        Id(u128::from_be_bytes(id))
44    }
45}
46
47type U256 = [u8; 32];
48
49pub const fn hash(bytes: &[u8]) -> Id {
50    Id(fold(sha256(bytes)))
51}
52
53const fn sha256(bytes: &[u8]) -> U256 {
54    sha2_const::Sha256::new().update(bytes).finalize()
55}
56
57const fn fold(hash: U256) -> u128 {
58    let (upper, lower) = split(hash);
59    upper ^ lower
60}
61
62const fn split(hash: U256) -> (u128, u128) {
63    let mut upper = [0u8; 16];
64    let mut lower = [0u8; 16];
65
66    let mut i = 0;
67    while i < 16 {
68        upper[i] = hash[i];
69        lower[i] = hash[i + 16];
70        i += 1;
71    }
72
73    (u128::from_be_bytes(upper), u128::from_be_bytes(lower))
74}
75
76impl Game {
77    // Experimental: A globally unique ID for all games
78    pub fn id(&self) -> Id {
79        use crate::game::cursor::Mainline;
80
81        let mut hash = sha2_const::Sha256::new()
82            .update(b"game:")
83            .update(&self.start().id().u128().to_be_bytes());
84
85        for play in Mainline::new(self) {
86            hash = hash.update(b":").update(play.play().uci_960().to_string().as_bytes());
87        }
88
89        Id(fold(hash.finalize()))
90    }
91
92    // // TODO: This is probably a bad idea
93    // //
94    // // start:2iHiqJgL4hH1Qqqng6VaDF:move:e2e4 => AipYeC9ie6GSVAKwJsVkvB
95    // // play:MSVFqF2aDcnN8CziQZQmqY:move:e7e5 => J3bJ2SruB518B2qn9QKKgs
96    // // play:8pxD8BKK3A9XZuDzrcuexd:move:g1f3 => 26BuaaRqhKHZVs9YRHvLzb
97    // // play:4r2FcZ9jEJukZ3wEGPn3h:move:e7e8q => C6VatJhT8ZjDrsToNGJh4N
98    // fn play_id(&self, previous: Option<Id>, play: Move) -> Id {
99    //     if let Some(previous) = previous {
100    //         hash(format!("play:{previous}:move:{}", play.uci()).as_bytes())
101    //     } else {
102    //         hash(format!("start:{}:move:{}", self.start().id(), play.uci()).as_bytes())
103    //     }
104    // }
105}
106
107impl Position {
108    // standard ID plus counters
109    pub fn id(self) -> Id {
110        self.standard_id()
111            .xor(counter_id("reversible", self.reversible()))
112            .xor(counter_id("round", self.round().get()))
113    }
114
115    // our 128-bit replacement of the classical Polyglot hash
116    pub const fn standard_id(self) -> Id {
117        self.transposition_id(&STANDARD)
118    }
119
120    // the classical Polyglot hash, just embedded into u128
121    pub const fn polyglot_id(self) -> Id {
122        self.transposition_id(&POLYGLOT)
123    }
124
125    pub const fn transposition_id(self, basis: &Basis) -> Id {
126        // In validated positions, en_passant is normalized to effective rights.
127        self.apparent_id(basis)
128            .xor(castle_id(basis, self.castles()))
129            .xor(en_passant_id(basis, self.en_passant()))
130    }
131    // What one typically sees in a depicted position: The board, and the player to move
132    pub const fn apparent_id(self, basis: &Basis) -> Id {
133        self.board().id(basis).xor(turn_id(basis, self.turn()))
134    }
135}
136
137impl Board {
138    pub const fn polyglot_id(self) -> Id {
139        self.id(&POLYGLOT)
140    }
141
142    pub const fn standard_id(self) -> Id {
143        self.id(&STANDARD)
144    }
145
146    pub const fn id(self, basis: &Basis) -> Id {
147        let mut id = Id(0);
148
149        finite_for!(player in Player {
150            finite_for!(role in Role {
151                let mut squares = self.player(player).intersection(self.role(role));
152                while let Some(square) = squares.pop_first() {
153                    id = id.xor(basis.board.get(square).get(player).get(role));
154                }
155            });
156        });
157
158        id
159    }
160}
161
162fn counter_id(prefix: &str, counter: u32) -> Id {
163    hash(format!("{prefix}:{counter}").as_bytes())
164}
165
166const fn turn_id(basis: &Basis, turn: Player) -> Id {
167    basis.turn.get(turn)
168}
169
170const fn en_passant_id(basis: &Basis, en_passant: Option<EnPassant>) -> Id {
171    match en_passant {
172        Some(square) => basis.en_passant.get(square),
173        None => Id(0),
174    }
175}
176
177const fn castle_id(basis: &Basis, castles: Castles) -> Id {
178    let mut id = Id(0);
179
180    finite_for!(player in Player {
181        finite_for!(side in Side {
182            if let Some(file) = castles.get(player, side) {
183                id = id.xor(basis.castle.get(player).get(file));
184            }
185        });
186    });
187
188    id
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::{Move, board::Role::*, game::Cursor, square::Square::*};
195
196    const COLLISION_FEN: &str = "2b1kqr1/p2p3p/3p4/p2PpP2/PpP2p2/6P1/8/RRB1KQ1N w - - 21 32";
197    const COLLISION_GAME: &str = "
198        1. e4 c5 2. Ba6 bxa6 3. b4 g6 4. f4 Bh6 5. f5 Bf4 6. g3 Nf6
199        7. gxf4 Nh5 8. c4 cxb4 9. Ne2 Ng3 10. Nd4 a5 11. hxg3 Nc6
200        12. Nxc6 Rb8 13. Nxb8 g5 14. d4 f6 15. Na6 gxf4 16. Nc5 Rg8
201        17. Nb7 Rh8 18. Nd6+ exd6 19. e5 fxe5 20. d5 Qf6 21. a4 Rg8
202        22. Nd2 Qf7 23. Rb1 Qf6 24. Ne4 Qf7 25. Rh2 Qf6 26. Nf2 Qf7
203        27. Nh1 Qf6 28. Qd3 Qf7 29. Qf1 Qf6 30. Ra2 Qf7 31. Raa1 Qf8 *";
204    const START: Position = Position::start();
205
206    #[test]
207    fn documents_game_ids() {
208        use crate::formats::{Parser as _, pgn};
209
210        // start position, no moves
211        let game = Game::chess(START).unwrap();
212        let id = game.id();
213        assert_eq!(id.to_string(), "36MkWJmbeTWaYEDrfrGxNn");
214        assert_eq!(id.u128(), 22523061550585735571242541243028587795);
215
216        // 1. e4 on start position
217        let mut cursor = Cursor::new(game);
218        cursor.push(Move::normal(Pawn, E2, E4)).unwrap();
219        let e4 = cursor.into_inner();
220        assert_eq!(e4.id().to_string(), "46osJVmEGh3XxSk2FKcFbr");
221        assert_eq!(e4.id().u128(), 33370984391190127392068542153345756661);
222
223        // The weird game
224        let pgn = pgn::game.parse(COLLISION_GAME).unwrap();
225        let game: Game = pgn.try_into().unwrap();
226        assert_eq!(game.id().to_string(), "DL7b2eDrC8LVtpBQFL9BYT");
227        assert_eq!(game.id().u128(), 132719545793121815798994835819316023416);
228    }
229
230    #[test]
231    fn documents_polyglot_start_position_hash() {
232        assert_eq!(START.polyglot_id(), Id(0x463b_9618_1691_fc9c));
233    }
234
235    #[test]
236    fn documents_polyglot_start_position_collision() {
237        // Polyglot collision with the standard start position:
238        // https://talkchess.com/viewtopic.php?sid=19ffa9bbce9b0b8c00e176365ba29da6&start=20&t=57255
239        // https://talkchess.com/viewtopic.php?start=40&t=57255
240        let start = Position::start();
241        let position = Position::from_fen(COLLISION_FEN).unwrap();
242
243        assert_eq!(position.polyglot_id(), start.polyglot_id());
244        assert_ne!(position.standard_id(), start.standard_id());
245    }
246
247    #[test]
248    fn reaches_polyglot_collision_position_from_linked_movetext() {
249        use crate::formats::{Parser as _, pgn};
250
251        // The first TalkChess thread referenced by
252        // `documents_polyglot_start_position_collision` gives this game
253        // reaching the collision position.
254        let pgn = pgn::game.parse(COLLISION_GAME).unwrap();
255        let game: Game = pgn.try_into().unwrap();
256        let mut cursor = Cursor::new(game);
257        cursor.end();
258        let constructed = cursor.position();
259
260        let expected = Position::from_fen(COLLISION_FEN).unwrap();
261
262        assert_eq!(constructed.fen(), expected.fen());
263        assert_eq!(constructed.transposition_fen(), expected.transposition_fen());
264        assert_eq!(constructed.standard_id(), expected.standard_id());
265        assert_eq!(constructed.polyglot_id(), expected.polyglot_id());
266    }
267
268    #[test]
269    fn documents_polyglot_zero_hash_position() {
270        // Polyglot zero-hash position:
271        // https://talkchess.com/forum/viewtopic.php?p=482951
272        // https://talkchess.com/viewtopic.php?sid=19ffa9bbce9b0b8c00e176365ba29da6&start=20&t=57255
273        let position =
274            Position::from_fen("2b1k3/4p3/3p1p2/p2P2p1/P2P4/2P2PP1/4P3/2NQKB2 b - - 0 1").unwrap();
275
276        assert_eq!(position.polyglot_id(), Id(0));
277        assert_ne!(position.transposition_id(&STANDARD), Id(0));
278    }
279}