Skip to main content

chaiss_core/engine/
notation.rs

1use super::models::{GameEndStatus, GameState, PieceType};
2use super::movement;
3
4/// Translates active board geometries natively into formal FIDE Standard Algebraic Notation directly!
5pub 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    // 1. Castling Transpositions
15    if piece.piece_type == PieceType::King && (from as i32 - to as i32).abs() == 2 {
16        if to > from {
17            return "O-O".to_string(); // Kingside
18        } else {
19            return "O-O-O".to_string(); // Queenside
20        }
21    }
22
23    // 2. Identify Hostile Space Overlaps (Captures)
24    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    // 3. Resolve Structural Ambiguity (If 2 overlapping identical pieces mathematically reach the target)
34    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        // Pawns capturing ALWAYS structurally state departing file regardless!
77        let from_file = from % 8;
78        disambiguation.push((b'a' + from_file as u8) as char);
79    }
80
81    // 4. Construct Explicit Prefix (Piece Mapping)
82    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    // Explicit Targeting
101    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    // 5. Pawn Promotion
107    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    // 6. Check / Checkmate Target Overlays
119    let mut sim = state.clone();
120    sim.apply_move(from, to, promotion);
121
122    // Evaluate geometry termination exactly mathematically evaluating the freshly toggled state natively!
123    if let Some(GameEndStatus::Checkmate(_)) = sim.evaluate_terminal_state() {
124        notation.push('#');
125    } else {
126        // The color has ALREADY flipped sequentially natively globally inside `sim`!
127        if let Some(enemy_king_idx) = movement::find_king(&sim, sim.active_color) {
128            // Is that hostile king currently caught natively by our newly updated geometric pressure line?
129            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    // Check castling
151    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    // Check promotion
159    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            // Fallback for pure pawn mapping (exd5)
216            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; // Safely strip rigid external application metadata natively!
293        }
294
295        for token in line.split_whitespace() {
296            if token == "1-0" || token == "0-1" || token == "1/2-1/2" || token == "*" {
297                continue; // Ignore final mathematical terminal block structures!
298            }
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; // Processed numbered format logically
308            }
309
310            // Mathematically strip external annotations conditionally!
311            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        // Test e4 mathematically
343        let (from, to, promo) = parse_algebraic_move(&state, "e4").unwrap();
344        assert_eq!(from, 52); // e2
345        assert_eq!(to, 36); // e4
346        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); // g1
354        assert_eq!(to, 45); // f3
355        assert_eq!(promo, None);
356    }
357
358    #[test]
359    fn test_parse_algebraic_disambiguation() {
360        // Construct a state where two knights can reach d2 mathematically (b1 and f3)
361        // We must clear the friendly pawn at d2 so the Knights can physically jump natively!
362        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); // b1
367        assert_eq!(to, 51); // d2
368    }
369}