Fix a castling edge case in Chess960 positions where the kings start on the e-file...
[remoteglot] / Board.pm
1 #! /usr/bin/perl
2 #
3 # There are too many chess modules on CPAN already, so here's another one...
4 #
5 use strict;
6 use warnings;
7
8 package Board;
9
10 sub new {
11         my ($class, @rows) = @_;
12         my $board = [];
13
14         for my $row (0..7) {
15                 for my $col (0..7) {
16                         $board->[$row][$col] = substr($rows[$row], $col, 1);
17                 }
18         }
19
20         return bless $board;
21 }
22
23 sub clone {
24         my ($board) = shift;
25         my $nb = [];
26
27         for my $row (0..7) {
28                 $nb->[$row] = [ @{$board->[$row]} ];
29         }
30
31         return bless $nb;
32 }
33
34 # Returns a new board.
35 sub make_move {
36         my ($board, $from_row, $from_col, $to_row, $to_col, $promo) = @_;
37         my $move = move_to_uci_notation($from_row, $from_col, $to_row, $to_col, $promo);
38         my $piece = $board->[$from_row][$from_col];
39         my $nb = $board->clone();
40
41         if ($piece eq '-') {
42                 die "Invalid move $move";
43         }
44
45         if ($piece eq 'K') {
46                 # Convert to Chess960 king-takes-rook.
47                 $to_col = 7 if ($move eq 'e1g1' && $board->[$to_row][$to_col] ne 'R');
48                 $to_col = 0 if ($move eq 'e1c1' && $board->[$to_row][$to_col] ne 'R');
49
50                 my $dst_piece = $board->[$to_row][$to_col];
51
52                 # White short castling.
53                 if ($dst_piece eq 'R' && $to_col > $from_col) {
54                         # king
55                         $nb->[7][$from_col] = '-';
56                         $nb->[7][$to_col] = '-';
57                         $nb->[7][6] = 'K';
58                         $nb->[7][5] = 'R';
59
60                         return $nb;
61                 }
62
63                 # Black short castling.
64                 if ($dst_piece eq 'R' && $to_col < $from_col) {
65                         $nb->[7][$from_col] = '-';
66                         $nb->[7][$to_col] = '-';
67                         $nb->[7][2] = 'K';
68                         $nb->[7][3] = 'R';
69
70                         return $nb;
71                 }
72         } elsif ($piece eq 'k') {
73                 # Convert to Chess960 king-takes-rook.
74                 $to_col = 7 if ($move eq 'e8g8' && $board->[$to_row][$to_col] ne 'r');
75                 $to_col = 0 if ($move eq 'e8c8' && $board->[$to_row][$to_col] ne 'r');
76
77                 my $dst_piece = $board->[$to_row][$to_col];
78
79                 # Black short castling.
80                 if ($dst_piece eq 'r' && $to_col > $from_col) {
81                         $nb->[0][$from_col] = '-';
82                         $nb->[0][$to_col] = '-';
83                         $nb->[0][6] = 'k';
84                         $nb->[0][5] = 'r';
85
86                         return $nb;
87                 }
88
89                 # Black long castling.
90                 if ($dst_piece eq 'r' && $to_col < $from_col) {
91                         # king
92                         $nb->[0][$from_col] = '-';
93                         $nb->[0][$to_col] = '-';
94                         $nb->[0][2] = 'k';
95                         $nb->[0][3] = 'r';
96
97                         return $nb;
98                 }
99         }
100
101         # check if the from-piece is a pawn
102         if (lc($piece) eq 'p') {
103                 # attack?
104                 if ($from_col != $to_col) {
105                         # en passant?
106                         if ($board->[$to_row][$to_col] eq '-') {
107                                 if ($piece eq 'p') {
108                                         $nb->[$to_row - 1][$to_col] = '-';
109                                 } else {
110                                         $nb->[$to_row + 1][$to_col] = '-';
111                                 }
112                         }
113                 }
114                 if (defined($promo) && $promo ne '') {
115                         if ($piece eq 'p') {
116                                 $piece = lc($promo);
117                         } else {
118                                 $piece = uc($promo);
119                         }
120                 }
121         }
122
123         # update the board
124         $nb->[$from_row][$from_col] = '-';
125         $nb->[$to_row][$to_col] = $piece;
126
127         return $nb;
128 }
129
130 sub _pos_to_square {
131         my ($row, $col) = @_;
132         return sprintf("%c%d", ord('a') + $col, 8 - $row);
133 }
134
135 sub _col_letter_to_num {
136         return ord(shift) - ord('a');
137 }
138
139 sub _row_letter_to_num {
140         return 7 - (ord(shift) - ord('1'));
141 }
142
143 use Carp;
144
145 sub _square_to_pos {
146         my ($square) = @_;
147         #$square =~ /^([a-h])([1-8])$/ or die "Invalid square $square"; 
148         $square =~ /^([a-h])([1-8])$/ or Carp::confess("Invalid square $square");
149         return (_row_letter_to_num($2), _col_letter_to_num($1));
150 }
151
152 sub move_to_uci_notation {
153         my ($from_row, $from_col, $to_row, $to_col, $promo) = @_;
154         $promo //= "";
155         return _pos_to_square($from_row, $from_col) . _pos_to_square($to_row, $to_col) . $promo;
156 }
157
158 sub _find_piece_col {
159         my ($row, $piece) = @_;
160         for my $col (0..7) {
161                 return $col if ($row->[$col] eq $piece);
162         }
163         die "Could not find piece $piece";
164 }
165
166 # Note: This is in general not a validation that the move is actually allowed
167 # (e.g. you can castle even though you're in check).
168 sub parse_pretty_move {
169         my ($board, $move, $toplay, $chess960, $white_castle_k, $white_castle_q, $black_castle_k, $black_castle_q) = @_;
170
171         # Strip check or mate
172         $move =~ s/[+#]$//;
173
174         if ($move eq '0-0' or $move eq 'O-O') {
175                 if ($toplay eq 'W') {
176                         if ($chess960) {
177                                 # King takes rook.
178                                 return (7, _find_piece_col($board->[7], 'K'), _square_to_pos($white_castle_k . '1'));
179                         } else {
180                                 return (_square_to_pos('e1'), _square_to_pos('g1'));
181                         }
182                 } else {
183                         if ($chess960) {
184                                 # King takes rook.
185                                 return (0, _find_piece_col($board->[0], 'k'), _square_to_pos($black_castle_k . '8'));
186                         } else {
187                                 return (_square_to_pos('e8'), _square_to_pos('g8'));
188                         }
189                 }
190         } elsif ($move eq '0-0-0' or $move eq 'O-O-O') {
191                 if ($toplay eq 'W') {
192                         if ($chess960) {
193                                 # King takes rook.
194                                 return (7, _find_piece_col($board->[7], 'K'), _square_to_pos($white_castle_q . '1'));
195                         } else {
196                                 return (_square_to_pos('e1'), _square_to_pos('c1'));
197                         }
198                 } else {
199                         if ($chess960) {
200                                 # King takes rook.
201                                 return (0, _find_piece_col($board->[0], 'k'), _square_to_pos($black_castle_q . '8'));
202                         } else {
203                                 return (_square_to_pos('e8'), _square_to_pos('c8'));
204                         }
205                 }
206         }
207
208         # Parse promo
209         my $promo;
210         if ($move =~ s/=?([QRNB])$//) {
211                 $promo = $1;
212         }
213
214         $move =~ /^([KQRBN])?([a-h])?([1-8])?x?([a-h][1-8])$/ or die "Invalid move $move";
215         my $piece = $1;
216         my $from_col = defined($2) ? _col_letter_to_num($2) : undef;
217         my $from_row = defined($3) ? _row_letter_to_num($3) : undef;
218         if (!defined($piece) && (!defined($from_col) || !defined($from_row))) {
219                 $piece = 'P';
220         }
221         my ($to_row, $to_col) = _square_to_pos($4);
222         
223         # Find all possible from-squares that could have been meant.
224         my @squares = ();
225         my $side = 'K';
226         if ($toplay eq 'B') {
227                 $piece = lc($piece) if defined($piece);
228                 $side = 'k';
229         }
230         for my $row (0..7) {
231                 next if (defined($from_row) && $from_row != $row);
232                 for my $col (0..7) {
233                         next if (defined($from_col) && $from_col != $col);
234                         next if (defined($piece) && $board->[$row][$col] ne $piece);
235                         push @squares, [ $row, $col ];
236                 }
237         }
238         if (scalar @squares > 1) {
239                 # Filter out pieces which cannot reach this square.
240                 @squares = grep { $board->can_reach($piece, $_->[0], $_->[1], $to_row, $to_col) } @squares;
241         }
242         if (scalar @squares > 1) {
243                 # See if doing this move would put us in check
244                 # (yes, there are clients that expect us to do this).
245                 @squares = grep { !$board->make_move($_->[0], $_->[1], $to_row, $to_col, $promo)->in_check($side) } @squares;
246         }
247         if (scalar @squares == 0) {
248                 die "Impossible move $move";
249         }
250         if (scalar @squares != 1) {
251                 die "Ambigious move $move";
252         }
253         return (@{$squares[0]}, $to_row, $to_col, $promo);
254 }
255
256 sub fen {
257         my ($board) = @_;
258         my @rows = ();
259         for my $row (0..7) {
260                 my $str = join('', @{$board->[$row]});
261                 $str =~ s/(-+)/length($1)/ge;
262                 push @rows, $str;
263         }
264
265         return join('/', @rows);
266 }
267
268 sub can_reach {
269         my ($board, $piece, $from_row, $from_col, $to_row, $to_col) = @_;
270         
271         # Can't eat your own piece (Chess960 uses king-takes-rook for castling,
272         # but castling is irrelevant for reachability)
273         my $dest_piece = $board->[$to_row][$to_col];
274         if ($dest_piece ne '-') {
275                 return 0 if (($piece eq lc($piece)) == ($dest_piece eq lc($dest_piece)));
276         }
277         
278         if ($piece eq 'p') {
279                 # black pawn
280                 if ($to_col == $from_col && $to_row == $from_row + 1) {
281                         return ($dest_piece eq '-');
282                 }
283                 if ($to_col == $from_col && $from_row == 1 && $to_row == 3) {
284                         my $middle_piece = $board->[2][$to_col];
285                         return ($dest_piece eq '-' && $middle_piece eq '-');
286                 }
287                 if (abs($to_col - $from_col) == 1 && $to_row == $from_row + 1) {
288                         if ($dest_piece eq '-') {
289                                 # En passant. TODO: check that the last move was indeed an EP move
290                                 return ($to_row == 5 && $board->[4][$to_col] eq 'P');
291                         } else {
292                                 return 1;
293                         }
294                 }
295                 return 0;
296         }
297         if ($piece eq 'P') {
298                 # white pawn
299                 if ($to_col == $from_col && $to_row == $from_row - 1) {
300                         return ($dest_piece eq '-');
301                 }
302                 if ($to_col == $from_col && $from_row == 6 && $to_row == 4) {
303                         my $middle_piece = $board->[5][$to_col];
304                         return ($dest_piece eq '-' && $middle_piece eq '-');
305                 }
306                 if (abs($to_col - $from_col) == 1 && $to_row == $from_row - 1) {
307                         if ($dest_piece eq '-') {
308                                 # En passant. TODO: check that the last move was indeed an EP move
309                                 return ($to_row == 2 && $board->[3][$to_col] eq 'p');
310                         } else {
311                                 return 1;
312                         }
313                 }
314                 return 0;
315         }
316         
317         if (lc($piece) eq 'r') {
318                 return 0 unless ($from_row == $to_row || $from_col == $to_col);
319
320                 # check that there's a clear passage
321                 if ($from_row == $to_row) {
322                         if ($from_col > $to_col) {
323                                 ($to_col, $from_col) = ($from_col, $to_col);
324                         }
325
326                         for my $c (($from_col+1)..($to_col-1)) {
327                                 my $middle_piece = $board->[$to_row][$c];
328                                 return 0 if ($middle_piece ne '-');
329                         }
330
331                         return 1;
332                 } else {
333                         if ($from_row > $to_row) {
334                                 ($to_row, $from_row) = ($from_row, $to_row);
335                         }
336
337                         for my $r (($from_row+1)..($to_row-1)) {
338                                 my $middle_piece = $board->[$r][$to_col];
339                                 return 0 if ($middle_piece ne '-');     
340                         }
341
342                         return 1;
343                 }
344         }
345         if (lc($piece) eq 'b') {
346                 return 0 unless (abs($from_row - $to_row) == abs($from_col - $to_col));
347
348                 my $dr = ($to_row - $from_row) / abs($to_row - $from_row);
349                 my $dc = ($to_col - $from_col) / abs($to_col - $from_col);
350
351                 my $r = $from_row + $dr;
352                 my $c = $from_col + $dc;
353
354                 while ($r != $to_row) {
355                         my $middle_piece = $board->[$r][$c];
356                         return 0 if ($middle_piece ne '-');
357                         
358                         $r += $dr;
359                         $c += $dc;
360                 }
361
362                 return 1;
363         }
364         if (lc($piece) eq 'n') {
365                 my $diff_r = abs($from_row - $to_row);
366                 my $diff_c = abs($from_col - $to_col);
367                 return 1 if ($diff_r == 2 && $diff_c == 1);
368                 return 1 if ($diff_r == 1 && $diff_c == 2);
369                 return 0;
370         }
371         if ($piece eq 'q') {
372                 return (can_reach($board, 'r', $from_row, $from_col, $to_row, $to_col) ||
373                         can_reach($board, 'b', $from_row, $from_col, $to_row, $to_col));
374         }
375         if ($piece eq 'Q') {
376                 return (can_reach($board, 'R', $from_row, $from_col, $to_row, $to_col) ||
377                         can_reach($board, 'B', $from_row, $from_col, $to_row, $to_col));
378         }
379         if (lc($piece) eq 'k') {
380                 return (abs($from_row - $to_row) <= 1 && abs($from_col - $to_col) <= 1);
381         }
382
383         # unknown piece
384         return 0;
385 }
386
387 # Like can_reach, but also checks the move doesn't put the side in check.
388 # We use this in prettyprint_move to reduce the disambiguation, because Chess.js
389 # needs moves to be in minimally disambiguated form.
390 sub can_legally_reach {
391         my ($board, $piece, $from_row, $from_col, $to_row, $to_col) = @_;
392
393         return 0 if (!can_reach($board, $piece, $from_row, $from_col, $to_row, $to_col));
394
395         my $nb = $board->make_move($from_row, $from_col, $to_row, $to_col);
396         my $side = ($piece eq lc($piece)) ? 'k' : 'K';
397
398         return !in_check($nb, $side);
399 }
400
401 my %pieces_against_side = (
402         k => { K => 1, Q => 1, R => 1, N => 1, B => 1, P => 1 },
403         K => { k => 1, q => 1, r => 1, n => 1, b => 1, p => 1 },
404 );
405
406 # Returns whether the given side (given as k or K for black and white) is in check.
407 sub in_check {
408         my ($board, $side) = @_;
409         my ($kr, $kc) = _find_piece($board, $side);
410
411         # check all pieces for the possibility of threatening this king
412         for my $row (0..7) {
413                 next unless grep { exists($pieces_against_side{$side}{$_}) } @{$board->[$row]};
414                 for my $col (0..7) {
415                         my $piece = $board->[$row][$col];
416                         next if ($piece eq '-');
417                         return 1 if ($board->can_reach($piece, $row, $col, $kr, $kc));
418                 }
419         }
420
421         return 0;
422 }
423
424 sub _find_piece {
425         my ($board, $piece) = @_;
426
427         for my $row (0..7) {
428                 next unless grep { $_ eq $piece } @{$board->[$row]};
429                 for my $col (0..7) {
430                         if ($board->[$row][$col] eq $piece) {
431                                 return ($row, $col);
432                         }
433                 }
434         }
435
436         return (undef, undef);
437 }
438
439 # Returns if the given side (given as k or K) is in mate.
440 sub in_mate {
441         my ($board, $side, $in_check) = @_;
442         return 0 if (!$in_check);
443
444         # try all possible moves for the side in check
445         for my $row (0..7) {
446                 for my $col (0..7) {
447                         my $piece = $board->[$row][$col];
448                         next if ($piece eq '-');
449
450                         if ($side eq 'K') {
451                                 next if ($piece eq lc($piece));
452                         } else {
453                                 next if ($piece eq uc($piece));
454                         }
455
456                         for my $dest_row (0..7) {
457                                 for my $dest_col (0..7) {
458                                         next if ($row == $dest_row && $col == $dest_col);
459                                         next unless ($board->can_reach($piece, $row, $col, $dest_row, $dest_col));
460
461                                         my $nb = $board->make_move($row, $col, $dest_row, $dest_col);
462                                         return 0 if (!$nb->in_check($side));
463                                 }
464                         }
465                 }
466         }
467
468         # nothing to do; mate
469         return 1;
470 }
471
472 # Returns the short algebraic form of the move, as well as the new position.
473 sub prettyprint_move {
474         my ($board, $from_row, $from_col, $to_row, $to_col, $promo) = @_;
475         my $pretty = $board->_prettyprint_move_no_check_or_mate($from_row, $from_col, $to_row, $to_col, $promo);
476
477         my $nb = $board->make_move($from_row, $from_col, $to_row, $to_col, $promo);
478
479         my $piece = $board->[$from_row][$from_col];
480         my $other_side = (uc($piece) eq $piece) ? 'k' : 'K';
481         my $in_check = $nb->in_check($other_side);
482         if ($nb->in_mate($other_side, $in_check)) {
483                 $pretty .= '#';
484         } elsif ($in_check) {
485                 $pretty .= '+';
486         }
487         return ($pretty, $nb);
488 }
489
490 sub num_pieces {
491         my ($board) = @_;
492
493         my $num = 0;
494         for my $row (0..7) {
495                 for my $col (0..7) {
496                         my $piece = $board->[$row][$col];
497                         ++$num if ($piece ne '-');
498                 }
499         }
500         return $num;    
501 }
502
503 sub _prettyprint_move_no_check_or_mate {
504         my ($board, $from_row, $from_col, $to_row, $to_col, $promo) = @_;
505         my $piece = $board->[$from_row][$from_col];
506         my $move = move_to_uci_notation($from_row, $from_col, $to_row, $to_col, $promo);
507
508         if ($piece eq '-') {
509                 die "Invalid move $move";
510         }
511
512         if ($piece eq 'K') {
513                 # white short/long castling
514                 return 'O-O' if ($move eq 'e1g1');
515                 return 'O-O-O' if ($move eq 'e1c1');
516
517                 # white short/long chess960-style castling (king takes own rook)
518                 my $dst_piece = $board->[$to_row][$to_col];
519                 if ($dst_piece eq 'R') {
520                         return ($to_col > $from_col) ? 'O-O' : 'O-O-O';
521                 }
522         } elsif ($piece eq 'k') {
523                 # black short/long castling
524                 return 'O-O' if ($move eq 'e8g8');
525                 return 'O-O-O' if ($move eq 'e8c8');
526
527                 # black short/long chess960-style castling (king takes own rook)
528                 my $dst_piece = $board->[$to_row][$to_col];
529                 if ($dst_piece eq 'r') {
530                         return ($to_col > $from_col) ? 'O-O' : 'O-O-O';
531                 }
532         }
533
534         my $pretty;
535
536         # check if the from-piece is a pawn
537         if (lc($piece) eq 'p') {
538                 # attack?
539                 if ($from_col != $to_col) {
540                         $pretty = substr($move, 0, 1) . 'x' . _pos_to_square($to_row, $to_col);
541                 } else {
542                         $pretty = _pos_to_square($to_row, $to_col);
543                 }
544
545                 if (defined($promo) && $promo ne '') {
546                         # promotion
547                         $pretty .= "=";
548                         $pretty .= uc($promo);
549                 }
550                 return $pretty;
551         }
552
553         $pretty = uc($piece);
554
555         # see how many of these pieces could go here, in all
556         my $num_total = 0;
557         for my $col (0..7) {
558                 for my $row (0..7) {
559                         next unless ($board->[$row][$col] eq $piece);
560                         ++$num_total if ($board->can_legally_reach($piece, $row, $col, $to_row, $to_col));
561                 }
562         }
563
564         # see how many of these pieces from the given row could go here
565         my $num_row = 0;
566         for my $col (0..7) {
567                 next unless ($board->[$from_row][$col] eq $piece);
568                 ++$num_row if ($board->can_legally_reach($piece, $from_row, $col, $to_row, $to_col));
569         }
570
571         # and same for columns
572         my $num_col = 0;
573         for my $row (0..7) {
574                 next unless ($board->[$row][$from_col] eq $piece);
575                 ++$num_col if ($board->can_legally_reach($piece, $row, $from_col, $to_row, $to_col));
576         }
577
578         # see if we need to disambiguate
579         if ($num_total > 1) {
580                 if ($num_col == 1) {
581                         $pretty .= substr($move, 0, 1);
582                 } elsif ($num_row == 1) {
583                         $pretty .= substr($move, 1, 1);
584                 } else {
585                         $pretty .= substr($move, 0, 2);
586                 }
587         }
588
589         # attack?
590         if ($board->[$to_row][$to_col] ne '-') {
591                 $pretty .= 'x';
592         }
593
594         $pretty .= _pos_to_square($to_row, $to_col);
595         return $pretty;
596 }
597
598 1;