Some further transformation on Dynamo, prepare next variants too main
authorBenjamin Auder <benjamin.auder@somewhere>
Thu, 30 Jan 2025 11:00:00 +0000 (12:00 +0100)
committerBenjamin Auder <benjamin.auder@somewhere>
Thu, 30 Jan 2025 11:00:00 +0000 (12:00 +0100)
14 files changed:
variants/Dynamo/class.js
variants/Eightpieces/class.js [new file with mode: 0644]
variants/Eightpieces/complete_rules.html [new file with mode: 0644]
variants/Eightpieces/rules.html [new file with mode: 0644]
variants/Eightpieces/style.css [new file with mode: 0644]
variants/Emergo/class.js [new file with mode: 0644]
variants/Empire/class.js [new file with mode: 0644]
variants/Enpassant/class.js [new file with mode: 0644]
variants/Evolution/class.js [new file with mode: 0644]
variants/Extinction/class.js [new file with mode: 0644]
variants/Fanorona/class.js [new file with mode: 0644]
variants/Sleepy/class.js [new file with mode: 0644]
variants/Sleepy/rules.html [new file with mode: 0644]
variants/Sleepy/style.css [new file with mode: 0644]

index ed0ea27..7b835e0 100644 (file)
@@ -307,7 +307,7 @@ export default class DynamoRules extends ChessRules {
         const dir = this.getNormalizedDirection(
           [fm.start.x - x, fm.start.y - y]);
         const nbSteps =
-          [V.PAWN, V.KING, V.KNIGHT].includes(piece)
+          ['p', 'k', 'n'].includes(piece)
             ? 1
             : null;
         return this.getMovesInDirection([x, y], dir, nbSteps);
@@ -327,7 +327,7 @@ export default class DynamoRules extends ChessRules {
         const deltaX = Math.abs(fm.start.x - x);
         const deltaY = Math.abs(fm.start.y - y);
         switch (piece) {
-          case V.PAWN:
+          case 'p':
             if (x == pawnStartRank) {
               if (
                 (fm.start.x - x) * pawnShift < 0 ||
@@ -354,7 +354,7 @@ export default class DynamoRules extends ChessRules {
               }
             }
             break;
-          case V.KNIGHT:
+          case 'n':
             if (
               (deltaX + deltaY != 3 || (deltaX == 0 && deltaY == 0)) ||
               (fm.end.x - fm.start.x != fm.start.x - x) ||
@@ -363,7 +363,7 @@ export default class DynamoRules extends ChessRules {
               return [];
             }
             break;
-          case V.KING:
+          case 'k':
             if (
               (deltaX >= 2 || deltaY >= 2) ||
               (fm.end.x - fm.start.x != fm.start.x - x) ||
@@ -372,15 +372,15 @@ export default class DynamoRules extends ChessRules {
               return [];
             }
             break;
-          case V.BISHOP:
+          case 'b':
             if (deltaX != deltaY)
               return [];
             break;
-          case V.ROOK:
+          case 'r':
             if (deltaX != 0 && deltaY != 0)
               return [];
             break;
-          case V.QUEEN:
+          case 'q':
             if (deltaX != deltaY && deltaX != 0 && deltaY != 0)
               return [];
             break;
@@ -404,7 +404,7 @@ export default class DynamoRules extends ChessRules {
       // Note: kings cannot suicide, so fm.vanish[0].p is not KING.
       // Could be PAWN though, if a pawn was pushed out of board.
       if (
-        fm.vanish[0].p != V.PAWN && //pawns cannot pull
+        fm.vanish[0].p != 'p' && //pawns cannot pull
         this.isAprioriValidExit(
           [x, y],
           [fm.start.x, fm.start.y],
@@ -415,13 +415,13 @@ export default class DynamoRules extends ChessRules {
         // Seems so:
         const dir = this.getNormalizedDirection(
           [fm.start.x - x, fm.start.y - y]);
-        const nbSteps = (fm.vanish[0].p == V.KNIGHT ? 1 : null);
+        const nbSteps = (fm.vanish[0].p == 'n' ? 1 : null);
         return this.getMovesInDirection([x, y], dir, nbSteps);
       }
       return [];
     };
     const getPullMoves = () => {
-      if (fm.vanish[0].p == V.PAWN)
+      if (fm.vanish[0].p == 'p')
         // pawns cannot pull
         return [];
       const dirM = this.getNormalizedDirection(
@@ -434,8 +434,8 @@ export default class DynamoRules extends ChessRules {
         const deltaX = Math.abs(x - fm.start.x);
         const deltaY = Math.abs(y - fm.start.y);
         if (
-          (fm.vanish[0].p == V.KING && (deltaX > 1 || deltaY > 1)) ||
-          (fm.vanish[0].p == V.KNIGHT &&
+          (fm.vanish[0].p == 'k' && (deltaX > 1 || deltaY > 1)) ||
+          (fm.vanish[0].p == 'n' &&
             (deltaX + deltaY != 3 || deltaX == 0 || deltaY == 0))
         ) {
           return [];
@@ -444,7 +444,7 @@ export default class DynamoRules extends ChessRules {
         let [i, j] = [x + dir[0], y + dir[1]];
         while (
           (i != fm.start.x || j != fm.start.y) &&
-          this.board[i][j] == V.EMPTY
+          this.board[i][j] == ""
         ) {
           i += dir[0];
           j += dir[1];
@@ -493,20 +493,20 @@ export default class DynamoRules extends ChessRules {
     outerLoop: for (let step of steps) {
       let i = x + step[0];
       let j = y + step[1];
-      while (V.OnBoard(i, j) && this.board[i][j] == V.EMPTY) {
+      while (this.onBoard(i, j) && this.board[i][j] == "") {
         moves.push(this.getBasicMove([x, y], [i, j]));
         if (oneStep)
           continue outerLoop;
         i += step[0];
         j += step[1];
       }
-      if (V.OnBoard(i, j)) {
+      if (this.onBoard(i, j)) {
         if (this.canTake([x, y], [i, j]))
           moves.push(this.getBasicMove([x, y], [i, j]));
       }
       else {
         // Add potential board exit (suicide), except for the king
-        if (piece != V.KING) {
+        if (piece != 'k') {
           moves.push({
             start: { x: x, y: y},
             end: { x: this.kingPos[c][0], y: this.kingPos[c][1] },
@@ -563,7 +563,8 @@ export default class DynamoRules extends ChessRules {
     }
   }
 
-  // TODO ::
+// TODO: re-write just for here getAllPotentialMoves() ?
+
   filterValid(moves) {
     const color = this.turn;
     const La = this.amoves.length;
@@ -572,7 +573,7 @@ export default class DynamoRules extends ChessRules {
         // A move is valid either if it doesn't result in a check,
         // or if a second move is possible to counter the check
         // (not undoing a potential move + action of the opponent)
-        this.play(m);
+        this.playOnBoard(m);
         let res = this.underCheck(color);
         if (this.subTurn == 2) {
           let isOpposite = La > 0 && this.oppositeMoves(this.amoves[La-1], m);
@@ -580,7 +581,7 @@ export default class DynamoRules extends ChessRules {
             const moves2 = this.getAllPotentialMoves();
             for (let m2 of moves2) {
               this.play(m2);
-              const res2 = this.underCheck(color);
+              const res2 = this.underCheck(color); //TODO: + square
               const amove = this.getAmove(m, m2);
               isOpposite =
                 La > 0 && this.oppositeMoves(this.amoves[La-1], amove);
@@ -592,7 +593,7 @@ export default class DynamoRules extends ChessRules {
             }
           }
         }
-        this.undo(m);
+        this.undoOnBoard(m);
         return !res;
       });
     }
diff --git a/variants/Eightpieces/class.js b/variants/Eightpieces/class.js
new file mode 100644 (file)
index 0000000..3808a16
--- /dev/null
@@ -0,0 +1,1136 @@
+import { randInt, sample } from "@/utils/alea";
+import { ChessRules, PiPo, Move } from "@/base_rules";
+
+export class EightpiecesRules extends ChessRules {
+
+  static get JAILER() {
+    return "j";
+  }
+  static get SENTRY() {
+    return "s";
+  }
+  static get LANCER() {
+    return "l";
+  }
+
+  static get IMAGE_EXTENSION() {
+    // Temporarily, for the time SVG pieces are being designed:
+    return ".png";
+  }
+
+  // Lancer directions *from white perspective*
+  static get LANCER_DIRS() {
+    return {
+      'c': [-1, 0], //north
+      'd': [-1, 1], //N-E
+      'e': [0, 1], //east
+      'f': [1, 1], //S-E
+      'g': [1, 0], //south
+      'h': [1, -1], //S-W
+      'm': [0, -1], //west
+      'o': [-1, -1] //N-W
+    };
+  }
+
+  static get PIECES() {
+    return ChessRules.PIECES
+      .concat([V.JAILER, V.SENTRY])
+      .concat(Object.keys(V.LANCER_DIRS));
+  }
+
+  getPiece(i, j) {
+    const piece = this.board[i][j].charAt(1);
+    // Special lancer case: 8 possible orientations
+    if (Object.keys(V.LANCER_DIRS).includes(piece)) return V.LANCER;
+    return piece;
+  }
+
+  getPpath(b, color, score, orientation) {
+    if ([V.JAILER, V.SENTRY].includes(b[1])) return "Eightpieces/tmp_png/" + b;
+    if (Object.keys(V.LANCER_DIRS).includes(b[1])) {
+      if (orientation == 'w') return "Eightpieces/tmp_png/" + b;
+      // Find opposite direction for adequate display:
+      let oppDir = '';
+      switch (b[1]) {
+        case 'c':
+          oppDir = 'g';
+          break;
+        case 'g':
+          oppDir = 'c';
+          break;
+        case 'd':
+          oppDir = 'h';
+          break;
+        case 'h':
+          oppDir = 'd';
+          break;
+        case 'e':
+          oppDir = 'm';
+          break;
+        case 'm':
+          oppDir = 'e';
+          break;
+        case 'f':
+          oppDir = 'o';
+          break;
+        case 'o':
+          oppDir = 'f';
+          break;
+      }
+      return "Eightpieces/tmp_png/" + b[0] + oppDir;
+    }
+    // TODO: after we have SVG pieces, remove the folder and next prefix:
+    return "Eightpieces/tmp_png/" + b;
+  }
+
+  getPPpath(m, orientation) {
+    return (
+      this.getPpath(
+        m.appear[0].c + m.appear[0].p,
+        null,
+        null,
+        orientation
+      )
+    );
+  }
+
+  static ParseFen(fen) {
+    const fenParts = fen.split(" ");
+    return Object.assign(
+      ChessRules.ParseFen(fen),
+      { sentrypush: fenParts[5] }
+    );
+  }
+
+  static IsGoodFen(fen) {
+    if (!ChessRules.IsGoodFen(fen)) return false;
+    const fenParsed = V.ParseFen(fen);
+    // 5) Check sentry push (if any)
+    if (
+      fenParsed.sentrypush != "-" &&
+      !fenParsed.sentrypush.match(/^([a-h][1-8]){2,2}$/)
+    ) {
+      return false;
+    }
+    return true;
+  }
+
+  getFen() {
+    return super.getFen() + " " + this.getSentrypushFen();
+  }
+
+  getFenForRepeat() {
+    return super.getFenForRepeat() + "_" + this.getSentrypushFen();
+  }
+
+  getSentrypushFen() {
+    const L = this.sentryPush.length;
+    if (!this.sentryPush[L-1]) return "-";
+    let res = "";
+    const spL = this.sentryPush[L-1].length;
+    // Condensate path: just need initial and final squares:
+    return [0, spL - 1]
+      .map(i => V.CoordsToSquare(this.sentryPush[L-1][i]))
+      .join("");
+  }
+
+  setOtherVariables(fen) {
+    super.setOtherVariables(fen);
+    // subTurn == 2 only when a sentry moved, and is about to push something
+    this.subTurn = 1;
+    // Sentry position just after a "capture" (subTurn from 1 to 2)
+    this.sentryPos = null;
+    // Stack pieces' forbidden squares after a sentry move at each turn
+    const parsedFen = V.ParseFen(fen);
+    if (parsedFen.sentrypush == "-") this.sentryPush = [null];
+    else {
+      // Expand init + dest squares into a full path:
+      const init = V.SquareToCoords(parsedFen.sentrypush.substr(0, 2)),
+            dest = V.SquareToCoords(parsedFen.sentrypush.substr(2));
+      let newPath = [init];
+      const delta = ['x', 'y'].map(i => Math.abs(dest[i] - init[i]));
+      // Check that it's not a knight movement:
+      if (delta[0] == 0 || delta[1] == 0 || delta[0] == delta[1]) {
+        const step = ['x', 'y'].map((i, idx) => {
+          return (dest[i] - init[i]) / delta[idx] || 0
+        });
+        let x = init.x + step[0],
+            y = init.y + step[1];
+        while (x != dest.x || y != dest.y) {
+          newPath.push({ x: x, y: y });
+          x += step[0];
+          y += step[1];
+        }
+      }
+      newPath.push(dest);
+      this.sentryPush = [newPath];
+    }
+  }
+
+  static GenRandInitFen(options) {
+    if (options.randomness == 0)
+      return "jfsqkbnr/pppppppp/8/8/8/8/PPPPPPPP/JDSQKBNR w 0 ahah - -";
+
+    const baseFen = ChessRules.GenRandInitFen(options);
+    const fenParts = baseFen.split(' ');
+    const posParts = fenParts[0].split('/');
+
+    // Replace one bishop by sentry, so that sentries on different colors
+    // Also replace one random rook by jailer,
+    // and one random knight by lancer (facing north/south)
+    let pieceLine = { b: posParts[0], w: posParts[7].toLowerCase() };
+    let posBlack = { r: -1, n: -1, b: -1 };
+    const mapP = { r: 'j', n: 'l', b: 's' };
+    ['b', 'w'].forEach(c => {
+      ['r', 'n', 'b'].forEach(p => {
+        let pl = pieceLine[c];
+        let pos = -1;
+        if (options.randomness == 2 || c == 'b')
+          pos = (randInt(2) == 0 ? pl.indexOf(p) : pl.lastIndexOf(p));
+        else pos = posBlack[p];
+        pieceLine[c] =
+          pieceLine[c].substr(0, pos) + mapP[p] + pieceLine[c].substr(pos+1);
+        if (options.randomness == 1 && c == 'b') posBlack[p] = pos;
+      });
+    });
+    // Rename 'l' into 'g' (black) or 'c' (white)
+    pieceLine['w'] = pieceLine['w'].replace('l', 'c');
+    pieceLine['b'] = pieceLine['b'].replace('l', 'g');
+    if (options.randomness == 2) {
+      const ws = pieceLine['w'].indexOf('s');
+      const bs = pieceLine['b'].indexOf('s');
+      if (ws % 2 != bs % 2) {
+        // Fix sentry: should be on different colors.
+        // => move sentry on other bishop for random color
+        const c = sample(['w', 'b'], 1);
+        pieceLine[c] = pieceLine[c]
+                       .replace('b', 't') //tmp
+                       .replace('s', 'b')
+                       .replace('t', 's');
+      }
+    }
+
+    return (
+      pieceLine['b'] + "/" +
+      posParts.slice(1, 7).join('/') + "/" +
+      pieceLine['w'].toUpperCase() + " " +
+      fenParts.slice(1, 5).join(' ') + " -"
+    );
+  }
+
+  canTake([x1, y1], [x2, y2]) {
+    if (this.subTurn == 2)
+      // Only self captures on this subturn:
+      return this.getColor(x1, y1) == this.getColor(x2, y2);
+    return super.canTake([x1, y1], [x2, y2]);
+  }
+
+  // Is piece on square (x,y) immobilized?
+  isImmobilized([x, y]) {
+    const color = this.getColor(x, y);
+    const oppCol = V.GetOppCol(color);
+    for (let step of V.steps[V.ROOK]) {
+      const [i, j] = [x + step[0], y + step[1]];
+      if (
+        V.OnBoard(i, j) &&
+        this.board[i][j] != V.EMPTY &&
+        this.getColor(i, j) == oppCol
+      ) {
+        if (this.getPiece(i, j) == V.JAILER) return [i, j];
+      }
+    }
+    return null;
+  }
+
+  canIplay(side, [x, y]) {
+    return (
+      (this.subTurn == 1 && this.turn == side && this.getColor(x, y) == side)
+      ||
+      (this.subTurn == 2 && x == this.sentryPos.x && y == this.sentryPos.y)
+    );
+  }
+
+  getPotentialMovesFrom([x, y]) {
+    const piece = this.getPiece(x, y);
+    const L = this.sentryPush.length;
+    // At subTurn == 2, jailers aren't effective (Jeff K)
+    if (this.subTurn == 1) {
+      const jsq = this.isImmobilized([x, y]);
+      if (!!jsq) {
+        let moves = [];
+        // Special pass move if king:
+        if (piece == V.KING) {
+          moves.push(
+            new Move({
+              appear: [],
+              vanish: [],
+              start: { x: x, y: y },
+              end: { x: jsq[0], y: jsq[1] }
+            })
+          );
+        }
+        else if (piece == V.LANCER && !!this.sentryPush[L-1]) {
+          // A pushed lancer next to the jailer: reorient
+          const color = this.getColor(x, y);
+          const curDir = this.board[x][y].charAt(1);
+          Object.keys(V.LANCER_DIRS).forEach(k => {
+            moves.push(
+              new Move({
+                appear: [{ x: x, y: y, c: color, p: k }],
+                vanish: [{ x: x, y: y, c: color, p: curDir }],
+                start: { x: x, y: y },
+                end: { x: jsq[0], y: jsq[1] }
+              })
+            );
+          });
+        }
+        return moves;
+      }
+    }
+    let moves = [];
+    switch (piece) {
+      case V.JAILER:
+        moves = this.getPotentialJailerMoves([x, y]);
+        break;
+      case V.SENTRY:
+        moves = this.getPotentialSentryMoves([x, y]);
+        break;
+      case V.LANCER:
+        moves = this.getPotentialLancerMoves([x, y]);
+        break;
+      default:
+        moves = super.getPotentialMovesFrom([x, y]);
+        break;
+    }
+    if (!!this.sentryPush[L-1]) {
+      // Delete moves walking back on sentry push path,
+      // only if not a pawn, and the piece is the pushed one.
+      const pl = this.sentryPush[L-1].length;
+      const finalPushedSq = this.sentryPush[L-1][pl-1];
+      moves = moves.filter(m => {
+        if (
+          m.vanish[0].p != V.PAWN &&
+          m.start.x == finalPushedSq.x && m.start.y == finalPushedSq.y &&
+          this.sentryPush[L-1].some(sq => sq.x == m.end.x && sq.y == m.end.y)
+        ) {
+          return false;
+        }
+        return true;
+      });
+    }
+    else if (this.subTurn == 2) {
+      // Put back the sentinel on board:
+      const color = this.turn;
+      moves.forEach(m => {
+        m.appear.push({x: x, y: y, p: V.SENTRY, c: color});
+      });
+    }
+    return moves;
+  }
+
+  getPotentialPawnMoves([x, y]) {
+    const color = this.getColor(x, y);
+    let moves = [];
+    const [sizeX, sizeY] = [V.size.x, V.size.y];
+    let shiftX = (color == "w" ? -1 : 1);
+    if (this.subTurn == 2) shiftX *= -1;
+    const firstRank = color == "w" ? sizeX - 1 : 0;
+    const startRank = color == "w" ? sizeX - 2 : 1;
+    const lastRank = color == "w" ? 0 : sizeX - 1;
+
+    // Pawns might be pushed on 1st rank and attempt to move again:
+    if (!V.OnBoard(x + shiftX, y)) return [];
+
+    // A push cannot put a pawn on last rank (it goes backward)
+    let finalPieces = [V.PAWN];
+    if (x + shiftX == lastRank) {
+      // Only allow direction facing inside board:
+      const allowedLancerDirs =
+        lastRank == 0
+          ? ['e', 'f', 'g', 'h', 'm']
+          : ['c', 'd', 'e', 'm', 'o'];
+      finalPieces =
+        allowedLancerDirs
+        .concat([V.ROOK, V.KNIGHT, V.BISHOP, V.QUEEN, V.SENTRY, V.JAILER]);
+    }
+    if (this.board[x + shiftX][y] == V.EMPTY) {
+      // One square forward
+      for (let piece of finalPieces) {
+        moves.push(
+          this.getBasicMove([x, y], [x + shiftX, y], {
+            c: color,
+            p: piece
+          })
+        );
+      }
+      if (
+        // 2-squares jumps forbidden if pawn push
+        this.subTurn == 1 &&
+        [startRank, firstRank].includes(x) &&
+        this.board[x + 2 * shiftX][y] == V.EMPTY
+      ) {
+        // Two squares jump
+        moves.push(this.getBasicMove([x, y], [x + 2 * shiftX, y]));
+      }
+    }
+    // Captures
+    for (let shiftY of [-1, 1]) {
+      if (
+        y + shiftY >= 0 &&
+        y + shiftY < sizeY &&
+        this.board[x + shiftX][y + shiftY] != V.EMPTY &&
+        this.canTake([x, y], [x + shiftX, y + shiftY])
+      ) {
+        for (let piece of finalPieces) {
+          moves.push(
+            this.getBasicMove([x, y], [x + shiftX, y + shiftY], {
+              c: color,
+              p: piece
+            })
+          );
+        }
+      }
+    }
+
+    // En passant: only on subTurn == 1
+    const Lep = this.epSquares.length;
+    const epSquare = this.epSquares[Lep - 1];
+    if (
+      this.subTurn == 1 &&
+      !!epSquare &&
+      epSquare.x == x + shiftX &&
+      Math.abs(epSquare.y - y) == 1
+    ) {
+      let enpassantMove = this.getBasicMove([x, y], [epSquare.x, epSquare.y]);
+      enpassantMove.vanish.push({
+        x: x,
+        y: epSquare.y,
+        p: "p",
+        c: this.getColor(x, epSquare.y)
+      });
+      moves.push(enpassantMove);
+    }
+
+    return moves;
+  }
+
+  doClick(square) {
+    if (isNaN(square[0])) return null;
+    const L = this.sentryPush.length;
+    const [x, y] = [square[0], square[1]];
+    const color = this.turn;
+    if (
+      this.subTurn == 2 ||
+      this.board[x][y] == V.EMPTY ||
+      this.getPiece(x, y) != V.LANCER ||
+      this.getColor(x, y) != color ||
+      !!this.sentryPush[L-1]
+    ) {
+      return null;
+    }
+    // Stuck lancer?
+    const orientation = this.board[x][y][1];
+    const step = V.LANCER_DIRS[orientation];
+    if (!V.OnBoard(x + step[0], y + step[1])) {
+      let choices = [];
+      Object.keys(V.LANCER_DIRS).forEach(k => {
+        const dir = V.LANCER_DIRS[k];
+        if (
+          (dir[0] != step[0] || dir[1] != step[1]) &&
+          V.OnBoard(x + dir[0], y + dir[1])
+        ) {
+          choices.push(
+            new Move({
+              vanish: [
+                new PiPo({
+                  x: x,
+                  y: y,
+                  c: color,
+                  p: orientation
+                })
+              ],
+              appear: [
+                new PiPo({
+                  x: x,
+                  y: y,
+                  c: color,
+                  p: k
+                })
+              ],
+              start: { x: x, y : y },
+              end: { x: -1, y: -1 }
+            })
+          );
+        }
+      });
+      return choices;
+    }
+    return null;
+  }
+
+  // Obtain all lancer moves in "step" direction
+  getPotentialLancerMoves_aux([x, y], step, tr) {
+    let moves = [];
+    // Add all moves to vacant squares until opponent is met:
+    const color = this.getColor(x, y);
+    const oppCol =
+      this.subTurn == 1
+        ? V.GetOppCol(color)
+        // at subTurn == 2, consider own pieces as opponent
+        : color;
+    let sq = [x + step[0], y + step[1]];
+    while (V.OnBoard(sq[0], sq[1]) && this.getColor(sq[0], sq[1]) != oppCol) {
+      if (this.board[sq[0]][sq[1]] == V.EMPTY)
+        moves.push(this.getBasicMove([x, y], sq, tr));
+      sq[0] += step[0];
+      sq[1] += step[1];
+    }
+    if (V.OnBoard(sq[0], sq[1]))
+      // Add capturing move
+      moves.push(this.getBasicMove([x, y], sq, tr));
+    return moves;
+  }
+
+  getPotentialLancerMoves([x, y]) {
+    let moves = [];
+    // Add all lancer possible orientations, similar to pawn promotions.
+    // Except if just after a push: allow all movements from init square then
+    const L = this.sentryPush.length;
+    const color = this.getColor(x, y);
+    const dirCode = this.board[x][y][1];
+    const curDir = V.LANCER_DIRS[dirCode];
+    if (!!this.sentryPush[L-1]) {
+      // Maybe I was pushed
+      const pl = this.sentryPush[L-1].length;
+      if (
+        this.sentryPush[L-1][pl-1].x == x &&
+        this.sentryPush[L-1][pl-1].y == y
+      ) {
+        // I was pushed: allow all directions (for this move only), but
+        // do not change direction after moving, *except* if I keep the
+        // same orientation in which I was pushed.
+        // Also allow simple reorientation ("capturing king"):
+        if (!V.OnBoard(x + curDir[0], y + curDir[1])) {
+          const kp = this.kingPos[color];
+          let reorientMoves = [];
+          Object.keys(V.LANCER_DIRS).forEach(k => {
+            const dir = V.LANCER_DIRS[k];
+            if (
+              (dir[0] != curDir[0] || dir[1] != curDir[1]) &&
+              V.OnBoard(x + dir[0], y + dir[1])
+            ) {
+              reorientMoves.push(
+                new Move({
+                  vanish: [
+                    new PiPo({
+                      x: x,
+                      y: y,
+                      c: color,
+                      p: dirCode
+                    })
+                  ],
+                  appear: [
+                    new PiPo({
+                      x: x,
+                      y: y,
+                      c: color,
+                      p: k
+                    })
+                  ],
+                  start: { x: x, y : y },
+                  end: { x: kp[0], y: kp[1] }
+                })
+              );
+            }
+          });
+          Array.prototype.push.apply(moves, reorientMoves);
+        }
+        Object.values(V.LANCER_DIRS).forEach(step => {
+          const dirCode = Object.keys(V.LANCER_DIRS).find(k => {
+            return (
+              V.LANCER_DIRS[k][0] == step[0] &&
+              V.LANCER_DIRS[k][1] == step[1]
+            );
+          });
+          const dirMoves =
+            this.getPotentialLancerMoves_aux(
+              [x, y],
+              step,
+              { p: dirCode, c: color }
+            );
+          if (curDir[0] == step[0] && curDir[1] == step[1]) {
+            // Keeping same orientation: can choose after
+            let chooseMoves = [];
+            dirMoves.forEach(m => {
+              Object.keys(V.LANCER_DIRS).forEach(k => {
+                const newDir = V.LANCER_DIRS[k];
+                // Prevent orientations toward outer board:
+                if (V.OnBoard(m.end.x + newDir[0], m.end.y + newDir[1])) {
+                  let mk = JSON.parse(JSON.stringify(m));
+                  mk.appear[0].p = k;
+                  chooseMoves.push(mk);
+                }
+              });
+            });
+            Array.prototype.push.apply(moves, chooseMoves);
+          }
+          else Array.prototype.push.apply(moves, dirMoves);
+        });
+        return moves;
+      }
+    }
+    // I wasn't pushed: standard lancer move
+    const monodirMoves =
+      this.getPotentialLancerMoves_aux([x, y], V.LANCER_DIRS[dirCode]);
+    // Add all possible orientations aftermove except if I'm being pushed
+    if (this.subTurn == 1) {
+      monodirMoves.forEach(m => {
+        Object.keys(V.LANCER_DIRS).forEach(k => {
+          const newDir = V.LANCER_DIRS[k];
+          // Prevent orientations toward outer board:
+          if (V.OnBoard(m.end.x + newDir[0], m.end.y + newDir[1])) {
+            let mk = JSON.parse(JSON.stringify(m));
+            mk.appear[0].p = k;
+            moves.push(mk);
+          }
+        });
+      });
+      return moves;
+    }
+    else {
+      // I'm pushed: add potential nudges, except for current orientation
+      let potentialNudges = [];
+      for (let step of V.steps[V.ROOK].concat(V.steps[V.BISHOP])) {
+        if (
+          (step[0] != curDir[0] || step[1] != curDir[1]) &&
+          V.OnBoard(x + step[0], y + step[1]) &&
+          this.board[x + step[0]][y + step[1]] == V.EMPTY
+        ) {
+          const newDirCode = Object.keys(V.LANCER_DIRS).find(k => {
+            const codeStep = V.LANCER_DIRS[k];
+            return (codeStep[0] == step[0] && codeStep[1] == step[1]);
+          });
+          potentialNudges.push(
+            this.getBasicMove(
+              [x, y],
+              [x + step[0], y + step[1]],
+              { c: color, p: newDirCode }
+            )
+          );
+        }
+      }
+      return monodirMoves.concat(potentialNudges);
+    }
+  }
+
+  getPotentialSentryMoves([x, y]) {
+    // The sentry moves a priori like a bishop:
+    let moves = super.getPotentialBishopMoves([x, y]);
+    // ...but captures are replaced by special move, if and only if
+    // "captured" piece can move now, considered as the capturer unit.
+    // --> except is subTurn == 2, in this case I don't push anything.
+    if (this.subTurn == 2) return moves.filter(m => m.vanish.length == 1);
+    moves.forEach(m => {
+      if (m.vanish.length == 2) {
+        // Temporarily cancel the sentry capture:
+        m.appear.pop();
+        m.vanish.pop();
+      }
+    });
+    const color = this.getColor(x, y);
+    const fMoves = moves.filter(m => {
+      // Can the pushed unit make any move? ...resulting in a non-self-check?
+      if (m.appear.length == 0) {
+        let res = false;
+        this.play(m);
+        let moves2 = this.getPotentialMovesFrom([m.end.x, m.end.y]);
+        for (let m2 of moves2) {
+          this.play(m2);
+          res = !this.underCheck(color);
+          this.undo(m2);
+          if (res) break;
+        }
+        this.undo(m);
+        return res;
+      }
+      return true;
+    });
+    return fMoves;
+  }
+
+  getPotentialJailerMoves([x, y]) {
+    return super.getPotentialRookMoves([x, y]).filter(m => {
+      // Remove jailer captures
+      return m.vanish[0].p != V.JAILER || m.vanish.length == 1;
+    });
+  }
+
+  getPotentialKingMoves(sq) {
+    const moves = this.getSlideNJumpMoves(
+      sq, V.steps[V.ROOK].concat(V.steps[V.BISHOP]), 1);
+    return (
+      this.subTurn == 1
+        ? moves.concat(this.getCastleMoves(sq))
+        : moves
+    );
+  }
+
+  atLeastOneMove() {
+    // If in second-half of a move, we already know that a move is possible
+    if (this.subTurn == 2) return true;
+    return super.atLeastOneMove();
+  }
+
+  filterValid(moves) {
+    if (moves.length == 0) return [];
+    const basicFilter = (m, c) => {
+      this.play(m);
+      const res = !this.underCheck(c);
+      this.undo(m);
+      return res;
+    };
+    // Disable check tests for sentry pushes,
+    // because in this case the move isn't finished
+    let movesWithoutSentryPushes = [];
+    let movesWithSentryPushes = [];
+    moves.forEach(m => {
+      // Second condition below for special king "pass" moves
+      if (m.appear.length > 0 || m.vanish.length == 0)
+        movesWithoutSentryPushes.push(m);
+      else movesWithSentryPushes.push(m);
+    });
+    const color = this.turn;
+    const oppCol = V.GetOppCol(color);
+    const filteredMoves =
+      movesWithoutSentryPushes.filter(m => basicFilter(m, color));
+    // If at least one full move made, everything is allowed.
+    // Else: forbid checks and captures.
+    return (
+      this.movesCount >= 2
+        ? filteredMoves
+        : filteredMoves.filter(m => {
+          return (m.vanish.length <= 1 && basicFilter(m, oppCol));
+        })
+    ).concat(movesWithSentryPushes);
+  }
+
+  getAllValidMoves() {
+    if (this.subTurn == 1) return super.getAllValidMoves();
+    // Sentry push:
+    const sentrySq = [this.sentryPos.x, this.sentryPos.y];
+    return this.filterValid(this.getPotentialMovesFrom(sentrySq));
+  }
+
+  isAttacked(sq, color) {
+    return (
+      super.isAttacked(sq, color) ||
+      this.isAttackedByLancer(sq, color) ||
+      this.isAttackedBySentry(sq, color)
+      // The jailer doesn't capture.
+    );
+  }
+
+  isAttackedBySlideNJump([x, y], color, piece, steps, oneStep) {
+    for (let step of steps) {
+      let rx = x + step[0],
+          ry = y + step[1];
+      while (V.OnBoard(rx, ry) && this.board[rx][ry] == V.EMPTY && !oneStep) {
+        rx += step[0];
+        ry += step[1];
+      }
+      if (
+        V.OnBoard(rx, ry) &&
+        this.getPiece(rx, ry) == piece &&
+        this.getColor(rx, ry) == color &&
+        !this.isImmobilized([rx, ry])
+      ) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  isAttackedByPawn([x, y], color) {
+    const pawnShift = (color == "w" ? 1 : -1);
+    if (x + pawnShift >= 0 && x + pawnShift < V.size.x) {
+      for (let i of [-1, 1]) {
+        if (
+          y + i >= 0 &&
+          y + i < V.size.y &&
+          this.getPiece(x + pawnShift, y + i) == V.PAWN &&
+          this.getColor(x + pawnShift, y + i) == color &&
+          !this.isImmobilized([x + pawnShift, y + i])
+        ) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  isAttackedByLancer([x, y], color) {
+    for (let step of V.steps[V.ROOK].concat(V.steps[V.BISHOP])) {
+      // If in this direction there are only enemy pieces and empty squares,
+      // and we meet a lancer: can he reach us?
+      // NOTE: do not stop at first lancer, there might be several!
+      let coord = { x: x + step[0], y: y + step[1] };
+      let lancerPos = [];
+      while (
+        V.OnBoard(coord.x, coord.y) &&
+        (
+          this.board[coord.x][coord.y] == V.EMPTY ||
+          this.getColor(coord.x, coord.y) == color
+        )
+      ) {
+        if (
+          this.getPiece(coord.x, coord.y) == V.LANCER &&
+          !this.isImmobilized([coord.x, coord.y])
+        ) {
+          lancerPos.push({x: coord.x, y: coord.y});
+        }
+        coord.x += step[0];
+        coord.y += step[1];
+      }
+      const L = this.sentryPush.length;
+      const pl = (!!this.sentryPush[L-1] ? this.sentryPush[L-1].length : 0);
+      for (let xy of lancerPos) {
+        const dir = V.LANCER_DIRS[this.board[xy.x][xy.y].charAt(1)];
+        if (
+          (dir[0] == -step[0] && dir[1] == -step[1]) ||
+          // If the lancer was just pushed, this is an attack too:
+          (
+            !!this.sentryPush[L-1] &&
+            this.sentryPush[L-1][pl-1].x == xy.x &&
+            this.sentryPush[L-1][pl-1].y == xy.y
+          )
+        ) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  // Helper to check sentries attacks:
+  selfAttack([x1, y1], [x2, y2]) {
+    const color = this.getColor(x1, y1);
+    const oppCol = V.GetOppCol(color);
+    const sliderAttack = (allowedSteps, lancer) => {
+      const deltaX = x2 - x1,
+            deltaY = y2 - y1;
+      const absDeltaX = Math.abs(deltaX),
+            absDeltaY = Math.abs(deltaY);
+      const step = [ deltaX / absDeltaX || 0, deltaY / absDeltaY || 0 ];
+      if (
+        // Check that the step is a priori valid:
+        (absDeltaX != absDeltaY && deltaX != 0 && deltaY != 0) ||
+        allowedSteps.every(st => st[0] != step[0] || st[1] != step[1])
+      ) {
+        return false;
+      }
+      let sq = [ x1 + step[0], y1 + step[1] ];
+      while (sq[0] != x2 || sq[1] != y2) {
+        // NOTE: no need to check OnBoard in this special case
+        if (this.board[sq[0]][sq[1]] != V.EMPTY) {
+          const p = this.getPiece(sq[0], sq[1]);
+          const pc = this.getColor(sq[0], sq[1]);
+          if (
+            // Enemy sentry on the way will be gone:
+            (p != V.SENTRY || pc != oppCol) &&
+            // Lancer temporarily "changed color":
+            (!lancer || pc == color)
+          ) {
+            return false;
+          }
+        }
+        sq[0] += step[0];
+        sq[1] += step[1];
+      }
+      return true;
+    };
+    switch (this.getPiece(x1, y1)) {
+      case V.PAWN: {
+        // Pushed pawns move as enemy pawns
+        const shift = (color == 'w' ? 1 : -1);
+        return (x1 + shift == x2 && Math.abs(y1 - y2) == 1);
+      }
+      case V.KNIGHT: {
+        const deltaX = Math.abs(x1 - x2);
+        const deltaY = Math.abs(y1 - y2);
+        return (
+          deltaX + deltaY == 3 &&
+          [1, 2].includes(deltaX) &&
+          [1, 2].includes(deltaY)
+        );
+      }
+      case V.ROOK:
+        return sliderAttack(V.steps[V.ROOK]);
+      case V.BISHOP:
+        return sliderAttack(V.steps[V.BISHOP]);
+      case V.QUEEN:
+        return sliderAttack(V.steps[V.ROOK].concat(V.steps[V.BISHOP]));
+      case V.LANCER: {
+        // Special case: as long as no enemy units stands in-between,
+        // it attacks (if it points toward the king).
+        const allowedStep = V.LANCER_DIRS[this.board[x1][y1].charAt(1)];
+        return sliderAttack([allowedStep], "lancer");
+      }
+      // No sentries or jailer tests: they cannot self-capture
+    }
+    return false;
+  }
+
+  isAttackedBySentry([x, y], color) {
+    // Attacked by sentry means it can self-take our king.
+    // Just check diagonals of enemy sentry(ies), and if it reaches
+    // one of our pieces: can I self-take?
+    const myColor = V.GetOppCol(color);
+    let candidates = [];
+    for (let i=0; i<V.size.x; i++) {
+      for (let j=0; j<V.size.y; j++) {
+        if (
+          this.getPiece(i,j) == V.SENTRY &&
+          this.getColor(i,j) == color &&
+          !this.isImmobilized([i, j])
+        ) {
+          for (let step of V.steps[V.BISHOP]) {
+            let sq = [ i + step[0], j + step[1] ];
+            while (
+              V.OnBoard(sq[0], sq[1]) &&
+              this.board[sq[0]][sq[1]] == V.EMPTY
+            ) {
+              sq[0] += step[0];
+              sq[1] += step[1];
+            }
+            if (
+              V.OnBoard(sq[0], sq[1]) &&
+              this.getColor(sq[0], sq[1]) == myColor
+            ) {
+              candidates.push([ sq[0], sq[1] ]);
+            }
+          }
+        }
+      }
+    }
+    for (let c of candidates)
+      if (this.selfAttack(c, [x, y])) return true;
+    return false;
+  }
+
+  // Jailer doesn't capture or give check
+
+  prePlay(move) {
+    if (move.appear.length == 0 && move.vanish.length == 1)
+      // The sentry is about to push a piece: subTurn goes from 1 to 2
+      this.sentryPos = { x: move.end.x, y: move.end.y };
+    if (this.subTurn == 2 && move.vanish[0].p != V.PAWN) {
+      // A piece is pushed: forbid array of squares between start and end
+      // of move, included (except if it's a pawn)
+      let squares = [];
+      if ([V.KNIGHT,V.KING].includes(move.vanish[0].p))
+        // short-range pieces: just forbid initial square
+        squares.push({ x: move.start.x, y: move.start.y });
+      else {
+        const deltaX = move.end.x - move.start.x;
+        const deltaY = move.end.y - move.start.y;
+        const step = [
+          deltaX / Math.abs(deltaX) || 0,
+          deltaY / Math.abs(deltaY) || 0
+        ];
+        for (
+          let sq = {x: move.start.x, y: move.start.y};
+          sq.x != move.end.x || sq.y != move.end.y;
+          sq.x += step[0], sq.y += step[1]
+        ) {
+          squares.push({ x: sq.x, y: sq.y });
+        }
+      }
+      // Add end square as well, to know if I was pushed (useful for lancers)
+      squares.push({ x: move.end.x, y: move.end.y });
+      this.sentryPush.push(squares);
+    } else this.sentryPush.push(null);
+  }
+
+  play(move) {
+    this.prePlay(move);
+    move.flags = JSON.stringify(this.aggregateFlags());
+    this.epSquares.push(this.getEpSquare(move));
+    V.PlayOnBoard(this.board, move);
+    // Is it a sentry push? (useful for undo)
+    move.sentryPush = (this.subTurn == 2);
+    if (this.subTurn == 1) this.movesCount++;
+    if (move.appear.length == 0 && move.vanish.length == 1) this.subTurn = 2;
+    else {
+      // Turn changes only if not a sentry "pre-push"
+      this.turn = V.GetOppCol(this.turn);
+      this.subTurn = 1;
+    }
+    this.postPlay(move);
+  }
+
+  postPlay(move) {
+    if (move.vanish.length == 0 || this.subTurn == 2)
+      // Special pass move of the king, or sentry pre-push: nothing to update
+      return;
+    const c = move.vanish[0].c;
+    const piece = move.vanish[0].p;
+    const firstRank = c == "w" ? V.size.x - 1 : 0;
+
+    if (piece == V.KING) {
+      this.kingPos[c][0] = move.appear[0].x;
+      this.kingPos[c][1] = move.appear[0].y;
+      this.castleFlags[c] = [V.size.y, V.size.y];
+      return;
+    }
+    // Update castling flags if rooks are moved
+    const oppCol = V.GetOppCol(c);
+    const oppFirstRank = V.size.x - 1 - firstRank;
+    if (
+      move.start.x == firstRank && //our rook moves?
+      this.castleFlags[c].includes(move.start.y)
+    ) {
+      const flagIdx = (move.start.y == this.castleFlags[c][0] ? 0 : 1);
+      this.castleFlags[c][flagIdx] = V.size.y;
+    } else if (
+      move.end.x == oppFirstRank && //we took opponent rook?
+      this.castleFlags[oppCol].includes(move.end.y)
+    ) {
+      const flagIdx = (move.end.y == this.castleFlags[oppCol][0] ? 0 : 1);
+      this.castleFlags[oppCol][flagIdx] = V.size.y;
+    }
+  }
+
+  undo(move) {
+    this.epSquares.pop();
+    this.disaggregateFlags(JSON.parse(move.flags));
+    V.UndoOnBoard(this.board, move);
+    // Decrement movesCount except if the move is a sentry push
+    if (!move.sentryPush) this.movesCount--;
+    if (this.subTurn == 2) this.subTurn = 1;
+    else {
+      this.turn = V.GetOppCol(this.turn);
+      if (move.sentryPush) this.subTurn = 2;
+    }
+    this.postUndo(move);
+  }
+
+  postUndo(move) {
+    super.postUndo(move);
+    this.sentryPush.pop();
+  }
+
+  static get VALUES() {
+    return Object.assign(
+      { l: 4.8, s: 2.8, j: 3.8 }, //Jeff K. estimations
+      ChessRules.VALUES
+    );
+  }
+
+  getComputerMove() {
+    const maxeval = V.INFINITY;
+    const color = this.turn;
+    let moves1 = this.getAllValidMoves();
+
+    if (moves1.length == 0)
+      // TODO: this situation should not happen
+      return null;
+
+    const setEval = (move, next) => {
+      const score = this.getCurrentScore();
+      const curEval = move.eval;
+      if (score != "*") {
+        move.eval =
+          score == "1/2"
+            ? 0
+            : (score == "1-0" ? 1 : -1) * maxeval;
+      } else move.eval = this.evalPosition();
+      if (
+        // "next" is defined after sentry pushes
+        !!next && (
+          !curEval ||
+          color == 'w' && move.eval > curEval ||
+          color == 'b' && move.eval < curEval
+        )
+      ) {
+        move.second = next;
+      }
+    };
+
+    // Just search_depth == 1 (because of sentries. TODO: can do better...)
+    moves1.forEach(m1 => {
+      this.play(m1);
+      if (this.subTurn == 1) setEval(m1);
+      else {
+        // Need to play every pushes and count:
+        const moves2 = this.getAllValidMoves();
+        moves2.forEach(m2 => {
+          this.play(m2);
+          setEval(m1, m2);
+          this.undo(m2);
+        });
+      }
+      this.undo(m1);
+    });
+
+    moves1.sort((a, b) => {
+      return (color == "w" ? 1 : -1) * (b.eval - a.eval);
+    });
+    let candidates = [0];
+    for (let j = 1; j < moves1.length && moves1[j].eval == moves1[0].eval; j++)
+      candidates.push(j);
+    const choice = moves1[candidates[randInt(candidates.length)]];
+    return (!choice.second ? choice : [choice, choice.second]);
+  }
+
+  // For moves notation:
+  static get LANCER_DIRNAMES() {
+    return {
+      'c': "N",
+      'd': "NE",
+      'e': "E",
+      'f': "SE",
+      'g': "S",
+      'h': "SW",
+      'm': "W",
+      'o': "NW"
+    };
+  }
+
+  getNotation(move) {
+    // Special case "king takes jailer" is a pass move
+    if (move.appear.length == 0 && move.vanish.length == 0) return "pass";
+    let notation = undefined;
+    if (this.subTurn == 2) {
+      // Do not consider appear[1] (sentry) for sentry pushes
+      const simpleMove = {
+        appear: [move.appear[0]],
+        vanish: move.vanish,
+        start: move.start,
+        end: move.end
+      };
+      notation = super.getNotation(simpleMove);
+    }
+    else if (
+      move.appear.length > 0 &&
+      move.vanish[0].x == move.appear[0].x &&
+      move.vanish[0].y == move.appear[0].y
+    ) {
+      // Lancer in-place reorientation:
+      notation = "L" + V.CoordsToSquare(move.start) + ":R";
+    }
+    else notation = super.getNotation(move);
+    if (Object.keys(V.LANCER_DIRNAMES).includes(move.vanish[0].p))
+      // Lancer: add direction info
+      notation += "=" + V.LANCER_DIRNAMES[move.appear[0].p];
+    else if (
+      move.vanish[0].p == V.PAWN &&
+      Object.keys(V.LANCER_DIRNAMES).includes(move.appear[0].p)
+    ) {
+      // Fix promotions in lancer:
+      notation = notation.slice(0, -1) +
+        "L:" + V.LANCER_DIRNAMES[move.appear[0].p];
+    }
+    return notation;
+  }
+
+};
diff --git a/variants/Eightpieces/complete_rules.html b/variants/Eightpieces/complete_rules.html
new file mode 100644 (file)
index 0000000..c1d12ab
--- /dev/null
@@ -0,0 +1,48 @@
+p.boxed
+  | Three new pieces appear. All pieces are unique.
+
+p.
+  There are only one rook, one bishop and one knight per side in this variant.
+  That explains the name. The king and queen are still there,
+  and the three remaining slots are taken by new pieces:
+
+ul
+  li.
+    The lancer 'L' is oriented and can only move in the direction it points,
+    by any number of squares as long as an enemy isn't met
+    (it can jump over friendly pieces). If an opponent' piece is found,
+    it can be captured. After moving you can reorient the lancer.
+  li.
+    The sentry 'S' moves like a bishop but doesn't capture directly.
+    It "pushes" enemy pieces instead, either on an empty square or on other
+    enemy pieces which are thus (self-)captured.
+  li.
+    The jailer 'J' moves like a rook but also doesn't capture.
+    It immobilizes enemy pieces which are vertically or horizontally adjacent.
+
+p.
+  On the following diagram the white sentry can push the black lancer to
+  capture the black pawn on b4. The lancer is then immobilized
+  by the white jailer at a4.
+
+figure.diagram-container
+  .diagram.diag12
+    | fen:7k/8/8/8/Jp3m2/8/3S4/K7:
+  .diagram.diag22
+    | fen:7k/8/8/8/Jm3S2/8/8/K7:
+  figcaption Left: before white move S"push"f4. Right: after this move.
+
+p To reorient a stuck lancer,
+ul
+  li Just after being pushed: play a move which 'capture your king".
+  li Later in the game: click on the lancer.
+
+h3 Complete rules
+
+p
+  | The rules were invented by Jeff Kubach (2020), who described them much
+  | more precisely on the 
+  a(href="https://www.chessvariants.com/rules/8-piece-chess")
+    | chessvariants page
+  | . While the summary given above may suffice to start playing,
+  | you should read the complete rules to fully understand this variant.
diff --git a/variants/Eightpieces/rules.html b/variants/Eightpieces/rules.html
new file mode 100644 (file)
index 0000000..41e9d42
--- /dev/null
@@ -0,0 +1,3 @@
+Three new pieces appear: the lancer is a rook with a constrained direction, the sentry moves like a bishop and pushes pieces, and the jailer immobilizes pieces orthogonally adjacent.
+
+The goal is still to checkmate.
diff --git a/variants/Eightpieces/style.css b/variants/Eightpieces/style.css
new file mode 100644 (file)
index 0000000..a3550bc
--- /dev/null
@@ -0,0 +1 @@
+@import url("/base_pieces.css");
diff --git a/variants/Emergo/class.js b/variants/Emergo/class.js
new file mode 100644 (file)
index 0000000..13069de
--- /dev/null
@@ -0,0 +1,576 @@
+import { ChessRules, Move, PiPo } from "@/base_rules";
+import { randInt } from "@/utils/alea";
+import { ArrayFun } from "@/utils/array";
+
+export class EmergoRules extends ChessRules {
+
+  // Simple encoding: A to L = 1 to 12, from left to right, if white controls.
+  // Lowercase if black controls.
+  // Single piece (no prisoners): A@ to L@ (+ lowercase)
+
+  static get Options() {
+    return null;
+  }
+
+  static get HasFlags() {
+    return false;
+  }
+
+  static get HasEnpassant() {
+    return false;
+  }
+
+  static get DarkBottomRight() {
+    return true;
+  }
+
+  // board element == file name:
+  static board2fen(b) {
+    return b;
+  }
+  static fen2board(f) {
+    return f;
+  }
+
+  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++) {
+        // Add only 0.5 per symbol because 2 per piece
+        if (row[i].toLowerCase().match(/^[a-lA-L@]$/)) sumElts += 0.5;
+        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 GetBoard(position) {
+    const rows = position.split("/");
+    let board = ArrayFun.init(V.size.x, V.size.y, "");
+    for (let i = 0; i < rows.length; i++) {
+      let j = 0;
+      for (let indexInRow = 0; indexInRow < rows[i].length; indexInRow++) {
+        const character = rows[i][indexInRow];
+        const num = parseInt(character, 10);
+        // If num is a number, just shift j:
+        if (!isNaN(num)) j += num;
+        else
+          // Something at position i,j
+          board[i][j++] = V.fen2board(character + rows[i][++indexInRow]);
+      }
+    }
+    return board;
+  }
+
+  getPpath(b) {
+    return "Emergo/" + b;
+  }
+
+  getColor(x, y) {
+    if (x >= V.size.x) return x == V.size.x ? "w" : "b";
+    if (this.board[x][y].charCodeAt(0) < 97) return 'w';
+    return 'b';
+  }
+
+  getPiece() {
+    return V.PAWN; //unused
+  }
+
+  static IsGoodFen(fen) {
+    if (!ChessRules.IsGoodFen(fen)) return false;
+    const fenParsed = V.ParseFen(fen);
+    // 3) Check reserves
+    if (
+      !fenParsed.reserve ||
+      !fenParsed.reserve.match(/^([0-9]{1,2},?){2,2}$/)
+    ) {
+      return false;
+    }
+    return true;
+  }
+
+  static ParseFen(fen) {
+    const fenParts = fen.split(" ");
+    return Object.assign(
+      ChessRules.ParseFen(fen),
+      { reserve: fenParts[3] }
+    );
+  }
+
+  static get size() {
+    return { x: 9, y: 9 };
+  }
+
+  static GenRandInitFen() {
+    return "9/9/9/9/9/9/9/9/9 w 0 12,12";
+  }
+
+  getFen() {
+    return super.getFen() + " " + this.getReserveFen();
+  }
+
+  getFenForRepeat() {
+    return super.getFenForRepeat() + "_" + this.getReserveFen();
+  }
+
+  getReserveFen() {
+    return (
+      (!this.reserve["w"] ? 0 : this.reserve["w"][V.PAWN]) + "," +
+      (!this.reserve["b"] ? 0 : this.reserve["b"][V.PAWN])
+    );
+  }
+
+  getReservePpath(index, color) {
+    return "Emergo/" + (color == 'w' ? 'A' : 'a') + '@';
+  }
+
+  static get RESERVE_PIECES() {
+    return [V.PAWN]; //only array length matters
+  }
+
+  setOtherVariables(fen) {
+    const reserve =
+      V.ParseFen(fen).reserve.split(",").map(x => parseInt(x, 10));
+    this.reserve = { w: null, b: null };
+    if (reserve[0] > 0) this.reserve['w'] = { [V.PAWN]: reserve[0] };
+    if (reserve[1] > 0) this.reserve['b'] = { [V.PAWN]: reserve[1] };
+    // Local stack of captures during a turn (squares + directions)
+    this.captures = [ [] ];
+  }
+
+  atLeastOneCaptureFrom([x, y], color, forbiddenStep) {
+    for (let s of V.steps[V.BISHOP]) {
+      if (
+        !forbiddenStep ||
+        (s[0] != -forbiddenStep[0] || s[1] != -forbiddenStep[1])
+      ) {
+        const [i, j] = [x + s[0], y + s[1]];
+        if (
+          V.OnBoard(i + s[0], j + s[1]) &&
+          this.board[i][j] != V.EMPTY &&
+          this.getColor(i, j) != color &&
+          this.board[i + s[0]][j + s[1]] == V.EMPTY
+        ) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  atLeastOneCapture(color) {
+    const L0 = this.captures.length;
+    const captures = this.captures[L0 - 1];
+    const L = captures.length;
+    if (L > 0) {
+      return (
+        this.atLeastOneCaptureFrom(
+          captures[L-1].square, color, captures[L-1].step)
+      );
+    }
+    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 &&
+          this.atLeastOneCaptureFrom([i, j], color)
+        ) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  maxLengthIndices(caps) {
+    let maxLength = 0;
+    let res = [];
+    for (let i = 0; i < caps.length; i++) {
+      if (caps[i].length > maxLength) {
+        res = [i];
+        maxLength = caps[i].length;
+      }
+      else if (caps[i].length == maxLength) res.push(i);
+    }
+    return res;
+  };
+
+  getLongestCaptures_aux([x, y], color, locSteps) {
+    let res = [];
+    const L = locSteps.length;
+    const lastStep = (L > 0 ? locSteps[L-1] : null);
+    for (let s of V.steps[V.BISHOP]) {
+      if (!!lastStep && s[0] == -lastStep[0] && s[1] == -lastStep[1]) continue;
+      const [i, j] = [x + s[0], y + s[1]];
+      if (
+        V.OnBoard(i + s[0], j + s[1]) &&
+        this.board[i + s[0]][j + s[1]] == V.EMPTY &&
+        this.board[i][j] != V.EMPTY &&
+        this.getColor(i, j) != color
+      ) {
+        const move = this.getBasicMove([x, y], [i + s[0], j + s[1]], [i, j]);
+        locSteps.push(s);
+        V.PlayOnBoard(this.board, move);
+        const nextRes =
+          this.getLongestCaptures_aux([i + s[0], j + s[1]], color, locSteps);
+        res.push(1 + nextRes);
+        locSteps.pop();
+        V.UndoOnBoard(this.board, move);
+      }
+    }
+    if (res.length == 0) return 0;
+    return Math.max(...res);
+  }
+
+  getLongestCapturesFrom([x, y], color, locSteps) {
+    let res = [];
+    const L = locSteps.length;
+    const lastStep = (L > 0 ? locSteps[L-1] : null);
+    for (let s of V.steps[V.BISHOP]) {
+      if (!!lastStep && s[0] == -lastStep[0] && s[1] == -lastStep[1]) continue;
+      const [i, j] = [x + s[0], y + s[1]];
+      if (
+        V.OnBoard(i + s[0], j + s[1]) &&
+        this.board[i + s[0]][j + s[1]] == V.EMPTY &&
+        this.board[i][j] != V.EMPTY &&
+        this.getColor(i, j) != color
+      ) {
+        const move = this.getBasicMove([x, y], [i + s[0], j + s[1]], [i, j]);
+        locSteps.push(s);
+        V.PlayOnBoard(this.board, move);
+        const stepRes =
+          this.getLongestCaptures_aux([i + s[0], j + s[1]], color, locSteps);
+        res.push({ step: s, length: 1 + stepRes });
+        locSteps.pop();
+        V.UndoOnBoard(this.board, move);
+      }
+    }
+    return this.maxLengthIndices(res).map(i => res[i]);;
+  }
+
+  getAllLongestCaptures(color) {
+    const L0 = this.captures.length;
+    const captures = this.captures[L0 - 1];
+    const L = captures.length;
+    let caps = [];
+    if (L > 0) {
+      let locSteps = [ captures[L-1].step ];
+      let res =
+        this.getLongestCapturesFrom(captures[L-1].square, color, locSteps);
+      Array.prototype.push.apply(
+        caps,
+        res.map(r => Object.assign({ square: captures[L-1].square }, r))
+      );
+    }
+    else {
+      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
+          ) {
+            let locSteps = [];
+            let res = this.getLongestCapturesFrom([i, j], color, locSteps);
+            Array.prototype.push.apply(
+              caps,
+              res.map(r => Object.assign({ square: [i, j] }, r))
+            );
+          }
+        }
+      }
+    }
+    return this.maxLengthIndices(caps).map(i => caps[i]);
+  }
+
+  getBasicMove([x1, y1], [x2, y2], capt) {
+    const cp1 = this.board[x1][y1];
+    if (!capt) {
+      return new Move({
+        appear: [ new PiPo({ x: x2, y: y2, c: cp1[0], p: cp1[1] }) ],
+        vanish: [ new PiPo({ x: x1, y: y1, c: cp1[0], p: cp1[1] }) ]
+      });
+    }
+    // Compute resulting types based on jumped + jumping pieces
+    const color = this.getColor(x1, y1);
+    const firstCodes = (color == 'w' ? [65, 97] : [97, 65]);
+    const cpCapt = this.board[capt[0]][capt[1]];
+    let count1 = [cp1.charCodeAt(0) - firstCodes[0], -1];
+    if (cp1[1] != '@') count1[1] = cp1.charCodeAt(1) - firstCodes[0];
+    let countC = [cpCapt.charCodeAt(0) - firstCodes[1], -1];
+    if (cpCapt[1] != '@') countC[1] = cpCapt.charCodeAt(1) - firstCodes[1];
+    count1[1]++;
+    countC[0]--;
+    let colorChange = false,
+        captVanish = false;
+    if (countC[0] < 0) {
+      if (countC[1] >= 0) {
+        colorChange = true;
+        countC = [countC[1], -1];
+      }
+      else captVanish = true;
+    }
+    const incPrisoners = String.fromCharCode(firstCodes[0] + count1[1]);
+    let mv = new Move({
+      appear: [
+        new PiPo({
+          x: x2,
+          y: y2,
+          c: cp1[0],
+          p: incPrisoners
+        })
+      ],
+      vanish: [
+        new PiPo({ x: x1, y: y1, c: cp1[0], p: cp1[1] }),
+        new PiPo({ x: capt[0], y: capt[1], c: cpCapt[0], p: cpCapt[1] })
+      ]
+    });
+    if (!captVanish) {
+      mv.appear.push(
+        new PiPo({
+          x: capt[0],
+          y: capt[1],
+          c: String.fromCharCode(
+               firstCodes[(colorChange ? 0 : 1)] + countC[0]),
+          p: (colorChange ? '@' : cpCapt[1]),
+        })
+      );
+    }
+    return mv;
+  }
+
+  getReserveMoves(x) {
+    const color = this.turn;
+    if (!this.reserve[color] || this.atLeastOneCapture(color)) return [];
+    let moves = [];
+    const shadowPiece =
+      this.reserve[V.GetOppCol(color)] == null
+        ? this.reserve[color][V.PAWN] - 1
+        : 0;
+    const appearColor = String.fromCharCode(
+      (color == 'w' ? 'A' : 'a').charCodeAt(0) + shadowPiece);
+    const addMove = ([i, j]) => {
+      moves.push(
+        new Move({
+          appear: [ new PiPo({ x: i, y: j, c: appearColor, p: '@' }) ],
+          vanish: [],
+          start: { x: V.size.x + (color == 'w' ? 0 : 1), y: 0 }
+        })
+      );
+    };
+    const oppCol = V.GetOppCol(color);
+    const opponentCanCapture = this.atLeastOneCapture(oppCol);
+    for (let i = 0; i < V.size.x; i++) {
+      for (let j = i % 2; j < V.size.y; j += 2) {
+        if (
+          this.board[i][j] == V.EMPTY &&
+          // prevent playing on central square at move 1:
+          (this.movesCount >= 1 || i != 4 || j != 4)
+        ) {
+          if (opponentCanCapture) addMove([i, j]);
+          else {
+            let canAddMove = true;
+            for (let s of V.steps[V.BISHOP]) {
+              if (
+                V.OnBoard(i + s[0], j + s[1]) &&
+                V.OnBoard(i - s[0], j - s[1]) &&
+                this.board[i + s[0]][j + s[1]] != V.EMPTY &&
+                this.board[i - s[0]][j - s[1]] == V.EMPTY &&
+                this.getColor(i + s[0], j + s[1]) == oppCol
+              ) {
+                canAddMove = false;
+                break;
+              }
+            }
+            if (canAddMove) addMove([i, j]);
+          }
+        }
+      }
+    }
+    return moves;
+  }
+
+  getPotentialMovesFrom([x, y], longestCaptures) {
+    if (x >= V.size.x) {
+      if (longestCaptures.length == 0) return this.getReserveMoves(x);
+      return [];
+    }
+    const color = this.turn;
+    if (!!this.reserve[color] && !this.atLeastOneCapture(color)) return [];
+    const L0 = this.captures.length;
+    const captures = this.captures[L0 - 1];
+    const L = captures.length;
+    let moves = [];
+    if (longestCaptures.length > 0) {
+      if (
+        L > 0 &&
+        (x != captures[L-1].square[0] || y != captures[L-1].square[1])
+      ) {
+        return [];
+      }
+      longestCaptures.forEach(lc => {
+        if (lc.square[0] == x && lc.square[1] == y) {
+          const s = lc.step;
+          const [i, j] = [x + s[0], y + s[1]];
+          moves.push(this.getBasicMove([x, y], [i + s[0], j + s[1]], [i, j]));
+        }
+      });
+      return moves;
+    }
+    // Just search simple moves:
+    for (let s of V.steps[V.BISHOP]) {
+      const [i, j] = [x + s[0], y + s[1]];
+      if (V.OnBoard(i, j) && this.board[i][j] == V.EMPTY)
+        moves.push(this.getBasicMove([x, y], [i, j]));
+    }
+    return moves;
+  }
+
+  getAllValidMoves() {
+    const color = this.turn;
+    const longestCaptures = this.getAllLongestCaptures(color);
+    let potentialMoves = [];
+    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) {
+          Array.prototype.push.apply(
+            potentialMoves,
+            this.getPotentialMovesFrom([i, j], longestCaptures)
+          );
+        }
+      }
+    }
+    // Add reserve moves
+    potentialMoves = potentialMoves.concat(
+      this.getReserveMoves(V.size.x + (color == "w" ? 0 : 1))
+    );
+    return potentialMoves;
+  }
+
+  getPossibleMovesFrom([x, y]) {
+    const longestCaptures = this.getAllLongestCaptures(this.getColor(x, y));
+    return this.getPotentialMovesFrom([x, y], longestCaptures);
+  }
+
+  filterValid(moves) {
+    return moves;
+  }
+
+  getCheckSquares() {
+    return [];
+  }
+
+  play(move) {
+    const color = this.turn;
+    move.turn = color; //for undo
+    V.PlayOnBoard(this.board, move);
+    if (move.vanish.length == 2) {
+      const L0 = this.captures.length;
+      let captures = this.captures[L0 - 1];
+      captures.push({
+        square: [move.end.x, move.end.y],
+        step: [(move.end.x - move.start.x)/2, (move.end.y - move.start.y)/2]
+      });
+      if (this.atLeastOneCapture(color))
+        // There could be other captures (mandatory)
+        move.notTheEnd = true;
+    }
+    else if (move.vanish == 0) {
+      const firstCode = (color == 'w' ? 65 : 97);
+      // Generally, reserveCount == 1 (except for shadow piece)
+      const reserveCount = move.appear[0].c.charCodeAt() - firstCode + 1;
+      this.reserve[color][V.PAWN] -= reserveCount;
+      if (this.reserve[color][V.PAWN] == 0) this.reserve[color] = null;
+    }
+    if (!move.notTheEnd) {
+      this.turn = V.GetOppCol(color);
+      this.movesCount++;
+      this.captures.push([]);
+    }
+  }
+
+  undo(move) {
+    V.UndoOnBoard(this.board, move);
+    if (!move.notTheEnd) {
+      this.turn = move.turn;
+      this.movesCount--;
+      this.captures.pop();
+    }
+    if (move.vanish.length == 0) {
+      const color = (move.appear[0].c == 'A' ? 'w' : 'b');
+      const firstCode = (color == 'w' ? 65 : 97);
+      const reserveCount = move.appear[0].c.charCodeAt() - firstCode + 1;
+      if (!this.reserve[color]) this.reserve[color] = { [V.PAWN]: 0 };
+      this.reserve[color][V.PAWN] += reserveCount;
+    }
+    else if (move.vanish.length == 2) {
+      const L0 = this.captures.length;
+      let captures = this.captures[L0 - 1];
+      captures.pop();
+    }
+  }
+
+  atLeastOneMove() {
+    const color = this.turn;
+    if (this.atLeastOneCapture(color)) return true;
+    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) {
+          const moves = this.getPotentialMovesFrom([i, j], []);
+          if (moves.length > 0) return true;
+        }
+      }
+    }
+    const reserveMoves =
+      this.getReserveMoves(V.size.x + (this.turn == "w" ? 0 : 1));
+    return (reserveMoves.length > 0);
+  }
+
+  getCurrentScore() {
+    const color = this.turn;
+    // If no pieces on board + reserve, I lose
+    if (!!this.reserve[color]) return "*";
+    let atLeastOnePiece = false;
+    outerLoop: 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) {
+          atLeastOnePiece = true;
+          break outerLoop;
+        }
+      }
+    }
+    if (!atLeastOnePiece) return (color == 'w' ? "0-1" : "1-0");
+    if (!this.atLeastOneMove()) return "1/2";
+    return "*";
+  }
+
+  getComputerMove() {
+    // Random mover for now (TODO)
+    const color = this.turn;
+    let mvArray = [];
+    let mv = null;
+    while (this.turn == color) {
+      const moves = this.getAllValidMoves();
+      mv = moves[randInt(moves.length)];
+      mvArray.push(mv);
+      this.play(mv);
+    }
+    for (let i = mvArray.length - 1; i >= 0; i--) this.undo(mvArray[i]);
+    return (mvArray.length > 1 ? mvArray : mvArray[0]);
+  }
+
+  getNotation(move) {
+    if (move.vanish.length == 0) return "@" + V.CoordsToSquare(move.end);
+    const L0 = this.captures.length;
+    if (this.captures[L0 - 1].length > 0) return V.CoordsToSquare(move.end);
+    return V.CoordsToSquare(move.start) + V.CoordsToSquare(move.end);
+  }
+
+};
diff --git a/variants/Empire/class.js b/variants/Empire/class.js
new file mode 100644 (file)
index 0000000..d04e4a8
--- /dev/null
@@ -0,0 +1,432 @@
+import { ChessRules } from "@/base_rules";
+
+export class EmpireRules extends ChessRules {
+
+  static get PawnSpecs() {
+    return Object.assign(
+      {},
+      ChessRules.PawnSpecs,
+      { promotions: [V.QUEEN] }
+    );
+  }
+
+  static get LoseOnRepetition() {
+    return true;
+  }
+
+  static IsGoodFlags(flags) {
+    // Only black can castle
+    return !!flags.match(/^[a-z]{2,2}$/);
+  }
+
+  getPpath(b) {
+    return (b[0] == 'w' ? "Empire/" : "") + b;
+  }
+
+  static GenRandInitFen(options) {
+    if (options.randomness == 0)
+      return "rnbqkbnr/pppppppp/8/8/8/PPPSSPPP/8/TECDKCET w 0 ah -";
+
+    // Mapping kingdom --> empire:
+    const piecesMap = {
+      'R': 'T',
+      'N': 'E',
+      'B': 'C',
+      'Q': 'D',
+      'K': 'K'
+    };
+
+    const baseFen = ChessRules.GenRandInitFen(options);
+    return (
+      baseFen.substr(0, 24) + "PPPSSPPP/8/" +
+      baseFen.substr(35, 8).split('').map(p => piecesMap[p]).join('') +
+      baseFen.substr(43, 5) + baseFen.substr(50)
+    );
+  }
+
+  getFlagsFen() {
+    return this.castleFlags['b'].map(V.CoordToColumn).join("");
+  }
+
+  setFlags(fenflags) {
+    this.castleFlags = { 'b': [-1, -1] };
+    for (let i = 0; i < 2; i++)
+      this.castleFlags['b'][i] = V.ColumnToCoord(fenflags.charAt(i));
+  }
+
+  static get TOWER() {
+    return 't';
+  }
+  static get EAGLE() {
+    return 'e';
+  }
+  static get CARDINAL() {
+    return 'c';
+  }
+  static get DUKE() {
+    return 'd';
+  }
+  static get SOLDIER() {
+    return 's';
+  }
+  // Kaiser is technically a King, so let's keep things simple.
+
+  static get PIECES() {
+    return ChessRules.PIECES.concat(
+      [V.TOWER, V.EAGLE, V.CARDINAL, V.DUKE, V.SOLDIER]);
+  }
+
+  getPotentialMovesFrom(sq) {
+    let moves = [];
+    const piece = this.getPiece(sq[0], sq[1]);
+    switch (piece) {
+      case V.TOWER:
+        moves = this.getPotentialTowerMoves(sq);
+        break;
+      case V.EAGLE:
+        moves = this.getPotentialEagleMoves(sq);
+        break;
+      case V.CARDINAL:
+        moves = this.getPotentialCardinalMoves(sq);
+        break;
+      case V.DUKE:
+        moves = this.getPotentialDukeMoves(sq);
+        break;
+      case V.SOLDIER:
+        moves = this.getPotentialSoldierMoves(sq);
+        break;
+      default:
+        moves = super.getPotentialMovesFrom(sq);
+    }
+    if (
+      piece != V.KING &&
+      this.kingPos['w'][0] != this.kingPos['b'][0] &&
+      this.kingPos['w'][1] != this.kingPos['b'][1]
+    ) {
+      return moves;
+    }
+    // TODO: factor two next "if" into one (rank/column...)
+    if (this.kingPos['w'][1] == this.kingPos['b'][1]) {
+      const colKing = this.kingPos['w'][1];
+      let intercept = 0; //count intercepting pieces
+      let [kingPos1, kingPos2] = [this.kingPos['w'][0], this.kingPos['b'][0]];
+      if (kingPos1 > kingPos2) [kingPos1, kingPos2] = [kingPos2, kingPos1];
+      for (let i = kingPos1 + 1; i < kingPos2; i++) {
+        if (this.board[i][colKing] != V.EMPTY) intercept++;
+      }
+      if (intercept >= 2) return moves;
+      // intercept == 1 (0 is impossible):
+      // Any move not removing intercept is OK
+      return moves.filter(m => {
+        return (
+          // From another column?
+          m.start.y != colKing ||
+          // From behind a king? (including kings themselves!)
+          m.start.x <= kingPos1 ||
+          m.start.x >= kingPos2 ||
+          // Intercept piece moving: must remain in-between
+          (
+            m.end.y == colKing &&
+            m.end.x > kingPos1 &&
+            m.end.x < kingPos2
+          )
+        );
+      });
+    }
+    if (this.kingPos['w'][0] == this.kingPos['b'][0]) {
+      const rowKing = this.kingPos['w'][0];
+      let intercept = 0; //count intercepting pieces
+      let [kingPos1, kingPos2] = [this.kingPos['w'][1], this.kingPos['b'][1]];
+      if (kingPos1 > kingPos2) [kingPos1, kingPos2] = [kingPos2, kingPos1];
+      for (let i = kingPos1 + 1; i < kingPos2; i++) {
+        if (this.board[rowKing][i] != V.EMPTY) intercept++;
+      }
+      if (intercept >= 2) return moves;
+      // intercept == 1 (0 is impossible):
+      // Any move not removing intercept is OK
+      return moves.filter(m => {
+        return (
+          // From another row?
+          m.start.x != rowKing ||
+          // From "behind" a king? (including kings themselves!)
+          m.start.y <= kingPos1 ||
+          m.start.y >= kingPos2 ||
+          // Intercept piece moving: must remain in-between
+          (
+            m.end.x == rowKing &&
+            m.end.y > kingPos1 &&
+            m.end.y < kingPos2
+          )
+        );
+      });
+    }
+    // piece == king: check only if move.end.y == enemy king column,
+    // or if move.end.x == enemy king rank.
+    const color = this.getColor(sq[0], sq[1]);
+    const oppCol = V.GetOppCol(color);
+    return moves.filter(m => {
+      if (
+        m.end.y != this.kingPos[oppCol][1] &&
+        m.end.x != this.kingPos[oppCol][0]
+      ) {
+        return true;
+      }
+      // check == -1 if (row, or col) unchecked, 1 if checked and occupied,
+      //          0 if checked and clear
+      let check = [-1, -1];
+      // TODO: factor two next "if"...
+      if (m.end.x == this.kingPos[oppCol][0]) {
+        if (check[0] < 0) {
+          // Do the check:
+          check[0] = 0;
+          let [kingPos1, kingPos2] = [m.end.y, this.kingPos[oppCol][1]];
+          if (kingPos1 > kingPos2) [kingPos1, kingPos2] = [kingPos2, kingPos1];
+          for (let i = kingPos1 + 1; i < kingPos2; i++) {
+            if (this.board[m.end.x][i] != V.EMPTY) {
+              check[0]++;
+              break;
+            }
+          }
+          return check[0] == 1;
+        }
+        // Check already done:
+        return check[0] == 1;
+      }
+      //if (m.end.y == this.kingPos[oppCol][1]) //true...
+      if (check[1] < 0) {
+        // Do the check:
+        check[1] = 0;
+        let [kingPos1, kingPos2] = [m.end.x, this.kingPos[oppCol][0]];
+        if (kingPos1 > kingPos2) [kingPos1, kingPos2] = [kingPos2, kingPos1];
+        for (let i = kingPos1 + 1; i < kingPos2; i++) {
+          if (this.board[i][m.end.y] != V.EMPTY) {
+            check[1]++;
+            break;
+          }
+        }
+        return check[1] == 1;
+      }
+      // Check already done:
+      return check[1] == 1;
+    });
+  }
+
+  // TODO: some merging to do with Orda method (and into base_rules.js)
+  getSlideNJumpMoves_([x, y], steps, oneStep) {
+    let moves = [];
+    outerLoop: for (let step of steps) {
+      const s = step.s;
+      let i = x + s[0];
+      let j = y + s[1];
+      while (V.OnBoard(i, j) && this.board[i][j] == V.EMPTY) {
+        if (!step.onlyTake) moves.push(this.getBasicMove([x, y], [i, j]));
+        // NOTE: (bad) HACK here, since onlyTake is true only for Eagle
+        // capturing moves, which are oneStep...
+        if (oneStep || step.onlyTake) continue outerLoop;
+        i += s[0];
+        j += s[1];
+      }
+      if (V.OnBoard(i, j) && this.canTake([x, y], [i, j]) && !step.onlyMove)
+        moves.push(this.getBasicMove([x, y], [i, j]));
+    }
+    return moves;
+  }
+
+  static get steps() {
+    return (
+      Object.assign(
+        {
+          t: [
+            { s: [-1, 0] },
+            { s: [1, 0] },
+            { s: [0, -1] },
+            { s: [0, 1] },
+            { s: [-1, -1], onlyMove: true },
+            { s: [-1, 1], onlyMove: true },
+            { s: [1, -1], onlyMove: true },
+            { s: [1, 1], onlyMove: true }
+          ],
+          c: [
+            { s: [-1, 0], onlyMove: true },
+            { s: [1, 0], onlyMove: true },
+            { s: [0, -1], onlyMove: true },
+            { s: [0, 1], onlyMove: true },
+            { s: [-1, -1] },
+            { s: [-1, 1] },
+            { s: [1, -1] },
+            { s: [1, 1] }
+          ],
+          e: [
+            { s: [-1, 0], onlyMove: true },
+            { s: [1, 0], onlyMove: true },
+            { s: [0, -1], onlyMove: true },
+            { s: [0, 1], onlyMove: true },
+            { s: [-1, -1], onlyMove: true },
+            { s: [-1, 1], onlyMove: true },
+            { s: [1, -1], onlyMove: true },
+            { s: [1, 1], onlyMove: true },
+            { s: [-2, -1], onlyTake: true },
+            { s: [-2, 1], onlyTake: true },
+            { s: [-1, -2], onlyTake: true },
+            { s: [-1, 2], onlyTake: true },
+            { s: [1, -2], onlyTake: true },
+            { s: [1, 2], onlyTake: true },
+            { s: [2, -1], onlyTake: true },
+            { s: [2, 1], onlyTake: true }
+          ]
+        },
+        ChessRules.steps
+      )
+    );
+  }
+
+  getPotentialTowerMoves(sq) {
+    return this.getSlideNJumpMoves_(sq, V.steps[V.TOWER]);
+  }
+
+  getPotentialCardinalMoves(sq) {
+    return this.getSlideNJumpMoves_(sq, V.steps[V.CARDINAL]);
+  }
+
+  getPotentialEagleMoves(sq) {
+    return this.getSlideNJumpMoves_(sq, V.steps[V.EAGLE]);
+  }
+
+  getPotentialDukeMoves([x, y]) {
+    // Anything to capture around? mark other steps to explore after
+    let steps = [];
+    const oppCol = V.GetOppCol(this.getColor(x, y));
+    let moves = [];
+    for (let s of V.steps[V.ROOK].concat(V.steps[V.BISHOP])) {
+      const [i, j] = [x + s[0], y + s[1]];
+      if (
+        V.OnBoard(i, j) &&
+        this.board[i][j] != V.EMPTY &&
+        this.getColor(i, j) == oppCol
+      ) {
+        moves.push(super.getBasicMove([x, y], [i, j]));
+      }
+      else steps.push({ s: s, onlyMove: true });
+    }
+    if (steps.length > 0) {
+      const noncapturingMoves = this.getSlideNJumpMoves_([x, y], steps);
+      Array.prototype.push.apply(moves, noncapturingMoves);
+    }
+    return moves;
+  }
+
+  getPotentialKingMoves([x, y]) {
+    if (this.getColor(x, y) == 'b') return super.getPotentialKingMoves([x, y]);
+    // Empire doesn't castle:
+    return super.getSlideNJumpMoves(
+      [x, y], V.steps[V.ROOK].concat(V.steps[V.BISHOP]), 1);
+  }
+
+  getPotentialSoldierMoves([x, y]) {
+    const c = this.getColor(x, y);
+    const shiftX = (c == 'w' ? -1 : 1);
+    const lastRank = (c == 'w' && x == 0 || c == 'b' && x == 9);
+    let steps = [];
+    if (!lastRank) steps.push([shiftX, 0]);
+    if (y > 0) steps.push([0, -1]);
+    if (y < 9) steps.push([0, 1]);
+    return super.getSlideNJumpMoves([x, y], steps, 1);
+  }
+
+  isAttacked(sq, color) {
+    if (color == 'b') return super.isAttacked(sq, color);
+    // Empire: only pawn and king (+ queen if promotion) in common:
+    return (
+      super.isAttackedByPawn(sq, color) ||
+      this.isAttackedByTower(sq, color) ||
+      this.isAttackedByEagle(sq, color) ||
+      this.isAttackedByCardinal(sq, color) ||
+      this.isAttackedByDuke(sq, color) ||
+      this.isAttackedBySoldier(sq, color) ||
+      super.isAttackedByKing(sq, color) ||
+      super.isAttackedByQueen(sq, color)
+    );
+  }
+
+  isAttackedByTower(sq, color) {
+    return super.isAttackedBySlideNJump(sq, color, V.TOWER, V.steps[V.ROOK]);
+  }
+
+  isAttackedByEagle(sq, color) {
+    return super.isAttackedBySlideNJump(
+      sq, color, V.EAGLE, V.steps[V.KNIGHT], 1);
+  }
+
+  isAttackedByCardinal(sq, color) {
+    return super.isAttackedBySlideNJump(
+      sq, color, V.CARDINAL, V.steps[V.BISHOP]);
+  }
+
+  isAttackedByDuke(sq, color) {
+    return (
+      super.isAttackedBySlideNJump(
+        sq, color, V.DUKE,
+        V.steps[V.ROOK].concat(V.steps[V.BISHOP]), 1
+      )
+    );
+  }
+
+  isAttackedBySoldier([x, y], color) {
+    const shiftX = (color == 'w' ? 1 : -1); //shift from king
+    return super.isAttackedBySlideNJump(
+      [x, y], color, V.SOLDIER, [[shiftX, 0], [0, 1], [0, -1]], 1);
+  }
+
+  updateCastleFlags(move, piece) {
+    // Only black can castle:
+    const firstRank = 0;
+    if (piece == V.KING && move.appear[0].c == 'b')
+      this.castleFlags['b'] = [8, 8];
+    else if (
+      move.start.x == firstRank &&
+      this.castleFlags['b'].includes(move.start.y)
+    ) {
+      const flagIdx = (move.start.y == this.castleFlags['b'][0] ? 0 : 1);
+      this.castleFlags['b'][flagIdx] = 8;
+    }
+    else if (
+      move.end.x == firstRank &&
+      this.castleFlags['b'].includes(move.end.y)
+    ) {
+      const flagIdx = (move.end.y == this.castleFlags['b'][0] ? 0 : 1);
+      this.castleFlags['b'][flagIdx] = 8;
+    }
+  }
+
+  getCurrentScore() {
+    // Turn has changed:
+    const color = V.GetOppCol(this.turn);
+    const lastRank = (color == 'w' ? 0 : 7);
+    if (this.kingPos[color][0] == lastRank)
+      // The opposing edge is reached!
+      return color == "w" ? "1-0" : "0-1";
+    if (this.atLeastOneMove()) return "*";
+    // Game over
+    const oppCol = this.turn;
+    return (oppCol == "w" ? "0-1" : "1-0");
+  }
+
+  static get VALUES() {
+    return Object.assign(
+      {},
+      ChessRules.VALUES,
+      {
+        t: 7,
+        e: 7,
+        c: 4,
+        d: 4,
+        s: 2
+      }
+    );
+  }
+
+  static get SEARCH_DEPTH() {
+    return 2;
+  }
+
+};
diff --git a/variants/Enpassant/class.js b/variants/Enpassant/class.js
new file mode 100644 (file)
index 0000000..cb21830
--- /dev/null
@@ -0,0 +1,209 @@
+import { ChessRules, PiPo, Move } from "@/base_rules";
+
+export class EnpassantRules extends ChessRules {
+
+  static IsGoodEnpassant(enpassant) {
+    if (enpassant != "-") {
+      const squares = enpassant.split(",");
+      if (squares.length > 2) return false;
+      for (let sq of squares) {
+        const ep = V.SquareToCoords(sq);
+        if (isNaN(ep.x) || !V.OnBoard(ep)) return false;
+      }
+    }
+    return true;
+  }
+
+  getPpath(b) {
+    return (b[1] == V.KNIGHT ? "Enpassant/" : "") + b;
+  }
+
+  getEpSquare(moveOrSquare) {
+    if (!moveOrSquare) return undefined;
+    if (typeof moveOrSquare === "string") {
+      const square = moveOrSquare;
+      if (square == "-") return undefined;
+      // Expand init + dest squares into a full path:
+      const init = V.SquareToCoords(square.substr(0, 2));
+      let newPath = [init];
+      if (square.length == 2) return newPath;
+      const dest = V.SquareToCoords(square.substr(2));
+      const delta = ['x', 'y'].map(i => Math.abs(dest[i] - init[i]));
+      // Check if it's a knight(rider) movement:
+      let step = [0, 0];
+      if (delta[0] > 0 && delta[1] > 0 && delta[0] != delta[1]) {
+        // Knightrider
+        const minShift = Math.min(delta[0], delta[1]);
+        step[0] = (dest.x - init.x) / minShift;
+        step[1] = (dest.y - init.y) / minShift;
+      } else {
+        // "Sliders"
+        step = ['x', 'y'].map((i, idx) => {
+          return (dest[i] - init[i]) / delta[idx] || 0
+        });
+      }
+      let x = init.x + step[0],
+          y = init.y + step[1];
+      while (x != dest.x || y != dest.y) {
+        newPath.push({ x: x, y: y });
+        x += step[0];
+        y += step[1];
+      }
+      newPath.push(dest);
+      return newPath;
+    }
+    // Argument is a move: all intermediate squares are en-passant candidates,
+    // except if the moving piece is a king.
+    const move = moveOrSquare;
+    const piece = move.appear[0].p;
+    if (piece == V.KING ||
+      (
+        Math.abs(move.end.x-move.start.x) <= 1 &&
+        Math.abs(move.end.y-move.start.y) <= 1
+      )
+    ) {
+      return undefined;
+    }
+    const delta = [move.end.x-move.start.x, move.end.y-move.start.y];
+    let step = undefined;
+    if (piece == V.KNIGHT) {
+      const divisor = Math.min(Math.abs(delta[0]), Math.abs(delta[1]));
+      step = [delta[0]/divisor || 0, delta[1]/divisor || 0];
+    } else {
+      step = [
+        delta[0]/Math.abs(delta[0]) || 0,
+        delta[1]/Math.abs(delta[1]) || 0
+      ];
+    }
+    let res = [];
+    for (
+      let [x,y] = [move.start.x+step[0],move.start.y+step[1]];
+      x != move.end.x || y != move.end.y;
+      x += step[0], y += step[1]
+    ) {
+      res.push({ x: x, y: y });
+    }
+    // Add final square to know which piece is taken en passant:
+    res.push(move.end);
+    return res;
+  }
+
+  getEnpassantFen() {
+    const L = this.epSquares.length;
+    if (!this.epSquares[L - 1]) return "-"; //no en-passant
+    const epsq = this.epSquares[L - 1];
+    if (epsq.length <= 2) return epsq.map(V.CoordsToSquare).join("");
+    // Condensate path: just need initial and final squares:
+    return V.CoordsToSquare(epsq[0]) + V.CoordsToSquare(epsq[epsq.length - 1]);
+  }
+
+  getPotentialMovesFrom([x, y]) {
+    let moves = super.getPotentialMovesFrom([x,y]);
+    // Add en-passant captures from this square:
+    const L = this.epSquares.length;
+    if (!this.epSquares[L - 1]) return moves;
+    const squares = this.epSquares[L - 1];
+    const S = squares.length;
+    // Object describing the removed opponent's piece:
+    const pipoV = new PiPo({
+      x: squares[S-1].x,
+      y: squares[S-1].y,
+      c: V.GetOppCol(this.turn),
+      p: this.getPiece(squares[S-1].x, squares[S-1].y)
+    });
+    // Check if existing non-capturing moves could also capture en passant
+    moves.forEach(m => {
+      if (
+        m.appear[0].p != V.PAWN && //special pawn case is handled elsewhere
+        m.vanish.length <= 1 &&
+        [...Array(S-1).keys()].some(i => {
+          return m.end.x == squares[i].x && m.end.y == squares[i].y;
+        })
+      ) {
+        m.vanish.push(pipoV);
+      }
+    });
+    // Special case of the king knight's movement:
+    if (this.getPiece(x, y) == V.KING) {
+      V.steps[V.KNIGHT].forEach(step => {
+        const endX = x + step[0];
+        const endY = y + step[1];
+        if (
+          V.OnBoard(endX, endY) &&
+          [...Array(S-1).keys()].some(i => {
+            return endX == squares[i].x && endY == squares[i].y;
+          })
+        ) {
+          let enpassantMove = this.getBasicMove([x, y], [endX, endY]);
+          enpassantMove.vanish.push(pipoV);
+          moves.push(enpassantMove);
+        }
+      });
+    }
+    return moves;
+  }
+
+  getEnpassantCaptures([x, y], shiftX) {
+    const Lep = this.epSquares.length;
+    const squares = this.epSquares[Lep - 1];
+    let moves = [];
+    if (!!squares) {
+      const S = squares.length;
+      const taken = squares[S-1];
+      const pipoV = new PiPo({
+        x: taken.x,
+        y: taken.y,
+        p: this.getPiece(taken.x, taken.y),
+        c: this.getColor(taken.x, taken.y)
+      });
+      [...Array(S-1).keys()].forEach(i => {
+        const sq = squares[i];
+        if (sq.x == x + shiftX && Math.abs(sq.y - y) == 1) {
+          let enpassantMove = this.getBasicMove([x, y], [sq.x, sq.y]);
+          enpassantMove.vanish.push(pipoV);
+          moves.push(enpassantMove);
+        }
+      });
+    }
+    return moves;
+  }
+
+  // Remove the "onestep" condition: knight promote to knightrider:
+  getPotentialKnightMoves(sq) {
+    return this.getSlideNJumpMoves(sq, V.steps[V.KNIGHT]);
+  }
+
+  filterValid(moves) {
+    const filteredMoves = super.filterValid(moves);
+    // If at least one full move made, everything is allowed:
+    if (this.movesCount >= 2)
+      return filteredMoves;
+    // Else, forbid captures:
+    return filteredMoves.filter(m => m.vanish.length == 1);
+  }
+
+  isAttackedByKnight(sq, color) {
+    return this.isAttackedBySlideNJump(
+      sq,
+      color,
+      V.KNIGHT,
+      V.steps[V.KNIGHT]
+    );
+  }
+
+  static get SEARCH_DEPTH() {
+    return 2;
+  }
+
+  static get VALUES() {
+    return {
+      p: 1,
+      r: 5,
+      n: 4,
+      b: 3,
+      q: 9,
+      k: 1000
+    };
+  }
+
+};
diff --git a/variants/Evolution/class.js b/variants/Evolution/class.js
new file mode 100644 (file)
index 0000000..5250089
--- /dev/null
@@ -0,0 +1,34 @@
+import { ChessRules } from "@/base_rules";
+
+export class EvolutionRules extends ChessRules {
+
+  getPotentialMovesFrom([x, y]) {
+    let moves = super.getPotentialMovesFrom([x, y]);
+    const c = this.getColor(x, y);
+    const piece = this.getPiece(x, y);
+    if (
+      [V.BISHOP, V.ROOK, V.QUEEN].includes(piece) &&
+      (c == 'w' && x == 7) || (c == 'b' && x == 0)
+    ) {
+      // Move from first rank
+      const forward = (c == 'w' ? -1 : 1);
+      for (let shift of [-2, 0, 2]) {
+        if (
+          (piece == V.ROOK && shift != 0) ||
+          (piece == V.BISHOP && shift == 0)
+        ) {
+          continue;
+        }
+        if (
+          V.OnBoard(x+2*forward, y+shift) &&
+          this.board[x+forward][y+shift/2] != V.EMPTY &&
+          this.getColor(x+2*forward, y+shift) != c
+        ) {
+          moves.push(this.getBasicMove([x,y], [x+2*forward,y+shift]));
+        }
+      }
+    }
+    return moves;
+  }
+
+};
diff --git a/variants/Extinction/class.js b/variants/Extinction/class.js
new file mode 100644 (file)
index 0000000..e42e294
--- /dev/null
@@ -0,0 +1,118 @@
+import { ChessRules } from "@/base_rules";
+
+export class ExtinctionRules extends ChessRules {
+
+  static get PawnSpecs() {
+    return Object.assign(
+      {},
+      ChessRules.PawnSpecs,
+      { promotions: ChessRules.PawnSpecs.promotions.concat([V.KING]) }
+    );
+  }
+
+  static IsGoodPosition(position) {
+    if (!ChessRules.IsGoodPosition(position)) return false;
+    // Also check that each piece type is present
+    const rows = position.split("/");
+    let pieces = {};
+    for (let row of rows) {
+      for (let i = 0; i < row.length; i++) {
+        if (isNaN(parseInt(row[i], 10)) && !pieces[row[i]])
+          pieces[row[i]] = true;
+      }
+    }
+    if (Object.keys(pieces).length != 12) return false;
+    return true;
+  }
+
+  setOtherVariables(fen) {
+    super.setOtherVariables(fen);
+    const pos = V.ParseFen(fen).position;
+    // NOTE: no need for safety "|| []", because each piece type is present
+    // (otherwise game is already over!)
+    this.material = {
+      w: {
+        [V.KING]: pos.match(/K/g).length,
+        [V.QUEEN]: pos.match(/Q/g).length,
+        [V.ROOK]: pos.match(/R/g).length,
+        [V.KNIGHT]: pos.match(/N/g).length,
+        [V.BISHOP]: pos.match(/B/g).length,
+        [V.PAWN]: pos.match(/P/g).length
+      },
+      b: {
+        [V.KING]: pos.match(/k/g).length,
+        [V.QUEEN]: pos.match(/q/g).length,
+        [V.ROOK]: pos.match(/r/g).length,
+        [V.KNIGHT]: pos.match(/n/g).length,
+        [V.BISHOP]: pos.match(/b/g).length,
+        [V.PAWN]: pos.match(/p/g).length
+      }
+    };
+  }
+
+  // TODO: verify this assertion
+  atLeastOneMove() {
+    return true; //always at least one possible move
+  }
+
+  filterValid(moves) {
+    return moves; //there is no check
+  }
+
+  getCheckSquares() {
+    return [];
+  }
+
+  postPlay(move) {
+    super.postPlay(move);
+    // Treat the promotion case: (not the capture part)
+    if (move.appear[0].p != move.vanish[0].p) {
+      this.material[move.appear[0].c][move.appear[0].p]++;
+      this.material[move.appear[0].c][V.PAWN]--;
+    }
+    if (move.vanish.length == 2 && move.appear.length == 1)
+      //capture
+      this.material[move.vanish[1].c][move.vanish[1].p]--;
+  }
+
+  postUndo(move) {
+    super.postUndo(move);
+    if (move.appear[0].p != move.vanish[0].p) {
+      this.material[move.appear[0].c][move.appear[0].p]--;
+      this.material[move.appear[0].c][V.PAWN]++;
+    }
+    if (move.vanish.length == 2 && move.appear.length == 1)
+      this.material[move.vanish[1].c][move.vanish[1].p]++;
+  }
+
+  getCurrentScore() {
+    if (this.atLeastOneMove()) {
+      // Game not over?
+      const color = this.turn;
+      if (
+        Object.keys(this.material[color]).some(p => {
+          return this.material[color][p] == 0;
+        })
+      ) {
+        return this.turn == "w" ? "0-1" : "1-0";
+      }
+      return "*";
+    }
+    return this.turn == "w" ? "0-1" : "1-0"; //NOTE: currently unreachable...
+  }
+
+  evalPosition() {
+    const color = this.turn;
+    if (
+      Object.keys(this.material[color]).some(p => {
+        return this.material[color][p] == 0;
+      })
+    ) {
+      // Very negative (resp. positive)
+      // if white (reps. black) pieces set is incomplete
+      return (color == "w" ? -1 : 1) * V.INFINITY;
+    }
+    return super.evalPosition();
+  }
+
+};
diff --git a/variants/Fanorona/class.js b/variants/Fanorona/class.js
new file mode 100644 (file)
index 0000000..e1e653a
--- /dev/null
@@ -0,0 +1,342 @@
+import { ChessRules, Move, PiPo } from "@/base_rules";
+import { randInt } from "@/utils/alea";
+
+export class FanoronaRules extends ChessRules {
+
+  static get Options() {
+    return null;
+  }
+
+  static get HasFlags() {
+    return false;
+  }
+
+  static get HasEnpassant() {
+    return false;
+  }
+
+  static get Monochrome() {
+    return true;
+  }
+
+  static get Lines() {
+    let lines = [];
+    // Draw all inter-squares lines, shifted:
+    for (let i = 0; i < V.size.x; i++)
+      lines.push([[i+0.5, 0.5], [i+0.5, V.size.y-0.5]]);
+    for (let j = 0; j < V.size.y; j++)
+      lines.push([[0.5, j+0.5], [V.size.x-0.5, j+0.5]]);
+    const columnDiags = [
+      [[0.5, 0.5], [2.5, 2.5]],
+      [[0.5, 2.5], [2.5, 0.5]],
+      [[2.5, 0.5], [4.5, 2.5]],
+      [[4.5, 0.5], [2.5, 2.5]]
+    ];
+    for (let j of [0, 2, 4, 6]) {
+      lines = lines.concat(
+        columnDiags.map(L => [[L[0][0], L[0][1] + j], [L[1][0], L[1][1] + j]])
+      );
+    }
+    return lines;
+  }
+
+  static get Notoodark() {
+    return true;
+  }
+
+  static GenRandInitFen() {
+    return "ppppppppp/ppppppppp/pPpP1pPpP/PPPPPPPPP/PPPPPPPPP w 0";
+  }
+
+  setOtherVariables(fen) {
+    // Local stack of captures during a turn (squares + directions)
+    this.captures = [ [] ];
+  }
+
+  static get size() {
+    return { x: 5, y: 9 };
+  }
+
+  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;
+  }
+
+  getPPpath(m, orientation) {
+    // 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 multStep = (orientation == 'w' ? 1 : -1);
+    const normalizedStep = [
+      multStep * step[0] / Math.abs(step[0]),
+      multStep * 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]) {
+    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) {
+    return moves;
+  }
+
+  getCheckSquares() {
+    return [];
+  }
+
+  play(move) {
+    const color = this.turn;
+    move.turn = color; //for undo
+    V.PlayOnBoard(this.board, move);
+    if (move.vanish.length >= 2) {
+      const L0 = this.captures.length;
+      let captures = this.captures[L0 - 1];
+      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)
+        move.notTheEnd = true;
+    }
+    if (!move.notTheEnd) {
+      this.turn = V.GetOppCol(color);
+      this.movesCount++;
+      this.captures.push([]);
+    }
+  }
+
+  undo(move) {
+    V.UndoOnBoard(this.board, move);
+    if (!move.notTheEnd) {
+      this.turn = move.turn;
+      this.movesCount--;
+      this.captures.pop();
+    }
+    if (move.vanish.length >= 2) {
+      const L0 = this.captures.length;
+      let captures = this.captures[L0 - 1];
+      captures.pop();
+    }
+  }
+
+  getCurrentScore() {
+    const color = this.turn;
+    // If no stones on board, I lose
+    if (
+      this.board.every(b => {
+        return b.every(cell => {
+          return (cell == "" || cell[0] != color);
+        });
+      })
+    ) {
+      return (color == 'w' ? "0-1" : "1-0");
+    }
+    return "*";
+  }
+
+  getComputerMove() {
+    const moves = this.getAllValidMoves();
+    if (moves.length == 0) return null;
+    const color = this.turn;
+    // Capture available? If yes, play it
+    let captures = moves.filter(m => m.vanish.length >= 2);
+    let mvArray = [];
+    while (captures.length >= 1) {
+      // Then just pick random captures (trying to maximize)
+      let candidates = captures.filter(c => !!c.notTheEnd);
+      let mv = null;
+      if (candidates.length >= 1) mv = candidates[randInt(candidates.length)];
+      else mv = captures[randInt(captures.length)];
+      this.play(mv);
+      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 does not let a capture
+    let candidates = [];
+    for (let m of moves) {
+      this.play(m);
+      if (!this.atLeastOneCapture()) candidates.push(m);
+      this.undo(m);
+    }
+    if (candidates.length >= 1) return candidates[randInt(candidates.length)];
+    return moves[randInt(moves.length)];
+  }
+
+  getNotation(move) {
+    if (move.appear.length == 0) return "stop";
+    return (
+      V.CoordsToSquare(move.start) +
+      V.CoordsToSquare(move.end) +
+      (move.vanish.length >= 2 ? "X" : "")
+    );
+  }
+
+};
diff --git a/variants/Sleepy/class.js b/variants/Sleepy/class.js
new file mode 100644 (file)
index 0000000..e58ceae
--- /dev/null
@@ -0,0 +1,110 @@
+import ChessRules from "/base_rules.js";
+import PiPo from "/utils/PiPo.js";
+import Move from "/utils/Move.js";
+
+export default class SleepyRules extends ChessRules {
+
+  static get Options() {
+    return {
+      select: C.Options.select,
+      input: {},
+      styles: ["cylinder"] //TODO
+    };
+  }
+
+  setOtherVariables(fenParsed) {
+    super.setOtherVariables(fenParsed);
+    // Stack of "last move" only for intermediate chaining
+    this.lastMoveEnd = [];
+  }
+
+  getBasicMove([sx, sy], [ex, ey], tr) {
+    const L = this.lastMoveEnd.length;
+    const piece = (L >= 1 ? this.lastMoveEnd[L-1].p : null);
+    if (
+      this.board[ex][ey] == "" ||
+      this.getColor(ex, ey) == C.GetOppTurn(this.turn)
+    ) {
+      if (piece && !tr)
+        tr = {c: this.turn, p: piece};
+      let mv = super.getBasicMove([sx, sy], [ex, ey], tr);
+      if (piece)
+        mv.vanish.pop(); //end of a chain: initial piece remains
+      return mv;
+    }
+    // (Self)Capture: initial, or inside a chain
+    const initPiece = (piece || this.getPiece(sx, sy)),
+          destPiece = this.getPiece(ex, ey);
+    let mv = new Move({
+      start: {x: sx, y: sy},
+      end: {x: ex, y: ey},
+      appear: [
+        new PiPo({
+          x: ex,
+          y: ey,
+          c: this.turn,
+          p: (!!tr ? tr.p : initPiece)
+        })
+      ],
+      vanish: [
+        new PiPo({
+          x: ex,
+          y: ey,
+          c: this.turn,
+          p: destPiece
+        })
+      ]
+    });
+    if (!piece) {
+      // Initial capture
+      mv.vanish.unshift(
+        new PiPo({
+          x: sx,
+          y: sy,
+          c: this.turn,
+          p: initPiece
+        })
+      );
+    }
+    mv.chained = destPiece; //easier (no need to detect it)
+//    mv.drag = {c: this.turn, p: initPiece}; //TODO: doesn't work
+    return mv;
+  }
+
+  getPiece(x, y) {
+    const L = this.lastMoveEnd.length;
+    if (L >= 1 && this.lastMoveEnd[L-1].x == x && this.lastMoveEnd[L-1].y == y)
+      return this.lastMoveEnd[L-1].p;
+    return super.getPiece(x, y);
+  }
+
+  getPotentialMovesFrom([x, y], color) {
+    const L = this.lastMoveEnd.length;
+    if (
+      L >= 1 &&
+      (x != this.lastMoveEnd[L-1].x || y != this.lastMoveEnd[L-1].y)
+    ) {
+      // A self-capture was played: wrong square
+      return [];
+    }
+    return super.getPotentialMovesFrom([x, y], color);
+  }
+
+  isLastMove(move) {
+    return !move.chained;
+  }
+
+  postPlay(move) {
+    super.postPlay(move);
+    if (!!move.chained) {
+      this.lastMoveEnd.push({
+        x: move.end.x,
+        y: move.end.y,
+        p: move.chained
+      });
+    }
+    else
+      this.lastMoveEnd = [];
+  }
+
+};
diff --git a/variants/Sleepy/rules.html b/variants/Sleepy/rules.html
new file mode 100644 (file)
index 0000000..f27cd96
--- /dev/null
@@ -0,0 +1,7 @@
+<p>
+  You can "capture" your own pieces, and then move them from the capturing
+  square in the same turn, with potential chaining if the captured unit
+  makes a self-capture too.
+</p>
+
+<p class="author">Benjamin Auder (2021).</p>
diff --git a/variants/Sleepy/style.css b/variants/Sleepy/style.css
new file mode 100644 (file)
index 0000000..a3550bc
--- /dev/null
@@ -0,0 +1 @@
+@import url("/base_pieces.css");