From: Benjamin Auder Date: Mon, 18 Jan 2021 19:25:19 +0000 (+0100) Subject: Add Fanorona X-Git-Url: https://git.auder.net/variants/current/doc/%3C?a=commitdiff_plain;h=cdab566355412821c9187078ee0864ceb30545de;p=vchess.git Add Fanorona --- diff --git a/TODO b/TODO index 7a50f280..10788bd0 100644 --- a/TODO +++ b/TODO @@ -2,7 +2,6 @@ PROBABLY WON'T FIX: Embedded rules language not updated when language is set (in Analyse, Game and Problems) If new live game starts in background, "new game" notify OK but not first move. -NEW VARIANTS, Non-chess ( won't add draughts: https://lidraughts.org/ ) -Gomoku, Konane -Fanorona https://fr.wikipedia.org/wiki/Fanorona -Yoté https://fr.wikipedia.org/wiki/Yot%C3%A9 http://www.zillionsofgames.com/cgi-bin/zilligames/submissions.cgi/92187?do=show;id=960 +"FreeBoard", re-using a lot of Board logic, but with SVG (empty) board + SVG (empty) reserves. +Will be used for variants with custom non-rectangular board (Hex, at least) +Or, with other board shapes (see greenchess.net for example) diff --git a/client/public/images/pieces/Fanorona/arrow_-1_-1.svg b/client/public/images/pieces/Fanorona/arrow_-1_-1.svg new file mode 100644 index 00000000..b6cd7893 --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_-1_-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_-1_0.svg b/client/public/images/pieces/Fanorona/arrow_-1_0.svg new file mode 100644 index 00000000..bb22f867 --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_-1_0.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_-1_1.svg b/client/public/images/pieces/Fanorona/arrow_-1_1.svg new file mode 100644 index 00000000..c84fdfd8 --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_-1_1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_0_-1.svg b/client/public/images/pieces/Fanorona/arrow_0_-1.svg new file mode 100644 index 00000000..95699dad --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_0_-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_0_1.svg b/client/public/images/pieces/Fanorona/arrow_0_1.svg new file mode 100644 index 00000000..960fc9eb --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_0_1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_1_-1.svg b/client/public/images/pieces/Fanorona/arrow_1_-1.svg new file mode 100644 index 00000000..a25fea34 --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_1_-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_1_0.svg b/client/public/images/pieces/Fanorona/arrow_1_0.svg new file mode 100644 index 00000000..08948dd7 --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_1_0.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/public/images/pieces/Fanorona/arrow_1_1.svg b/client/public/images/pieces/Fanorona/arrow_1_1.svg new file mode 100644 index 00000000..702706e3 --- /dev/null +++ b/client/public/images/pieces/Fanorona/arrow_1_1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/client/src/base_rules.js b/client/src/base_rules.js index 05491398..e8a82f93 100644 --- a/client/src/base_rules.js +++ b/client/src/base_rules.js @@ -1035,6 +1035,9 @@ export const ChessRules = class ChessRules { // Stop at the first move found // TODO: not really, it explores all moves from a square (one is enough). + // Possible fix: add extra arg "oneMove" to getPotentialMovesFrom, + // and then return only boolean true at first move found + // (in all getPotentialXXXMoves() ... for all variants ...) atLeastOneMove() { const color = this.turn; for (let i = 0; i < V.size.x; i++) { diff --git a/client/src/components/Board.vue b/client/src/components/Board.vue index b176bd73..b4528082 100644 --- a/client/src/components/Board.vue +++ b/client/src/components/Board.vue @@ -365,7 +365,7 @@ export default { const squareWidth = boardElt.offsetWidth / sizeY; const offset = [boardElt.offsetTop, boardElt.offsetLeft]; const maxNbeltsPerRow = Math.min(this.choices.length, sizeY); - let topOffset = offset[0] + (sizeY / 2) * squareWidth - squareWidth / 2; + let topOffset = offset[0] + ((sizeX - 1) / 2) * squareWidth; let choicesHeight = squareWidth; if (this.choices.length >= sizeY) { // A second row is required (Eightpieces variant) diff --git a/client/src/translations/rules/Fanorona/en.pug b/client/src/translations/rules/Fanorona/en.pug index 3a33838b..cc397c60 100644 --- a/client/src/translations/rules/Fanorona/en.pug +++ b/client/src/translations/rules/Fanorona/en.pug @@ -1,2 +1,60 @@ p.boxed - | TODO + | Capture stones by approach or withdrawal. + +p The following is summarized from Wikipedia. + +p. + The Fanorona board consists of 5 rows and 9 columns. + A stone can only move to an adjacent intersection, following the lines. + This movement can lead to captures. + +h3 General rules + +ul + li Players alternate turns, starting with White. + li There are two kinds of moves: non-capturing ("paika") and capturing. + li. + If at the beginning of a turn a capturing move is possible, + then it has to be played. + li + | Capturing implies removing one or more pieces of the opponent, in one of + | two ways: + ul + li. + Approach — moving the capturing stone to a point adjacent to an + opponent's stone, located right after in the movement's direction. + li. + Withdrawal — the capturing stone moves away from an opponent's + stone, initially adjacent. + li. + When an opponent stone is captured, all opponent pieces in line beyond + that stone (and connected to it) are captured as well. + Another capture can then be achieved with the same stone. + +figure.diagram-container + .diagram.diag12 + | fen:pppp1p1pp/pppppPppp/pP1PPp1pP/PPPpPPPPP/PPPP1PPPP: + .diagram.diag22 + | fen:pppp1p1pp/pppppP1pp/pP1PPpppP/PPPpPP1PP/PPPP1P1PP: + figcaption Before and after g4g3 (capturing g2 and g1). + +h3 Some restrictions + +ul + li. + An approach capture and a withdrawal capture cannot be made at the same + time. The two locations will appear on the interface and you'll have + to make a choice. + li + | The capturing piece is allowed to continue making successive captures, + | with these restrictions: + ul + li The piece is not allowed to arrive at the same position twice. + li. + It is not permitted to capture twice consecutively in the same + direction (first by withdrawal, and then by approach). + | However, continuing the capturing sequence is optional. + +p. + The game ends when one player captures all stones of the opponent. + If neither player can achieve this, then the game is a draw. diff --git a/client/src/translations/rules/Fanorona/es.pug b/client/src/translations/rules/Fanorona/es.pug index 3a33838b..0ee83499 100644 --- a/client/src/translations/rules/Fanorona/es.pug +++ b/client/src/translations/rules/Fanorona/es.pug @@ -1,2 +1,61 @@ p.boxed - | TODO + | Captura piedras por percusión o succión. + +p El resto se resume en Wikipedia. + +p. + El tablero de Fanorona tiene 5 filas y 9 columnas. + Una piedra solo puede moverse a una intersección adyacente, + siguiendo las líneas. Este movimiento puede dar lugar a capturas. + +h3 Reglas generales + +ul + li Los jugadores alternan turnos, comenzando con las blancas. + li Hay dos tipos de jugadas: no capturadoras ("paika") y capturadoras. + li. + Si al comienzo de un turno es posible un movimiento de captura, + entonces debe jugarse. + li + | Capturar implica eliminar una o más piezas opuestas, + | una de las dos formas siguientes: + li. + Percusión — moviendo la piedra de captura a un punto + adyacente a una piedra enemiga, ubicada justo después en la dirección + del movimiento. + li. + Succión — la piedra capturadora se aleja de una piedra enemiga, + inicialmente adyacente. + li. + Cuando se captura una piedra enemiga, todas las demás piezas enemigas + en línea más allá de esta piedra (y conectados a ella) están + también capturado. Entonces es posible otra captura + con la misma piedra. + +figure.diagram-container + .diagram.diag12 + | fen:pppp1p1pp/pppppPppp/pP1PPp1pP/PPPpPPPPP/PPPP1PPPP: + .diagram.diag22 + | fen:pppp1p1pp/pppppP1pp/pP1PPpppP/PPPpPP1PP/PPPP1P1PP: + figcaption Antes y después de g4g3 (capturando g2 y g1). + +h3 Algunas restricciones + +ul + li. + No se puede realizar la captura por succión y percusión + al mismo tiempo. Ambas ubicaciones aparecerán en la pantalla y deberá + hacer una elección. + li + | Se permite que la pieza de captura continúe capturando, módulo el + | siguientes restricciones: + ul + li La pieza no se debe planchar dos veces en el mismo lugar. + li. + No puedes capturar dos veces consecutivas en la misma dirección + (primero por succión, luego por percusión). + | Sin embargo, continuar con la secuencia de captura es opcional. + +p. + El juego termina cuando uno de los jugadores ha capturado todos los + del otro. Si ninguno de ellos tiene éxito, entonces es un empate. diff --git a/client/src/translations/rules/Fanorona/fr.pug b/client/src/translations/rules/Fanorona/fr.pug index 3a33838b..8f13edb5 100644 --- a/client/src/translations/rules/Fanorona/fr.pug +++ b/client/src/translations/rules/Fanorona/fr.pug @@ -1,2 +1,61 @@ p.boxed - | TODO + | Capturez des pierres par percussion ou aspiration. + +p La suite est résumée depuis Wikipedia. + +p. + Le plateau de Fanorona comporte 5 rangées et 9 colonnes. + Une pierre peut seulement se déplacer vers une intersection adjacente, + suivant les lignes. Ce mouvement peut donner lieu à des captures. + +h3 Règles générales + +ul + li Les joueurs alternent les tours, commençant avec les blancs. + li Il y a deux types de coups : non-capturants ("paika") et capturants. + li. + Si au début d'un tour un coup capturant est possible, + alors il doit être joué. + li + | Capturer implique retirer une ou plusieurs pièces adverses, + | d'une des deux manières suivantes : + li. + Percussion — en déplaçant la pierre capturante vers un point + adjacent à une pierre ennemie, située juste après dans la direction du + mouvement. + li. + Aspiration — la pierre capturante s'éloigne d'une pierre ennemie, + initialement adjacente. + li. + Quand une pierre ennemie est capturée, toutes les autres pièces adverses + en ligne au-delà de cette pierre (et connectées à celle-ci) sont + capturées également. Une autre capture est ensuite envisageable + avec la même pierre. + +figure.diagram-container + .diagram.diag12 + | fen:pppp1p1pp/pppppPppp/pP1PPp1pP/PPPpPPPPP/PPPP1PPPP: + .diagram.diag22 + | fen:pppp1p1pp/pppppP1pp/pP1PPpppP/PPPpPP1PP/PPPP1P1PP: + figcaption Avant et après g4g3 (capturant g2 et g1). + +h3 Quelques restrictions + +ul + li. + Une capture par percussion et une par aspiration ne peuvent être réalisées + en même temps. Les deux emplacements apparaitront à l'écran et vous devrez + faire un choix. + li + | La pièce capturante est autorisée à continuer de capturer, modulo les + | restrictions suivantes : + ul + li La pièce ne doit pas repasser deux fois au même endroit. + li. + On ne peut pas capturer deux fois consécutives dans la même direction + (d'abord par aspiration, puis par percussion). + | Cependant, continuer la séquence de capture est optionnel. + +p. + La partie s'achève quand un des joueurs a capturé toutes les pierres de + l'autre. Si aucun des deux n'y parvient, alors c'est un match nul. diff --git a/client/src/variants/Fanorona.js b/client/src/variants/Fanorona.js index bd92c859..04eea2c3 100644 --- a/client/src/variants/Fanorona.js +++ b/client/src/variants/Fanorona.js @@ -46,38 +46,192 @@ export class FanoronaRules extends ChessRules { setOtherVariables(fen) { // Local stack of captures during a turn (squares + directions) - this.captures = []; + this.captures = [ [] ]; } static get size() { return { x: 5, y: 9 }; } - static get PIECES() { - return [V.PAWN]; - } - getPiece() { return V.PAWN; } + static IsGoodPosition(position) { + if (position.length == 0) return false; + const rows = position.split("/"); + if (rows.length != V.size.x) return false; + for (let row of rows) { + let sumElts = 0; + for (let i = 0; i < row.length; i++) { + if (row[i].toLowerCase() == V.PAWN) sumElts++; + else { + const num = parseInt(row[i], 10); + if (isNaN(num) || num <= 0) return false; + sumElts += num; + } + } + if (sumElts != V.size.y) return false; + } + return true; + } + getPpath(b) { return "Fanorona/" + b; } - //TODO - //getPPpath() {} + getPPpath(m) { + // m.vanish.length >= 2, first capture gives direction + const ref = (Math.abs(m.vanish[1].x - m.start.x) == 1 ? m.start : m.end); + const step = [m.vanish[1].x - ref.x, m.vanish[1].y - ref.y]; + const normalizedStep = [ + step[0] / Math.abs(step[0]), + step[1] / Math.abs(step[1]) + ]; + return ( + "Fanorona/arrow_" + + (normalizedStep[0] || 0) + "_" + (normalizedStep[1] || 0) + ); + } + + // After moving, add stones captured in "step" direction from new location + // [x, y] to mv.vanish (if any captured stone!) + addCapture([x, y], step, move) { + let [i, j] = [x + step[0], y + step[1]]; + const oppCol = V.GetOppCol(move.vanish[0].c); + while ( + V.OnBoard(i, j) && + this.board[i][j] != V.EMPTY && + this.getColor(i, j) == oppCol + ) { + move.vanish.push(new PiPo({ x: i, y: j, c: oppCol, p: V.PAWN })); + i += step[0]; + j += step[1]; + } + return (move.vanish.length >= 2); + } getPotentialMovesFrom([x, y]) { - // NOTE: (x + y) % 2 == 0 ==> has diagonals - // TODO - // Même stratégie que Yote, revenir sur ses pas si stop avant de tout capturer - // Mais première capture obligatoire (si this.captures.length == 0). - // After a capture: allow only capturing. - // Warning: case 3 on Wikipedia page, if both percussion & aspiration, - // two different moves, cannot take all ==> adjust getPPpath showing arrows. - // nice looking arrows, with something representing a capture at its end... - return []; + const L0 = this.captures.length; + const captures = this.captures[L0 - 1]; + const L = captures.length; + if (L > 0) { + var c = captures[L-1]; + if (x != c.square.x + c.step[0] || y != c.square.y + c.step[1]) + return []; + } + const oppCol = V.GetOppCol(this.turn); + let steps = V.steps[V.ROOK]; + if ((x + y) % 2 == 0) steps = steps.concat(V.steps[V.BISHOP]); + let moves = []; + for (let s of steps) { + if (L > 0 && c.step[0] == s[0] && c.step[1] == s[1]) { + // Add a move to say "I'm done capturing" + moves.push( + new Move({ + appear: [], + vanish: [], + start: { x: x, y: y }, + end: { x: x - s[0], y: y - s[1] } + }) + ); + continue; + } + let [i, j] = [x + s[0], y + s[1]]; + if (captures.some(c => c.square.x == i && c.square.y == j)) continue; + if (V.OnBoard(i, j) && this.board[i][j] == V.EMPTY) { + // The move is potentially allowed. Might lead to 2 different captures + let mv = super.getBasicMove([x, y], [i, j]); + const capt = this.addCapture([i, j], s, mv); + if (capt) { + moves.push(mv); + mv = super.getBasicMove([x, y], [i, j]); + } + const capt_bw = this.addCapture([x, y], [-s[0], -s[1]], mv); + if (capt_bw) moves.push(mv); + // Captures take priority (if available) + if (!capt && !capt_bw && L == 0) moves.push(mv); + } + } + return moves; + } + + atLeastOneCapture() { + const color = this.turn; + const oppCol = V.GetOppCol(color); + const L0 = this.captures.length; + const captures = this.captures[L0 - 1]; + const L = captures.length; + if (L > 0) { + // If some adjacent enemy stone, with free space to capture it, + // toward a square not already visited, through a different step + // from last one: then yes. + const c = captures[L-1]; + const [x, y] = [c.square.x + c.step[0], c.square.y + c.step[1]]; + let steps = V.steps[V.ROOK]; + if ((x + y) % 2 == 0) steps = steps.concat(V.steps[V.BISHOP]); + // TODO: half of the steps explored are redundant + for (let s of steps) { + if (s[0] == c.step[0] && s[1] == c.step[1]) continue; + const [i, j] = [x + s[0], y + s[1]]; + if ( + !V.OnBoard(i, j) || + this.board[i][j] != V.EMPTY || + captures.some(c => c.square.x == i && c.square.y == j) + ) { + continue; + } + if ( + V.OnBoard(i + s[0], j + s[1]) && + this.board[i + s[0]][j + s[1]] != V.EMPTY && + this.getColor(i + s[0], j + s[1]) == oppCol + ) { + return true; + } + if ( + V.OnBoard(x - s[0], y - s[1]) && + this.board[x - s[0]][y - s[1]] != V.EMPTY && + this.getColor(x - s[0], y - s[1]) == oppCol + ) { + return true; + } + } + return false; + } + for (let i = 0; i < V.size.x; i++) { + for (let j = 0; j < V.size.y; j++) { + if ( + this.board[i][j] != V.EMPTY && + this.getColor(i, j) == color && + // TODO: this could be more efficient + this.getPotentialMovesFrom([i, j]).some(m => m.vanish.length >= 2) + ) { + return true; + } + } + } + return false; + } + + static KeepCaptures(moves) { + return moves.filter(m => m.vanish.length >= 2); + } + + getPossibleMovesFrom(sq) { + let moves = this.getPotentialMovesFrom(sq); + const L0 = this.captures.length; + const captures = this.captures[L0 - 1]; + if (captures.length > 0) return this.getPotentialMovesFrom(sq); + const captureMoves = V.KeepCaptures(moves); + if (captureMoves.length > 0) return captureMoves; + if (this.atLeastOneCapture()) return []; + return moves; + } + + getAllValidMoves() { + const moves = super.getAllValidMoves(); + if (moves.some(m => m.vanish.length >= 2)) return V.KeepCaptures(moves); + return moves; } filterValid(moves) { @@ -88,34 +242,42 @@ export class FanoronaRules extends ChessRules { return []; } - //TODO: function aux to detect if continuation captures - //(not trivial, but not difficult) - play(move) { const color = this.turn; move.turn = color; //for undo - const captureNotEnding = ( - move.vanish.length >= 2 && - true //TODO: detect if there are continuation captures - ); - this.captures.push(captureNotEnding); //TODO: something more structured - //with square + direction of capture - if (captureNotEnding) move.notTheEnd = true; - else { - this.turn = oppCol; + V.PlayOnBoard(this.board, move); + const L0 = this.captures.length; + let captures = this.captures[L0 - 1]; + if (move.vanish.length >= 2) { + captures.push({ + square: move.start, + step: [move.end.x - move.start.x, move.end.y - move.start.y] + }); + if (this.atLeastOneCapture()) + // There could be other captures (optional) + // This field is mostly useful for computer play. + move.notTheEnd = true; + else captures.pop(); //useless now + } + if (!move.notTheEnd) { + this.turn = V.GetOppCol(color); this.movesCount++; + this.captures.push([]); } - this.postPlay(move); } undo(move) { V.UndoOnBoard(this.board, move); - this.captures.pop(); if (move.turn != this.turn) { this.turn = move.turn; this.movesCount--; + this.captures.pop(); + } + else { + const L0 = this.captures.length; + let captures = this.captures[L0 - 1]; + captures.pop(); } - this.postUndo(move); } getCurrentScore() { @@ -134,7 +296,7 @@ export class FanoronaRules extends ChessRules { } getComputerMove() { - const moves = super.getAllValidMoves(); + const moves = this.getAllValidMoves(); if (moves.length == 0) return null; const color = this.turn; // Capture available? If yes, play it @@ -147,19 +309,18 @@ export class FanoronaRules extends ChessRules { if (candidates.length >= 1) mv = candidates[randInt(candidates.length)]; else mv = captures[randInt(captures.length)]; this.play(mv); - captures = (this.turn == color ? super.getAllValidMoves() : []); + mvArray.push(mv); + captures = (this.turn == color ? this.getAllValidMoves() : []); } if (mvArray.length >= 1) { for (let i = mvArray.length - 1; i >= 0; i--) this.undo(mvArray[i]); return mvArray; } - // Just play a random move, which if possible do not let a capture + // Just play a random move, which if possible does not let a capture let candidates = []; for (let m of moves) { this.play(m); - const moves2 = super.getAllValidMoves(); - if (moves2.every(m2 => m2.vanish.length <= 1)) - candidates.push(m); + if (!this.atLeastOneCapture()) candidates.push(m); this.undo(m); } if (candidates.length >= 1) return candidates[randInt(candidates.length)]; @@ -167,10 +328,11 @@ export class FanoronaRules extends ChessRules { } getNotation(move) { + if (move.appear.length == 0) return "stop"; return ( V.CoordsToSquare(move.start) + - (move.vanish.length >= 2 ? "x" : "") + - V.CoordsToSquare(move.end) + V.CoordsToSquare(move.end) + + (move.vanish.length >= 2 ? "X" : "") ); } diff --git a/client/src/variants/Konane.js b/client/src/variants/Konane.js index 6acc54ba..357bae75 100644 --- a/client/src/variants/Konane.js +++ b/client/src/variants/Konane.js @@ -15,10 +15,6 @@ export class KonaneRules extends ChessRules { return true; } - static get PIECES() { - return V.PAWN; - } - getPiece() { return V.PAWN; } @@ -34,7 +30,7 @@ export class KonaneRules extends ChessRules { for (let row of rows) { let sumElts = 0; for (let i = 0; i < row.length; i++) { - if (V.PIECES.includes(row[i].toLowerCase())) sumElts++; + if (row[i].toLowerCase() == V.PAWN) sumElts++; else { const num = parseInt(row[i], 10); if (isNaN(num) || num <= 0) return false; diff --git a/client/src/variants/Yote.js b/client/src/variants/Yote.js index 7158d4f4..76d7c2e9 100644 --- a/client/src/variants/Yote.js +++ b/client/src/variants/Yote.js @@ -23,6 +23,25 @@ export class YoteRules extends ChessRules { return true; } + static IsGoodPosition(position) { + if (position.length == 0) return false; + const rows = position.split("/"); + if (rows.length != V.size.x) return false; + for (let row of rows) { + let sumElts = 0; + for (let i = 0; i < row.length; i++) { + if (row[i].toLowerCase() == V.PAWN) sumElts++; + else { + const num = parseInt(row[i], 10); + if (isNaN(num) || num <= 0) return false; + sumElts += num; + } + } + if (sumElts != V.size.y) return false; + } + return true; + } + static IsGoodFen(fen) { if (!ChessRules.IsGoodFen(fen)) return false; const fenParsed = V.ParseFen(fen); @@ -120,10 +139,6 @@ export class YoteRules extends ChessRules { return { x: 5, y: 6 }; } - static get PIECES() { - return [V.PAWN]; - } - getColor(i, j) { if (i >= V.size.x) return i == V.size.x ? "w" : "b"; return this.board[i][j].charAt(0);