chaiss_core/engine/
notation.rs1use super::models::{GameEndStatus, GameState, PieceType};
2use super::movement;
3
4pub fn get_algebraic_notation(
6 state: &GameState,
7 from: usize,
8 to: usize,
9 promotion: Option<PieceType>,
10) -> String {
11 let piece = state.board[from]
12 .expect("No piece structurally present at explicit algebraic origin square!");
13
14 if piece.piece_type == PieceType::King && (from as i32 - to as i32).abs() == 2 {
16 if to > from {
17 return "O-O".to_string(); } else {
19 return "O-O-O".to_string(); }
21 }
22
23 let mut is_capture = state.board[to].is_some();
25 if piece.piece_type == PieceType::Pawn {
26 if let Some(ep) = state.en_passant_target {
27 if to == ep.index {
28 is_capture = true;
29 }
30 }
31 }
32
33 let mut disambiguation = String::new();
35 if piece.piece_type != PieceType::Pawn && piece.piece_type != PieceType::King {
36 let mut identical_attackers = Vec::new();
37 for sq in 0..64 {
38 if sq != from {
39 if let Some(other) = state.board[sq] {
40 if other.color == piece.color && other.piece_type == piece.piece_type {
41 let moves = movement::get_legal_moves(state, sq, other);
42 if moves.contains(&to) {
43 identical_attackers.push(sq);
44 }
45 }
46 }
47 }
48 }
49
50 if !identical_attackers.is_empty() {
51 let from_file = from % 8;
52 let from_rank = from / 8;
53
54 let mut file_unique = true;
55 let mut rank_unique = true;
56
57 for &sq in &identical_attackers {
58 if sq % 8 == from_file {
59 file_unique = false;
60 }
61 if sq / 8 == from_rank {
62 rank_unique = false;
63 }
64 }
65
66 if file_unique {
67 disambiguation.push((b'a' + from_file as u8) as char);
68 } else if rank_unique {
69 disambiguation.push_str(&(8 - from_rank).to_string());
70 } else {
71 disambiguation.push((b'a' + from_file as u8) as char);
72 disambiguation.push_str(&(8 - from_rank).to_string());
73 }
74 }
75 } else if piece.piece_type == PieceType::Pawn && is_capture {
76 let from_file = from % 8;
78 disambiguation.push((b'a' + from_file as u8) as char);
79 }
80
81 let mut notation = String::new();
83 if piece.piece_type != PieceType::Pawn {
84 notation.push(match piece.piece_type {
85 PieceType::Knight => 'N',
86 PieceType::Bishop => 'B',
87 PieceType::Rook => 'R',
88 PieceType::Queen => 'Q',
89 PieceType::King => 'K',
90 _ => unreachable!(),
91 });
92 }
93
94 notation.push_str(&disambiguation);
95
96 if is_capture {
97 notation.push('x');
98 }
99
100 let to_file = to % 8;
102 let to_rank = to / 8;
103 notation.push((b'a' + to_file as u8) as char);
104 notation.push_str(&(8 - to_rank).to_string());
105
106 if let Some(target_type) = promotion {
108 notation.push('=');
109 notation.push(match target_type {
110 PieceType::Knight => 'N',
111 PieceType::Bishop => 'B',
112 PieceType::Rook => 'R',
113 PieceType::Queen => 'Q',
114 _ => 'Q',
115 });
116 }
117
118 let mut sim = state.clone();
120 sim.apply_move(from, to, promotion);
121
122 if let Some(GameEndStatus::Checkmate(_)) = sim.evaluate_terminal_state() {
124 notation.push('#');
125 } else {
126 if let Some(enemy_king_idx) = movement::find_king(&sim, sim.active_color) {
128 if movement::is_square_attacked(&sim, enemy_king_idx, sim.active_color.opposite()) {
130 notation.push('+');
131 }
132 }
133 }
134
135 notation
136}
137
138pub fn parse_algebraic_move(
139 state: &GameState,
140 san: &str,
141) -> Result<(usize, usize, Option<PieceType>), String> {
142 let mut cleaned = san
143 .replace(|c: char| "+#!?".contains(c), "")
144 .trim()
145 .to_string();
146 if cleaned.is_empty() {
147 return Err("Empty move".to_string());
148 }
149
150 if cleaned.to_uppercase() == "O-O" || cleaned.to_uppercase() == "0-0" {
152 return parse_castling(state, true);
153 }
154 if cleaned.to_uppercase() == "O-O-O" || cleaned.to_uppercase() == "0-0-0" {
155 return parse_castling(state, false);
156 }
157
158 let mut promotion = None;
160 if let Some(eq_idx) = cleaned.find('=') {
161 let p_char = cleaned.chars().nth(eq_idx + 1).unwrap_or(' ');
162 promotion = super::models::Piece::from_char(p_char).map(|p| p.piece_type);
163 if promotion.is_none() || promotion == Some(PieceType::King) {
164 return Err("Invalid promotion target".to_string());
165 }
166 cleaned.truncate(eq_idx);
167 } else if let Some(last_char) = cleaned.chars().last() {
168 if "NnRrqQbB".contains(last_char) {
169 let maybe_rank = cleaned.chars().rev().nth(1).unwrap_or('a');
170 if "18".contains(maybe_rank) {
171 promotion = super::models::Piece::from_char(last_char).map(|p| p.piece_type);
172 cleaned.pop();
173 }
174 }
175 }
176
177 let _is_capture = cleaned.contains('x') || cleaned.contains('X');
178 cleaned = cleaned.replace(['x', 'X'], "");
179
180 if cleaned.len() < 2 {
181 return Err("San string too short".to_string());
182 }
183
184 let to_rank_char = cleaned.chars().last().unwrap();
185 let to_file_char = cleaned.chars().rev().nth(1).unwrap();
186
187 if !('a'..='h').contains(&to_file_char) || !('1'..='8').contains(&to_rank_char) {
188 return Err("Invalid destination square".to_string());
189 }
190
191 let to_file = (to_file_char as u8 - b'a') as usize;
192 let to_rank = 8 - to_rank_char.to_digit(10).unwrap() as usize;
193 let to_sq = to_rank * 8 + to_file;
194
195 let prefix = &cleaned[..cleaned.len() - 2];
196
197 let mut target_piece_type = PieceType::Pawn;
198 let mut from_file_constraint = None;
199 let mut from_rank_constraint = None;
200
201 if !prefix.is_empty() {
202 let first_char = prefix.chars().next().unwrap();
203 if "NRQBK".contains(first_char) {
204 target_piece_type = super::models::Piece::from_char(first_char)
205 .unwrap()
206 .piece_type;
207 for c in prefix.chars().skip(1) {
208 if ('a'..='h').contains(&c) {
209 from_file_constraint = Some((c as u8 - b'a') as usize);
210 } else if ('1'..='8').contains(&c) {
211 from_rank_constraint = Some(8 - c.to_digit(10).unwrap() as usize);
212 }
213 }
214 } else if ('a'..='h').contains(&first_char) {
215 from_file_constraint = Some((first_char as u8 - b'a') as usize);
217 }
218 }
219
220 let mut candidates = Vec::new();
221 for sq_idx in 0..64 {
222 if let Some(piece) = state.board[sq_idx] {
223 if piece.color == state.active_color && piece.piece_type == target_piece_type {
224 let sq_file = sq_idx % 8;
225 let sq_rank = sq_idx / 8;
226
227 if let Some(fc) = from_file_constraint {
228 if sq_file != fc {
229 continue;
230 }
231 }
232 if let Some(rc) = from_rank_constraint {
233 if sq_rank != rc {
234 continue;
235 }
236 }
237
238 let moves = movement::get_legal_moves(state, sq_idx, piece);
239 if moves.contains(&to_sq) {
240 candidates.push(sq_idx);
241 }
242 }
243 }
244 }
245
246 if candidates.is_empty() {
247 return Err(format!(
248 "No mathematically capable piece for string '{}'",
249 san
250 ));
251 }
252 if candidates.len() > 1 {
253 return Err(format!(
254 "Ambiguous array natively! More than one piece can organically reach target '{}'",
255 san
256 ));
257 }
258
259 Ok((candidates[0], to_sq, promotion))
260}
261
262fn parse_castling(
263 state: &GameState,
264 kingside: bool,
265) -> Result<(usize, usize, Option<PieceType>), String> {
266 let king_sq = movement::find_king(state, state.active_color)
267 .ok_or("King physically missing natively!")?;
268
269 if state.active_color == super::models::Color::White && king_sq != 60 {
270 return Err("Not eligible physically".to_string());
271 }
272 if state.active_color == super::models::Color::Black && king_sq != 4 {
273 return Err("Not eligible physically".to_string());
274 }
275
276 let target_sq = if kingside { king_sq + 2 } else { king_sq - 2 };
277
278 let piece = state.board[king_sq].unwrap();
279 let moves = movement::get_legal_moves(state, king_sq, piece);
280 if !moves.contains(&target_sq) {
281 return Err("Castling array blocked algebraically!".to_string());
282 }
283
284 Ok((king_sq, target_sq, None))
285}
286
287pub fn parse_pgn_moves(pgn: &str) -> Vec<String> {
288 let mut moves = Vec::new();
289 for line in pgn.lines() {
290 let line = line.trim();
291 if Default::default() || line.starts_with('[') {
292 continue; }
294
295 for token in line.split_whitespace() {
296 if token == "1-0" || token == "0-1" || token == "1/2-1/2" || token == "*" {
297 continue; }
299 if token.contains('.') {
300 let parts: Vec<&str> = token.split('.').collect();
301 if let Some(mv) = parts.last() {
302 let clean = mv.trim();
303 if !clean.is_empty() {
304 moves.push(clean.to_string());
305 }
306 }
307 continue; }
309
310 let clean = token.replace("!", "").replace("?", "");
312 if !clean.is_empty() {
313 moves.push(clean.to_string());
314 }
315 }
316 }
317 moves
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn test_pgn_sequence() {
326 let pgn = "1. c4 e5 2. Nc3 Bb4 3. Nd5 Nc6 4. Nxb4 Nxb4 5. a3 Nc6 6. g3 d6 7. Bg2 Bd7 8. d3 Nf6 9. Nf3 O-O 10. O-O e4";
327 let moves = super::parse_pgn_moves(pgn);
328 let mut state = super::super::GameState::new();
329 for m in moves {
330 if let Ok((from, to, promo)) = super::parse_algebraic_move(&state, &m) {
331 state.apply_move(from, to, promo);
332 println!("Success: {}", m);
333 } else {
334 panic!("Fail: {}", m);
335 }
336 }
337 }
338
339 #[test]
340 fn test_parse_basic_pawn_moves() {
341 let state = GameState::new();
342 let (from, to, promo) = parse_algebraic_move(&state, "e4").unwrap();
344 assert_eq!(from, 52); assert_eq!(to, 36); assert_eq!(promo, None);
347 }
348
349 #[test]
350 fn test_parse_basic_knight_moves() {
351 let state = GameState::new();
352 let (from, to, promo) = parse_algebraic_move(&state, "Nf3").unwrap();
353 assert_eq!(from, 62); assert_eq!(to, 45); assert_eq!(promo, None);
356 }
357
358 #[test]
359 fn test_parse_algebraic_disambiguation() {
360 let fen = "rnbqkbnr/pppppppp/8/8/8/5N2/PPP1PPPP/RN1QKB1R w KQkq - 0 2";
363 let state = GameState::from_fen(fen).unwrap();
364
365 let (from, to, _) = parse_algebraic_move(&state, "Nbd2").unwrap();
366 assert_eq!(from, 57); assert_eq!(to, 51); }
369}