From 1a3cfdc05b40c8ecc79397be02529b35411f073f Mon Sep 17 00:00:00 2001
From: Benjamin Auder <benjamin.auder@somewhere>
Date: Thu, 26 Mar 2020 22:47:15 +0100
Subject: [PATCH] More efficient Synchrone chess + fix a bug. FIrst draft of
 Apocalypse

---
 client/src/App.vue                            |   4 +
 .../src/translations/rules/Apocalypse/en.pug  |  63 +++
 .../src/translations/rules/Apocalypse/es.pug  |   2 +
 .../src/translations/rules/Apocalypse/fr.pug  |   2 +
 client/src/variants/Apocalypse.js             | 421 ++++++++++++++++++
 client/src/variants/Synchrone.js              |  30 +-
 client/src/views/Game.vue                     |   2 +-
 7 files changed, 510 insertions(+), 14 deletions(-)
 create mode 100644 client/src/translations/rules/Apocalypse/en.pug
 create mode 100644 client/src/translations/rules/Apocalypse/es.pug
 create mode 100644 client/src/translations/rules/Apocalypse/fr.pug
 create mode 100644 client/src/variants/Apocalypse.js

diff --git a/client/src/App.vue b/client/src/App.vue
index 77059bba..caf91a96 100644
--- a/client/src/App.vue
+++ b/client/src/App.vue
@@ -301,6 +301,10 @@ div.board
   display: inline-block
   position: relative
 
+div.board5
+  width: 20%
+  padding-bottom: 20%
+
 div.board8
   width: 12.5%
   padding-bottom: 12.5%
diff --git a/client/src/translations/rules/Apocalypse/en.pug b/client/src/translations/rules/Apocalypse/en.pug
new file mode 100644
index 00000000..3a0ef740
--- /dev/null
+++ b/client/src/translations/rules/Apocalypse/en.pug
@@ -0,0 +1,63 @@
+p.boxed
+  | Both players play a move "at the same time".
+  | The goal is to eliminate all pawns.
+
+p
+  | This variant is inspired by the 
+  a(href="https://en.wikipedia.org/wiki/Four_Horsemen_of_the_Apocalypse")
+    | &nbsp;Four Horsemen of the Apocalypse
+  | mythology. Knights are horsemen, and pawns are footmen.
+  | The goal is to eliminate all enemy footmen,
+  | most likely with the help of your horsemen.
+  | If all footmen die, the other side wins.
+
+p.
+  At each turn you can decide either to play safely an apparently valid move,
+  or speculate on your opponent's move and choose a move valid only
+  conditionally on his choice. In this last case the move may end up not
+  being playable: you would get a penalty point. Two penalty points loses
+  the game. For example in the initial position, 1.(c1)c2 is safe while 1.axb3
+  will be valid only if black plays 1...Nb3.
+
+p Resolving rules:
+ul
+  li.
+    If both moves are illegal none are played.
+    If one is illegal, the other is played.
+  li.
+    If both moves arrive on the same square, both pieces disappear except
+    if one is a horseman and the other a footman.
+    In this case only the horseman remains.
+  li.
+    If a capture was intended but the target moved, the move is still played
+    but doesn't capture anything.
+
+figure.diagram-container
+  .diagram
+    | fen:npppn/p4/4P/P2pP/NPP1N:
+  figcaption After 1.d1d2 e4e3 2.dxe3 exd2, pawns placements are inversed.
+
+h3 Pawn promotions
+
+p.
+  Pawns automatically promote in a knight, except if the player already
+  have two horsemen on the board. In this case the footman is relocated on
+  any free square which is not on last rank.
+
+h3 End of the game
+
+p.
+  As stated previously, losing all pawns lose the game, so promoting your
+  last pawn loses. It may be the only legal move.
+  If however both footmen armies vanish at the same time, it's a draw.
+  It can happen if the two last pawns decide to advance to the same square.
+
+h3 Source
+
+p
+  a(href="https://www.chessvariants.com/rules/apocalypse") Apocalypse chess
+  | &nbsp;on chessvariants.com. This variant is playable at 
+  a(href="http://apocalypsechess.online/") apocalypsechess.online
+  | &nbsp;but without the promotion restriction.
+
+p Inventor: C.S. Elliott (1976)
diff --git a/client/src/translations/rules/Apocalypse/es.pug b/client/src/translations/rules/Apocalypse/es.pug
new file mode 100644
index 00000000..3a33838b
--- /dev/null
+++ b/client/src/translations/rules/Apocalypse/es.pug
@@ -0,0 +1,2 @@
+p.boxed
+  | TODO
diff --git a/client/src/translations/rules/Apocalypse/fr.pug b/client/src/translations/rules/Apocalypse/fr.pug
new file mode 100644
index 00000000..3a33838b
--- /dev/null
+++ b/client/src/translations/rules/Apocalypse/fr.pug
@@ -0,0 +1,2 @@
+p.boxed
+  | TODO
diff --git a/client/src/variants/Apocalypse.js b/client/src/variants/Apocalypse.js
new file mode 100644
index 00000000..96431c35
--- /dev/null
+++ b/client/src/variants/Apocalypse.js
@@ -0,0 +1,421 @@
+import { ChessRules } from "@/base_rules";
+import { randInt } from "@/utils/alea";
+
+export class ApocalypseRules extends ChessRules {
+  static get PawnSpecs() {
+    return Object.assign(
+      {},
+      ChessRules.PawnSpecs,
+      {
+        twoSquares: false,
+        promotions: [V.KNIGHT]
+      }
+    );
+  }
+
+  static get HasCastle() {
+    return false;
+  }
+
+  static get HasEnpassant() {
+    return false;
+  }
+
+  static get CanAnalyze() {
+    return false;
+  }
+
+  static get ShowMoves() {
+    return "byrow";
+  }
+
+  static get PIECES() {
+    return [V.PAWN, V.KNIGHT];
+  }
+
+  static IsGoodPosition(position) {
+    if (position.length == 0) return false;
+    const rows = position.split("/");
+    if (rows.length != V.size.x) return false;
+    // At least one pawn per color
+    let pawns = { "p": 0, "P": 0 };
+    for (let row of rows) {
+      let sumElts = 0;
+      for (let i = 0; i < row.length; i++) {
+        if (['P','p'].includes(row[i])) pawns[row[i]]++;
+        if (V.PIECES.includes(row[i].toLowerCase())) sumElts++;
+        else {
+          const num = parseInt(row[i]);
+          if (isNaN(num)) return false;
+          sumElts += num;
+        }
+      }
+      if (sumElts != V.size.y) return false;
+    }
+    if (Object.values(pawns).some(v => v == 0))
+      return false;
+    return true;
+  }
+
+  static IsGoodFen(fen) {
+    if (!ChessRules.IsGoodFen(fen)) return false;
+    const fenParsed = V.ParseFen(fen);
+    // 4) Check whiteMove
+    if (
+      (
+        fenParsed.turn == "w" &&
+        // NOTE: do not check really JSON stringified move...
+        (!fenParsed.whiteMove || fenParsed.whiteMove == "-")
+      )
+      ||
+      (fenParsed.turn == "b" && fenParsed.whiteMove != "-")
+    ) {
+      return false;
+    }
+    return true;
+  }
+
+  static IsGoodFlags(flags) {
+    return !!flags.match(/^[0-2]{2,2}$/);
+  }
+
+  aggregateFlags() {
+    return this.penaltyFlags;
+  }
+
+  disaggregateFlags(flags) {
+    this.penaltyFlags = flags;
+  }
+
+  static ParseFen(fen) {
+    const fenParts = fen.split(" ");
+    return Object.assign(
+      ChessRules.ParseFen(fen),
+      { whiteMove: fenParts[4] }
+    );
+  }
+
+  static get size() {
+    return { x: 5, y: 5 };
+  }
+
+  static GenRandInitFen() {
+    return "npppn/p3p/5/P3P/NPPPN w 0 00 -";
+  }
+
+  getFen() {
+    return super.getFen() + " " + this.getWhitemoveFen();
+  }
+
+  getFenForRepeat() {
+    return super.getFenForRepeat() + "_" + this.getWhitemoveFen();
+  }
+
+  getFlagsFen() {
+    return this.penaltyFlags.join("");
+  }
+
+  setOtherVariables(fen) {
+    const parsedFen = V.ParseFen(fen);
+    this.setFlags(parsedFen.flags);
+    // Also init whiteMove
+    this.whiteMove =
+      parsedFen.whiteMove != "-"
+        ? JSON.parse(parsedFen.whiteMove)
+        : null;
+  }
+
+  setFlags(fenflags) {
+    this.penaltyFlags = [0, 1].map(i => parseInt(fenflags[i]));
+  }
+
+  getWhitemoveFen() {
+    if (!this.whiteMove) return "-";
+    return JSON.stringify({
+      start: this.whiteMove.start,
+      end: this.whiteMove.end,
+      appear: this.whiteMove.appear,
+      vanish: this.whiteMove.vanish
+    });
+  }
+
+  getSpeculations(moves, sq) {
+    let moveSet = {};
+    moves.forEach(m => {
+      const mHash = "m" + m.start.x + m.start.y + m.end.x + m.end.y;
+      moveSet[mHash] = true;
+    });
+    const color = this.turn;
+    this.turn = V.GetOppCol(color);
+    const oppMoves = super.getAllValidMoves();
+    this.turn = color;
+    // For each opponent's move, generate valid moves [from sq]
+    let speculations = [];
+    oppMoves.forEach(m => {
+      V.PlayOnBoard(this.board, m);
+      const newValidMoves =
+        !!sq
+          ? super.getPotentialMovesFrom(sq)
+          : super.getAllValidMoves();
+      newValidMoves.forEach(vm => {
+        const mHash = "m" + vm.start.x + vm.start.y + vm.end.x + vm.end.y;
+        if (!moveSet[mHash]) {
+          moveSet[mHash] = true;
+          vm.illegal = true; //potentially illegal!
+          speculations.push(vm);
+        }
+      });
+      V.UndoOnBoard(this.board, m);
+    });
+    return speculations;
+  }
+
+  getPossibleMovesFrom([x, y]) {
+    const possibleMoves = super.getPotentialMovesFrom([x, y])
+    // Augment potential moves with opponent's moves speculation:
+    return possibleMoves.concat(this.getSpeculations(possibleMoves, [x, y]));
+  }
+
+  getAllValidMoves() {
+    // Return possible moves + potentially valid moves
+    const validMoves = super.getAllValidMoves();
+    return validMoves.concat(this.getSpeculations(validMoves));
+  }
+
+  addPawnMoves([x1, y1], [x2, y2], moves) {
+    let finalPieces = [V.PAWN];
+    const color = this.turn;
+    const lastRank = (color == "w" ? 0 : V.size.x - 1);
+    if (x2 == lastRank) {
+      // If 0 or 1 horsemen, promote in knight
+      let knightCounter = 0;
+      let emptySquares = [];
+      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) emptySquares.push([i, j]);
+          else if (
+            this.getColor(i, j) == color &&
+            this.getPiece(i, j) == V.KNIGHT
+          ) {
+            knightCounter++;
+          }
+        }
+      }
+      if (knightCounter <= 1) finalPieces = [V.KNIGHT];
+      else {
+        // Generate all possible landings
+        emptySquares.forEach(sq => {
+          if (sq[0] != lastRank)
+            moves.push(this.getBasicMove([x1, y1], [sq[0], sq[1]]));
+        });
+        return;
+      }
+    }
+    let tr = null;
+    for (let piece of finalPieces) {
+      tr = (piece != V.PAWN ? { c: color, p: piece } : null);
+      moves.push(this.getBasicMove([x1, y1], [x2, y2], tr));
+    }
+  }
+
+  filterValid(moves) {
+    // No checks:
+    return moves;
+  }
+
+  atLeastOneMove(color) {
+    const curTurn = this.turn;
+    this.turn = color;
+    const res = super.atLeastOneMove();
+    this.turn = curTurn;
+    return res;
+  }
+
+  // White and black (partial) moves were played: merge
+  resolveSynchroneMove(move) {
+    let m = [this.whiteMove, move];
+    for (let i of [0, 1]) {
+      if (!!m[i].illegal) {
+        // Either an anticipated capture of something which didn't move
+        // (or not to the right square), or a push through blocus.
+        if (
+          (
+            // Push attempt
+            m[i].start.y == m[i].end.y &&
+            (m[1-i].start.x != m[i].end.x || m[1-i].start.y != m[i].end.y)
+          )
+          ||
+          (
+            // Capture attempt
+            Math.abs(m[i].start.y - m[i].end.y) == 1 &&
+            (m[1-i].end.x != m[i].end.x || m[1-i].end.y != m[i].end.y)
+          )
+        ) {
+          // Just discard the move, and add a penalty point
+          this.penaltyFlags[m[i].vanish[0].c]++;
+          m[i] = null;
+        }
+      }
+    }
+
+    // For PlayOnBoard (no need for start / end, irrelevant)
+    let smove = {
+      appear: [],
+      vanish: []
+    };
+    const m1 = m[0],
+          m2 = m[1];
+    // If one move is illegal, just execute the other
+    if (!m1 && !!m2) return m2;
+    if (!m2 && !!m1) return m1;
+    if (!m1 && !m2) return smove;
+    // Both move are now legal:
+    smove.vanish.push(m1.vanish[0]);
+    smove.vanish.push(m2.vanish[0]);
+    if ((m1.end.x != m2.end.x) || (m1.end.y != m2.end.y)) {
+      // Easy case: two independant moves
+      smove.appear.push(m1.appear[0]);
+      smove.appear.push(m2.appear[0]);
+      // "Captured" pieces may have moved:
+      if (
+        m1.vanish.length == 2 &&
+        (
+          m1.vanish[1].x != m2.start.x ||
+          m1.vanish[1].y != m2.start.y
+        )
+      ) {
+        smove.vanish.push(m1.vanish[1]);
+      }
+      if (
+        m2.vanish.length == 2 &&
+        (
+          m2.vanish[1].x != m1.start.x ||
+          m2.vanish[1].y != m1.start.y
+        )
+      ) {
+        smove.vanish.push(m2.vanish[1]);
+      }
+    } else {
+      // Collision: both disappear except if different kinds (knight remains)
+      const p1 = m1.vanish[0].p;
+      const p2 = m2.vanish[0].p;
+      if ([p1, p2].includes(V.KNIGHT) && [p1, p2].includes(V.PAWN)) {
+        smove.appear.push({
+          x: m1.end.x,
+          y: m1.end.y,
+          p: V.KNIGHT,
+          c: (p1 == V.KNIGHT ? 'w' : 'b')
+        });
+      }
+    }
+    return smove;
+  }
+
+  play(move) {
+    // Do not play on board (would reveal the move...)
+    move.flags = JSON.stringify(this.aggregateFlags());
+    this.turn = V.GetOppCol(this.turn);
+    this.movesCount++;
+    this.postPlay(move);
+  }
+
+  postPlay(move) {
+    if (this.turn == 'b') {
+      // NOTE: whiteMove is used read-only, so no need to copy
+      this.whiteMove = move;
+      return;
+    }
+
+    // A full turn just ended:
+    const smove = this.resolveSynchroneMove(move);
+    V.PlayOnBoard(this.board, smove);
+    move.whiteMove = this.whiteMove; //for undo
+    this.whiteMove = null;
+    move.smove = smove;
+  }
+
+  undo(move) {
+    this.disaggregateFlags(JSON.parse(move.flags));
+    if (this.turn == 'w')
+      // Back to the middle of the move
+      V.UndoOnBoard(this.board, move.smove);
+    this.turn = V.GetOppCol(this.turn);
+    this.movesCount--;
+    this.postUndo(move);
+  }
+
+  postUndo(move) {
+    if (this.turn == 'w') this.whiteMove = null;
+    else this.whiteMove = move.whiteMove;
+  }
+
+  getCheckSquares(color) {
+    return [];
+  }
+
+  getCurrentScore() {
+    if (this.turn == 'b')
+      // Turn (white + black) not over yet
+      return "*";
+    // Count footmen: if a side has none, it loses
+    let fmCount = { 'w': 0, 'b': 0 };
+    for (let i=0; i<5; i++) {
+      for (let j=0; j<5; j++) {
+        if (this.board[i][j] != V.EMPTY && this.getPiece(i, j) == V.PAWN)
+          fmCount[this.getColor(i, j)]++;
+      }
+    }
+    if (Object.values(fmCount).some(v => v == 0)) {
+      if (fmCount['w'] == 0 && fmCount['b'] == 0)
+        // Everyone died
+        return "1/2";
+      if (fmCount['w'] == 0) return "0-1";
+      return "1-0"; //fmCount['b'] == 0
+    }
+    // Check penaltyFlags: if a side has 2 or more, it loses
+    if (this.penaltyFlags.every(f => f == 2)) return "1/2";
+    if (this.penaltyFlags[0] == 2) return "0-1";
+    if (this.penaltyFlags[1] == 2) return "1-0";
+    if (!this.atLeastOneMove('w') || !this.atLeastOneMove('b'))
+      // Stalemate (should be very rare)
+      return "1/2";
+    return "*";
+  }
+
+  getComputerMove() {
+    const maxeval = V.INFINITY;
+    const color = this.turn;
+    let moves = this.getAllValidMoves();
+    if (moves.length == 0)
+      // TODO: this situation should not happen
+      return null;
+
+    if (Math.random() < 0.5)
+      // Return a random move
+      return moves[randInt(moves.length)];
+
+    // Rank moves at depth 1:
+    // try to capture something (not re-capturing)
+    moves.forEach(m => {
+      V.PlayOnBoard(this.board, m);
+      m.eval = this.evalPosition();
+      V.UndoOnBoard(this.board, m);
+    });
+    moves.sort((a, b) => {
+      return (color == "w" ? 1 : -1) * (b.eval - a.eval);
+    });
+    let candidates = [0];
+    for (let i = 1; i < moves.length && moves[i].eval == moves[0].eval; i++)
+      candidates.push(i);
+    return moves[candidates[randInt(candidates.length)]];
+  }
+
+  getNotation(move) {
+    // Basic system: piece + init + dest square
+    return (
+      move.vanish[0].p.toUpperCase() +
+      V.CoordsToSquare(move.start) +
+      V.CoordsToSquare(move.end)
+    );
+  }
+};
diff --git a/client/src/variants/Synchrone.js b/client/src/variants/Synchrone.js
index cee27c75..55d49dae 100644
--- a/client/src/variants/Synchrone.js
+++ b/client/src/variants/Synchrone.js
@@ -106,12 +106,12 @@ export class SynchroneRules extends ChessRules {
     });
   }
 
-  // NOTE: lazy unefficient implementation (for now. TODO?)
   getPossibleMovesFrom([x, y]) {
-    const moves = this.getAllValidMoves();
-    return moves.filter(m => {
-      return m.start.x == x && m.start.y == y;
-    });
+    return (
+      this.filterValid(super.getPotentialMovesFrom([x, y]))
+      // Augment with potential recaptures:
+      .concat(this.getRecaptures())
+    );
   }
 
   // Aux function used to find opponent and self captures
@@ -191,12 +191,10 @@ export class SynchroneRules extends ChessRules {
     return this.filterValid(moves);
   }
 
-  getAllValidMoves() {
-    const color = this.turn;
-    // 0) Generate our possible moves
-    let myMoves = super.getAllValidMoves();
+  getRecaptures() {
     // 1) Generate all opponent's capturing moves
     let oppCaptureMoves = [];
+    const color = this.turn;
     const oppCol = V.GetOppCol(color);
     for (let i=0; i<8; i++) {
       for (let j=0; j<8; j++) {
@@ -215,6 +213,7 @@ export class SynchroneRules extends ChessRules {
     // 2) Play each opponent's capture, and see if back-captures are possible:
     // Lookup table to quickly decide if a move is already in list:
     let moveSet = {};
+    let moves = [];
     oppCaptureMoves.forEach(m => {
       // If another opponent capture with same endpoint already processed, skip:
       const mHash = "m" + m.end.x + m.end.y;
@@ -227,11 +226,16 @@ export class SynchroneRules extends ChessRules {
         };
         V.PlayOnBoard(this.board, justDisappear);
         // Can I take on [m.end.x, m.end.y] ? If yes, add to list:
-        this.getCaptures(m.end.x, m.end.y, color).forEach(cm => myMoves.push(cm));
+        this.getCaptures(m.end.x, m.end.y, color).forEach(cm => moves.push(cm));
         V.UndoOnBoard(this.board, justDisappear);
       }
     });
-    return myMoves;
+    return moves;
+  }
+
+  getAllValidMoves() {
+    // Return possible moves + potential recaptures
+    return super.getAllValidMoves().concat(this.getRecaptures());
   }
 
   filterValid(moves) {
@@ -426,9 +430,9 @@ export class SynchroneRules extends ChessRules {
       this.undo(lastMove); //will erase whiteMove, thus saved above
     }
     let res = [];
-    if (this.underCheck('w'))
+    if (this.kingPos['w'][0] >= 0 && this.underCheck('w'))
       res.push(JSON.parse(JSON.stringify(this.kingPos['w'])));
-    if (this.underCheck('b'))
+    if (this.kingPos['b'][0] >= 0 && this.underCheck('b'))
       res.push(JSON.parse(JSON.stringify(this.kingPos['b'])));
     if (color == 'b') this.play(lastMove);
     return res;
diff --git a/client/src/views/Game.vue b/client/src/views/Game.vue
index 6d37d1ed..ec4f2c98 100644
--- a/client/src/views/Game.vue
+++ b/client/src/views/Game.vue
@@ -155,7 +155,7 @@ export default {
       gameRef: "",
       nextIds: [],
       game: {}, //passed to BaseGame
-      focus: false,
+      focus: !document.hidden, //will not always work... TODO
       // virtualClocks will be initialized from true game.clocks
       virtualClocks: [],
       vr: null, //"variant rules" object initialized from FEN
-- 
2.44.0