import 'dart:io'; import 'dart:math'; import 'dart:developer' as dv; ///String extentions extension CheckString on String { bool get isSpace => trim().isEmpty || RegExp(r'/\s/').hasMatch(this); bool get isUpper => !isSpace && toUpperCase() == this && this != '.'; bool get isLower => !isSpace && toLowerCase() == this && this != '.'; String get reversed => split('').reversed.join(''); String get swapCase => split('').map((x) => x.toUpperCase() == x ? x.toLowerCase() : x.toUpperCase()).toList().join(''); String get reversedAndSwapCased => split('').map((x) => x.toUpperCase() == x ? x.toLowerCase() : x.toUpperCase()).toList().reversed.join(''); } // List of list extention extension CheckListOfTuples on List { bool doContains(newList) => newList.length == 2 && indexWhere((l) => l[0] == newList[0] && l[1] == newList[1]) > -1; } int getSeconds() { return DateTime.now().millisecondsSinceEpoch ~/ 1000.toInt(); } // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // Piece-Square tables. Tune these to change sunfish's behaviour // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /// TODO: add wizard final piece = {'P': 100, 'N': 280, 'B': 320, 'R': 479, 'Q': 929, 'K': 60000}; ///TODO: add 2 raws and 2 colums to each set AND Wizard pst var pst = { 'P': [ 0, 0, 0, 0, 0, 0, 0, 0, // 78, 83, 86, 73, 102, 82, 85, 90, // 7, 29, 21, 44, 40, 31, 44, 7, // -17, 16, -2, 15, 14, 0, 15, -13, // -26, 3, 10, 9, 6, 1, 0, -23, // -22, 9, 5, -11, -10, -2, 3, -19, // -31, 8, -7, -37, -36, -14, 3, -31, // 0, 0, 0, 0, 0, 0, 0, 0 ], // 'N': [ -66, -53, -75, -75, -10, -55, -58, -70, // -3, -6, 100, -36, 4, 62, -4, -14, // 10, 67, 1, 74, 73, 27, 62, -2, // 24, 24, 45, 37, 33, 41, 25, 17, // -1, 5, 31, 21, 22, 35, 2, 0, // -18, 10, 13, 22, 18, 15, 11, -14, // -23, -15, 2, 0, 2, 0, -23, -20, // -74, -23, -26, -24, -19, -35, -22, -69 ], // 'B': [ -59, -78, -82, -76, -23, -107, -37, -50, // -11, 20, 35, -42, -39, 31, 2, -22, // -9, 39, -32, 41, 52, -10, 28, -14, // 25, 17, 20, 34, 26, 25, 15, 10, // 13, 10, 17, 23, 17, 16, 0, 7, // 14, 25, 24, 15, 8, 25, 20, 15, // 19, 20, 11, 6, 7, 6, 20, 16, // -7, 2, -15, -12, -14, -15, -10, -10 ], // 'R': [ 35, 29, 33, 4, 37, 33, 56, 50, // 55, 29, 56, 67, 55, 62, 34, 60, // 19, 35, 28, 33, 45, 27, 25, 15, // 0, 5, 16, 13, 18, -4, -9, -6, // -28, -35, -16, -21, -13, -29, -46, -30, // -42, -28, -42, -25, -25, -35, -26, -46, // -53, -38, -31, -26, -29, -43, -44, -53, // -30, -24, -18, 5, -2, -18, -31, -32 ], // 'Q': [ 6, 1, -8, -104, 69, 24, 88, 26, // 14, 32, 60, -10, 20, 76, 57, 24, // -2, 43, 32, 60, 72, 63, 43, 2, // 1, -16, 22, 17, 25, 20, -13, -6, // -14, -15, -2, -5, -1, -10, -20, -22, // -30, -6, -13, -11, -16, -11, -16, -27, // -36, -18, 0, -19, -15, -15, -21, -38, // -39, -30, -31, -13, -31, -36, -34, -42 ], // 'K': [ 4, 54, 47, -99, -99, 60, 83, -62, // -32, 10, 55, 56, 56, 55, 10, 3, // -62, 12, -57, 44, -67, 28, 37, -31, // -55, 50, 11, -4, -19, 13, 0, -49, // -55, -43, -52, -28, -51, -47, -8, -50, // -47, -42, -43, -79, -64, -32, -29, -32, // -4, 3, -14, -50, -57, -18, 13, 4, // 17, 30, -3, -14, 6, -1, 40, 18 ], // }; // recalculating Piece-Square raw into desk surrounded by 0-s List padrow(List row, String k) { var rowBody = [for (int x in row) x + piece[k]!]; return [ 0, ...rowBody, 0, ]; } void setPst() { pst.forEach((key, item) { final innerItem = [...List.filled(20, 0)]; for (var i = 0; i < 8; i++) { innerItem.addAll(padrow(item.getRange(i * 8, i * 8 + 8).toList(), key)); } innerItem.addAll(List.filled(20, 0)); pst[key] = innerItem; }); } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Global constants ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Our board is represented as a 120 character string. The padding allows for // fast detection of moves that don't stay within the board. //TODO: Tune globals for 10x10 field final A1 = 91, H1 = 98, A8 = 21, H8 = 28; final initial = (' \n' // 0 - 9 ' \n' // 10 - 19 ' rnbqkbnr\n' // 20 - 29 ' pppppppp\n' // 30 - 39 ' ........\n' // 40 - 49 ' ........\n' // 50 - 59 ' ........\n' // 60 - 69 ' ........\n' // 70 - 79 ' PPPPPPPP\n' // 80 - 89 ' RNBQKBNR\n' // 90 - 99 ' \n' // 100 -109 ' \n' // 110 -119 ); // Lists of possible moves for each piece type. int N = -10, E = 1, S = 10, W = -1; //TODO: add directions for Wizard final Map> directions = { 'P': [N, N + N, N + W, N + E], 'N': [N + N + E, E + N + E, E + S + E, S + S + E, S + S + W, W + S + W, W + N + W, N + N + W], 'B': [N + E, S + E, S + W, N + W], 'R': [N, E, S, W], 'Q': [N, E, S, W, N + E, S + E, S + W, N + W], 'K': [N, E, S, W, N + E, S + E, S + W, N + W], '.': [] }; // Mate value must be greater than 8*queen + 2*(rook+knight+bishop) // King value is set to twice this value such that if the opponent is // 8 queens up, but we got the king, we still exceed MATE_VALUE. // When a MATE is detected, we'll set the score to MATE_UPPER - plies to get there // E.g. Mate in 3 will be MATE_UPPER - 6 // TOD: replace mate with King capture event final MATE_LOWER = piece['K']! - 10 * piece['Q']!; final MATE_UPPER = piece['K']! + 10 * piece['Q']!; // The table size is the maximum number of elements in the transposition table. final TABLE_SIZE = 1e7; // Constants for tuning search final QS_LIMIT = 219, EVAL_ROUGHNESS = 13, DRAW_TEST = true; ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Chess logic ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class Position { Position( this.board, this.score, this.wc, this.bc, this.ep, this.kp, ); String board; int score; List wc; List bc; int ep; int kp; /* """ A state of a chess game board -- a 120 char representation of the board score -- the board evaluation wc -- the castling rights, [west/queen side, east/king side] bc -- the opponent castling rights, [west/king side, east/queen side] ep - the en passant square kp - the king passant square """; */ Iterable> gen_moves() sync* { // For each of our pieces, iterate through each possible 'ray' of moves, // as defined in the 'directions' map. The rays are broken e.g. by // captures or immediately in case of pieces such as knights. //TODO: add limitation of 3 cells for Wizard final charsList = board.split(''); for (var i = 0; i < board.length; i++) { final p = charsList[i]; // yield ([i, 12]); // } // board.split('').asMap().forEach((i, p) sync* { if (!p.isUpper) { continue; } for (var d in directions[p]!) { for (var j in count(i + d, d)) { final q = board[j]; // Stay inside the board, and off friendly pieces if (q.isSpace || q.isUpper) { break; } // Pawn move, double move and capture if (p == 'P' && [N, N + N].contains(d) && q != '.') { break; } if (p == 'P' && d == (N + N) && (i < A1 + N || board[i + N] != '.')) { break; } if (p == 'P' && [N + W, N + E].contains(d) && q == '.' && ![ep, kp, kp - 1, kp + 1].contains(j)) { break; } // Move it final res = [i, j]; yield (res); // Stop crawlers from sliding, and sliding after captures if ('PNK'.contains(p) || q.isLower) { break; } // Castling, by sliding the rook next to the king if (i == A1 && board[j + E] == 'K' && wc[0]) { yield ([j + E, j + W]); } if (i == H1 && board[j + W] == 'K' && wc[1]) { yield ([j + W, j + E]); } } } } } // String reversed(String subject) { // return subject.split('').reversed.join(); // } String swapCase(String subject) { if (subject is! String || subject.isEmpty) { return ''; } String _swap(String l) { if (l.toUpperCase() == l) { return l.toLowerCase(); } else { return l.toUpperCase(); } } var subjectChars = subject.split(''); return subjectChars.map((x) => _swap(x)).join(); } Position rotate() { ''' Rotates the board, preserving enpassant '''; return Position(board.reversedAndSwapCased, -score, bc, wc, ep > 0 ? 119 - ep : 0, kp > 0 ? 119 - kp : 0); } Position nullmove() { ''' Like rotate, but clears ep and kp '''; return Position(board.reversedAndSwapCased, -score, bc, wc, 0, 0); } String put(String _str, int i, String p) { final newStr = _str.substring(0, i) + p + _str.substring(i + 1); return newStr; } // move piece due to the move scecification Position movePiece(List move) { final i = move[0], j = move[1]; final p = board[i]; final q = board[j]; // Copy variables and reset ep and kp var _board = board; var _wc = wc, _bc = bc, _ep = 0, _kp = 0; score = score + value(move); // Actual move board = put(_board, j, p); board = put(board, i, '.'); // Castling rights, we move the rook or capture the opponent's if (i == A1) { _wc = [false, wc[1]]; } if (i == H1) { _wc = [wc[0], false]; } if (j == A8) { bc = [bc[0], false]; } if (j == H8) { bc = [false, bc[1]]; } // Castling if (p == 'K') { _wc = [false, false]; if ((j - i).abs() == 2) { kp = (i + j); //2 board = put(board, (j < i) ? A1 : H1, '.'); board = put(board, kp, 'R'); } } // Pawn promotion, double move and en passant capture if (p == 'P') { if (A8 <= j && j <= H8) { board = put(board, j, 'Q'); } if (j - i == 2 * N) { ep = i + N; } if (j == ep) { board = put(board, j + S, '.'); } } // We rotate the returned position, so it's ready for the next player return Position(board, score, _wc, _bc, _ep, _kp).rotate(); } // calculate score of the board position int value(List move) { final i = move[0], j = move[1]; final p = board[i], q = board[j]; // Actual move score = pst[p] != null ? pst[p]![j] - pst[p]![i] : 0; // Capture if (!q.isUpper) { score += pst[q.toUpperCase()] != null ? pst[q.toUpperCase()]![119 - j] : 0; } // Castling check detection if ((j - kp).abs() < 2) { score += pst['K']![119 - j]; } // Castling if (p == 'K' && (i - j).abs() == 2) { score += pst['R']![(i + j)]; //2] score -= pst['R']![j < i ? A1 : H1]; } // Special pawn stuff if (p == 'P') { if (A8 <= j && j <= H8) { score += pst['Q']![j] - pst['P']![j]; } if (j == ep) { score += pst['P']![119 - (j + S)]; } } return score; } /// python counter immulation Iterable count(int firstval, int step) sync* { var x = firstval; while (true) { yield (x); x += step; } } } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Search logic ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // lower <= s(pos) <= upper class Entry { Entry(this.lower, this.upper); int lower; int upper; } class Searcher { Searcher(); late Map tpScore = {}; late Map?> tpMove = {}; late Set history; int nodes = 0; int bound(Position pos, int gamma, int depth, {bool root = true}) { ''' returns r where s(pos) <= r < gamma if gamma > s(pos) gamma <= r <= s(pos) if gamma <= s(pos)'''; nodes += 1; // Depth <= 0 is QSearch. Here any position is searched as deeply as is needed for // calmness, and from this point on there is no difference in behaviour depending on // depth, so so there is no reason to keep different depths in the transposition table. depth = max(depth, 0); // Sunfish is a king-capture engine, so we should always check if we // still have a king. Notice since this is the only termination check, // the remaining code has to be comfortable with being mated, stalemated // or able to capture the opponent king. if (pos.score <= -MATE_LOWER) { return -MATE_UPPER; } // We detect 3-fold captures by comparing against previously // _actually played_ positions. // Note that we need to do this before we look in the table, as the // position may have been previously reached with a different score. // This is what prevents a search instability. // FIXME: This is not true, since other positions will be affected by // the new values for all the drawn positions. if (DRAW_TEST) { if (!root && history.contains(pos)) { return 0; } } // Look in the table if we have already searched this position before. // We also need to be sure, that the stored search was over the same // nodes as the current search. var entry = tpScore[[pos, depth, root]] ?? Entry(-MATE_UPPER, MATE_UPPER); if (entry.lower >= gamma && (!root || tpMove[pos] != null)) { return entry.lower; } if (entry.upper < gamma) { return entry.upper; } // Here extensions may be added // Such as 'if in_check: depth += 1' // Generator of moves to search in order. // This allows us to define the moves, but only calculate them if needed. Iterable> moves() sync* { // First try not moving at all. We only do this if there is at least one major // piece left on the board, since otherwise zugzwangs are too dangerous. // if (depth > 0 && !root && any(c in pos.board for c in 'RBNQ')){ if (depth > 0 && !root && pos.board.split('').any((c) => 'RBNQ'.contains(c))) { // print('post 1st condition'); yield ([null, -1 * bound(pos.nullmove(), 1 - gamma, depth - 3, root: false)]); } // For QSearch we have a different kind of null-move, namely we can just stop // and not capture anything else. if (depth == 0) { // print('post 2nd condition'); yield ([null, pos.score]); } // Then killer move. We search it twice, but the tp will fix things for us. // Note, we don't have to check for legality, since we've already done it // before. Also note that in QS the killer must be a capture, otherwise we // will be non deterministic. var killer = tpMove[pos]; if (killer != null && (depth > 0 || pos.value(killer) >= QS_LIMIT)) { // print('post killer'); yield ([killer, -1 * bound(pos.movePiece(killer), 1 - gamma, depth - 1, root: false)]); } // Then all the other moves var possibleMoves = pos.gen_moves().toList() ..sort((a, b) => pos.value(a).compareTo(pos.value(b))) ..toList(); //.reversed; for (var move in possibleMoves.reversed) { //pos.gen_moves().toList().sort((a, b) => a.value.compareto(b.value)).toList().reversed)) { // for val, move in sorted(((pos.value(move), move) for move in pos.gen_moves()), reverse=true): // If depth == 0 we only try moves with high intrinsic score (captures and // promotions). Otherwise we do all moves. if (depth > 0 || pos.value(move) >= QS_LIMIT) { yield ([move, -1 * bound(pos.movePiece(move), 1 - gamma, depth - 1, root: false)]); } } } // Run through the moves, shortcutting when possible var best = -MATE_UPPER; for (var m in moves()) { List? move = m[0]; int score = m[1]; best = max(best, score); // if (depth == 0){ // print([ // 'Depth', // depth, // 'Move', // move != null ? UI.render(119 - move[0]) + UI.render(119 - move[1]) : 'No move', // 'Score', // score, // 'Best', // best, // 'Gamma', // gamma, // 'STOP', // best >= gamma // ]);} if (best >= gamma) { // Clear before setting, so we always have a value if (tpMove.length > TABLE_SIZE) { tpMove.clear(); } // Save the move for pv construction and killer heuristic tpMove[pos] = move; break; } } // Stalemate checking is a bit tricky: Say we failed low, because // we can't (legally) move and so the (real) score is -infty. // At the next depth we are allowed to just return r, -infty <= r < gamma, // which is normally fine. // However, what if gamma = -10 and we don't have any legal moves? // Then the score is actaully a draw and we should fail high! // Thus, if best < gamma and best < 0 we need to double check what we are doing. // This doesn't prevent sunfish from making a move that results in stalemate, // but only if depth == 1, so that's probably fair enough. // (Btw, at depth 1 we can also mate without realizing.) if (best < gamma && best < 0 && depth > 0) { bool is_dead(pos) { return pos.value.any((m) => m >= MATE_LOWER); } // if (all(is_dead(pos.move(m)) for m in pos.gen_moves())){ if (pos.gen_moves().every((m) => is_dead(pos.movePiece(m)))) { var in_check = is_dead(pos.nullmove()); best = in_check ? -MATE_UPPER : 0; } } // Clear before setting, so we always have a value if (tpScore.length > TABLE_SIZE) { tpScore.clear(); } // Table part 2 if (best >= gamma) { tpScore[[pos, depth, root]] = Entry(best, entry.upper); } if (best < gamma) { tpScore[[pos, depth, root]] = Entry(entry.lower, best); } return best; } Iterable search(Position pos, Set _history) sync* { ''' Iterative deepening MTD-bi search '''; nodes = 0; if (DRAW_TEST) { history = _history; // print('// Clearing table due to new history') tpScore.clear(); } // In finished games, we could potentially go far enough to cause a recursion // limit exception. Hence we bound the ply. for (var depth = 1; depth < 1000; depth++) { // The inner loop is a binary search on the score of the position. // Inv: lower <= score <= upper // 'while lower != upper' would work, but play tests show a margin of 20 plays // better. var lower = -MATE_UPPER; var upper = MATE_UPPER; while (lower < upper - EVAL_ROUGHNESS) { var gamma = (lower + upper + 1) ~/ 2; var score = bound(pos, gamma, depth); if (score >= gamma) lower = score; if (score < gamma) upper = score; } // We want to make sure the move to play hasn't been kicked out of the table, // So we make another call that must always fail high and thus produce a move. // bound(pos, lower, depth); // If the game hasn't finished we can retrieve our move from the // transposition table. yield ([ depth, tpMove[pos], tpScore[[pos, depth, true]]?.lower ]); } } } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // User interface ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class UI { static int parse(String? c) { var fil = c![0].codeUnits.first - 'a'.codeUnits.first; var rank = int.parse(c[1]) - 1; return A1 + fil - 10 * rank; } static String render(int i) { var rank = (i - A1) ~/ 10; var fil = (i - A1) % 10; return String.fromCharCodes([fil + 'a'.codeUnits.first]) + (-rank + 1).toString(); } static void printBoard(Position pos) { final uni_pieces = { 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚', 'P': '♟', 'r': '♖', 'n': '♘', 'b': '♗', 'q': '♕', 'k': '♔', 'p': '♙', '.': '·', // ' ': ' ' }; print(''); final bordChars = pos.board.split('\n'); for (var i = 0; i < bordChars.length; i++) { var row = bordChars[i]; if (!row.isSpace) { var pieces = row.split('').map((e) => e = e.isSpace ? '' : uni_pieces[e]!); print('${(10 - i)}${pieces.join(' ')}'); } } print(' a b c d e f g h \n\n'); } } class GameBot { GameBot(); void gameMain() { var hist = [ Position(initial, 0, [true, true], [true, true], 0, 0), ]; var searcher = Searcher(); while (true) { UI.printBoard(hist.last); if (hist.last.score <= -MATE_LOWER) { print('You lost'); break; } // We query the user until she enters a (pseudo) legal move. var move = []; final possibleMoves = hist.last.gen_moves().toList(); while (!possibleMoves.doContains(move)) { var pattern = RegExp( r'([a-h][1-8])' * 2, caseSensitive: false, multiLine: false, ); print('Your move:'); var input = stdin.readLineSync(); var match = pattern.firstMatch(input.toString()); if (match != null) { move = [UI.parse(match.group(1)), UI.parse(match.group(2))]; if (!possibleMoves.doContains(move)) { print('Not valid move typed in. Try again'); } } else { // Inform the user when invalid input (e.g. "help") is entered print('Please enter a move like g8f6'); } } try { hist.add(hist.last.movePiece(move)); } catch (e) { // print(e.toString()); } // After our move we rotate the board and print it again. // This allows us to see the effect of our move. UI.printBoard(hist.last.rotate()); // print('${hist.last.score}'); if (hist.last.score <= -MATE_LOWER) { print('You won'); break; } // Fire up the engine to look for a move. var _depth = 0; var score = 0; var start = getSeconds(); /* */ for (var s in searcher.search(hist.last, hist.toSet())) { _depth = s[0]; move = s[1]; score = s[2] ?? 0; if (getSeconds() - start > 1) { break; } } if (score == MATE_UPPER) { print('Checkmate!'); } // The black player moves from a rotated position, so we have to // 'back rotate' the move before printing it. print('My move: ${UI.render(119 - move[0]) + UI.render(119 - move[1])}'); hist.add(hist.last.movePiece(move)); } } void playGame() { dv.log('message'); setPst(); gameMain(); } }