base_rules.js refactoring. draft state (untested)
[xogo.git] / base_rules.js
index c19ee64..ce0de11 100644 (file)
@@ -3,6 +3,21 @@ import { ArrayFun } from "/utils/array.js";
 import PiPo from "/utils/PiPo.js";
 import Move from "/utils/Move.js";
 
+// Helper class for move animation
+class TargetObj {
+
+  constructor(callOnComplete) {
+    this.value = 0;
+    this.target = 0;
+    this.callOnComplete = callOnComplete;
+  }
+  increment() {
+    if (++this.value == this.target)
+      this.callOnComplete();
+  }
+
+};
+
 // NOTE: x coords: top to bottom (white perspective); y: left to right
 // NOTE: ChessRules is aliased as window.C, and variants as window.V
 export default class ChessRules {
@@ -252,7 +267,7 @@ export default class ChessRules {
       parts.push(`"flags":"${flags}"`);
     if (this.hasEnpassant)
       parts.push('"enpassant":"-"');
-    if (this.hasReserve)
+    if (this.hasReserveFen)
       parts.push('"reserve":"000000000000"');
     if (this.options["crazyhouse"])
       parts.push('"ispawn":"-"');
@@ -391,17 +406,21 @@ export default class ChessRules {
     // Fen string fully describes the game state
     if (!o.fen)
       o.fen = this.genRandInitFen(o.seed);
-    const fenParsed = this.parseFen(o.fen);
-    this.board = this.getBoard(fenParsed.position);
-    this.turn = fenParsed.turn;
-    this.movesCount = parseInt(fenParsed.movesCount, 10);
-    this.setOtherVariables(fenParsed);
+    this.re_initFromFen(o.fen);
 
     // Graphical (can use variables defined above)
     this.containerId = o.element;
     this.graphicalInit();
   }
 
+  re_initFromFen(fen, oldBoard) {
+    const fenParsed = this.parseFen(fen);
+    this.board = oldBoard || this.getBoard(fenParsed.position);
+    this.turn = fenParsed.turn;
+    this.movesCount = parseInt(fenParsed.movesCount, 10);
+    this.setOtherVariables(fenParsed);
+  }
+
   // Turn position fen into double array ["wb","wp","bk",...]
   getBoard(position) {
     const rows = position.split("/");
@@ -433,7 +452,6 @@ export default class ChessRules {
       this.initReserves(fenParsed.reserve);
     if (this.options["crazyhouse"])
       this.initIspawn(fenParsed.ispawn);
-    this.subTurn = 1; //may be unused
     if (this.options["teleport"]) {
       this.subTurnTeleport = 1;
       this.captured = null;
@@ -443,6 +461,9 @@ export default class ChessRules {
       this.enlightened = ArrayFun.init(this.size.x, this.size.y, false);
       this.updateEnlightened();
     }
+    this.subTurn = 1; //may be unused
+    if (!this.moveStack) //avoid resetting (unwanted)
+      this.moveStack = [];
   }
 
   updateEnlightened() {
@@ -666,7 +687,8 @@ export default class ChessRules {
           const color = this.getColor(i, j);
           const piece = this.getPiece(i, j);
           this.g_pieces[i][j] = document.createElement("piece");
-          C.AddClass_es(this.g_pieces[i][j], this.pieces()[piece]["class"]);
+          C.AddClass_es(this.g_pieces[i][j],
+                        this.pieces(color, i, j)[piece]["class"]);
           this.g_pieces[i][j].classList.add(C.GetColorClass(color));
           this.g_pieces[i][j].style.width = pieceWidth + "px";
           this.g_pieces[i][j].style.height = pieceWidth + "px";
@@ -734,7 +756,7 @@ export default class ChessRules {
         r_cell.style.height = sqResSize + "px";
         rcontainer.appendChild(r_cell);
         let piece = document.createElement("piece");
-        C.AddClass_es(piece, this.pieces()[p]["class"]);
+        C.AddClass_es(piece, this.pieces(c, c, p)[p]["class"]);
         piece.classList.add(C.GetColorClass(c));
         piece.style.width = "100%";
         piece.style.height = "100%";
@@ -929,7 +951,7 @@ export default class ChessRules {
       if (cd) {
         const move = this.doClick(cd);
         if (move)
-          this.playPlusVisual(move);
+          this.buildMoveStack(move, r);
         else {
           const [x, y] = Object.values(cd);
           if (typeof x != "number")
@@ -983,7 +1005,7 @@ export default class ChessRules {
         if (moves.length >= 2)
           this.showChoices(moves, r);
         else if (moves.length == 1)
-          this.playPlusVisual(moves[0], r);
+          this.buildMoveStack(moves[0], r);
       }
       curPiece.remove();
     };
@@ -1024,7 +1046,7 @@ export default class ChessRules {
     const callback = (m) => {
       chessboard.style.opacity = "1";
       container.removeChild(choices);
-      this.playPlusVisual(m, r);
+      this.buildMoveStack(m, r);
     }
     for (let i=0; i < moves.length; i++) {
       let choice = document.createElement("div");
@@ -1037,7 +1059,8 @@ export default class ChessRules {
       choice.onclick = () => callback(moves[i]);
       const piece = document.createElement("piece");
       const cdisp = moves[i].choice || moves[i].appear[0].p;
-      C.AddClass_es(piece, this.pieces()[cdisp]["class"]);
+      C.AddClass_es(piece,
+        this.pieces(color, moves[i].end.x, moves[i].end.y)[cdisp]["class"]);
       piece.classList.add(C.GetColorClass(color));
       piece.style.width = "100%";
       piece.style.height = "100%";
@@ -1080,8 +1103,9 @@ export default class ChessRules {
   }
 
   // Piece type on square (i,j)
-  getPieceType(i, j) {
-    const p = this.getPiece(i, j);
+  getPieceType(i, j, p) {
+    if (!p)
+      p = this.getPiece(i, j);
     return C.CannibalKings[p] || p; //a cannibal king move as...
   }
 
@@ -1090,11 +1114,6 @@ export default class ChessRules {
     return (color == "w" ? "b" : "w");
   }
 
-  // Can thing on square1 capture (no return) thing on square2?
-  canTake([x1, y1], [x2, y2]) {
-    return (this.getColor(x1, y1) !== this.getColor(x2, y2));
-  }
-
   // Is (x,y) on the chessboard?
   onBoard(x, y) {
     return (x >= 0 && x < this.size.x &&
@@ -1190,8 +1209,31 @@ export default class ChessRules {
     };
   }
 
-  ////////////////////
-  // MOVES GENERATION
+  // NOTE: using special symbols to not interfere with variants' pieces codes
+  static get CannibalKings() {
+    return {
+      "!": "p",
+      "#": "r",
+      "$": "n",
+      "%": "b",
+      "*": "q",
+      "k": "k"
+    };
+  }
+
+  static get CannibalKingCode() {
+    return {
+      "p": "!",
+      "r": "#",
+      "n": "$",
+      "b": "%",
+      "q": "*",
+      "k": "k"
+    };
+  }
+
+  //////////////////////////
+  // MOVES GENERATION UTILS
 
   // For Cylinder: get Y coordinate
   getY(y) {
@@ -1203,36 +1245,65 @@ export default class ChessRules {
     return res;
   }
 
+  // Can thing on square1 capture thing on square2?
+  canTake([x1, y1], [x2, y2]) {
+    return this.getColor(x1, y1) !== this.getColor(x2, y2);
+  }
+
+  canStepOver(i, j, p) {
+    // In some variants, objects on boards don't stop movement (Chakart)
+    return this.board[i][j] == "";
+  }
+
+  // For Madrasi:
+  // (redefined in Baroque etc, where Madrasi condition doesn't make sense)
+  isImmobilized([x, y]) {
+    if (!this.options["madrasi"])
+      return false;
+    const color = this.getColor(x, y);
+    const oppCol = C.GetOppCol(color);
+    const piece = this.getPieceType(x, y); //ok not cannibal king
+    const stepSpec = this.getStepSpec(color, x, y);
+    const attacks = stepSpec.attack || stepSpec.moves;
+    for (let a of attacks) {
+      outerLoop: for (let step of a.steps) {
+        let [i, j] = [x + step[0], y + step[1]];
+        let stepCounter = 1;
+        while (this.onBoard(i, j) && this.board[i][j] == "") {
+          if (a.range <= stepCounter++)
+            continue outerLoop;
+          i += step[0];
+          j = this.getY(j + step[1]);
+        }
+        if (
+          this.onBoard(i, j) &&
+          this.getColor(i, j) == oppCol &&
+          this.getPieceType(i, j) == piece
+        ) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   // Stop at the first capture found
   atLeastOneCapture(color) {
-    color = color || this.turn;
     const oppCol = C.GetOppCol(color);
-    for (let i = 0; i < this.size.x; i++) {
-      for (let j = 0; j < this.size.y; j++) {
-        if (this.board[i][j] != "" && this.getColor(i, j) == color) {
-          const allSpecs = this.pieces(color, i, j)
-          let specs = allSpecs[this.getPieceType(i, j)];
-          const attacks = specs.attack || specs.moves;
-          for (let a of attacks) {
-            outerLoop: for (let step of a.steps) {
-              let [ii, jj] = [i + step[0], this.getY(j + step[1])];
-              let stepCounter = 1;
-              while (this.onBoard(ii, jj) && this.board[ii][jj] == "") {
-                if (a.range <= stepCounter++)
-                  continue outerLoop;
-                ii += step[0];
-                jj = this.getY(jj + step[1]);
-              }
-              if (
-                this.onBoard(ii, jj) &&
-                this.getColor(ii, jj) == oppCol &&
-                this.filterValid(
-                  [this.getBasicMove([i, j], [ii, jj])]
-                ).length >= 1
-              ) {
-                return true;
-              }
-            }
+    const allowed = ([x, y]) => {
+      this.getColor(x, y) == oppCol &&
+      this.filterValid([this.getBasicMove([i, j], [x, y])]).length >= 1
+    };
+    for (let i=0; i<this.size.x; i++) {
+      for (let j=0; j<this.size.y; j++) {
+        if (this.getColor(i, j) == color) {
+          if (
+            (!this.options["zen"] && this.findDestSquares(
+              [i, j], {attackOnly: true, one: true}, allowed).length == 1) ||
+            (this.options["zen"] && this.findCapturesOn(
+              [i, j], {one: true}, allowed).length == 1)
+          ) {
+            return true;
           }
         }
       }
@@ -1240,6 +1311,49 @@ export default class ChessRules {
     return false;
   }
 
+  getSegments(curSeg, segStart, segEnd) {
+    if (curSeg.length == 0)
+      return undefined;
+    let segments = JSON.parse(JSON.stringify(curSeg)); //not altering
+    segments.push([[segStart[0], segStart[1]], [segEnd[0], segEnd[1]]]);
+    return segments;
+  }
+
+  getStepSpec(color, x, y) {
+    const allSpecs = this.pieces(color, x, y);
+    let stepSpec = allSpecs[piece];
+    if (stepSpec.moveas)
+      stepSpec = allSpecs[stepSpec.moveas];
+    return stepSpec;
+  }
+
+  compatibleStep([x1, y1], [x2, y2], step, range) {
+    let shifts = [0];
+    if (this.options["cylinder"])
+      Array.prototype.push.apply(shifts, [-this.size.y, this.size.y]);
+    for (let sh of shifts) {
+      const rx = (x2 - x1) / step[0],
+            ry = (y2 + sh - y1) / step[1];
+      if (
+        (!Number.isFinite(rx) && !Number.isNaN(rx)) ||
+        (!Number.isFinite(ry) && !Number.isNaN(ry))
+      ) {
+        continue;
+      }
+      let distance = (Number.isNaN(rx) ? ry : rx);
+      // TODO: 1e-7 here is totally arbitrary
+      if (Math.abs(distance - Math.round(distance)) > 1e-7)
+        continue;
+      distance = Math.round(distance); //in case of (numerical...)
+      if (range >= distance)
+        return true;
+    }
+    return false;
+  }
+
+  ////////////////////
+  // MOVES GENERATION
+
   getDropMovesFrom([c, p]) {
     // NOTE: by design, this.reserve[c][p] >= 1 on user click
     // (but not necessarily otherwise: atLeastOneMove() etc)
@@ -1308,7 +1422,7 @@ export default class ChessRules {
       moves = this.capturePostProcess(moves, oppCol);
 
     if (this.options["atomic"])
-      this.atomicPostProcess(moves, oppCol);
+      this.atomicPostProcess(moves, color, oppCol);
 
     if (
       moves.length > 0 &&
@@ -1339,7 +1453,7 @@ export default class ChessRules {
     });
   }
 
-  atomicPostProcess(moves, oppCol) {
+  atomicPostProcess(moves, color, oppCol) {
     moves.forEach(m => {
       if (
         this.board[m.end.x][m.end.y] != "" &&
@@ -1356,15 +1470,22 @@ export default class ChessRules {
           [1, 0],
           [1, 1]
         ];
+        let mNext = new Move({
+          start: m.end,
+          end: m.end,
+          appear: [],
+          vanish: []
+        });
         for (let step of steps) {
           let x = m.end.x + step[0];
           let y = this.getY(m.end.y + step[1]);
           if (
             this.onBoard(x, y) &&
             this.board[x][y] != "" &&
+            (x != m.start.x || y != m.start.y) &&
             this.getPieceType(x, y) != "p"
           ) {
-            m.vanish.push(
+            mNext.vanish.push(
               new PiPo({
                 p: this.getPiece(x, y),
                 c: this.getColor(x, y),
@@ -1374,8 +1495,18 @@ export default class ChessRules {
             );
           }
         }
-        if (!this.options["rifle"])
-          m.appear.pop(); //nothing appears
+        if (!this.options["rifle"]) {
+          // The moving piece also vanish
+          mNext.vanish.unshift(
+            new PiPo({
+              x: m.end.x,
+              y: m.end.y,
+              c: color,
+              p: this.getPiece(m.start.x, m.start.y)
+            })
+          );
+        }
+        m.next = mNext;
       }
     });
   }
@@ -1445,114 +1576,125 @@ export default class ChessRules {
     Array.prototype.push.apply(moves, newMoves);
   }
 
-  // NOTE: using special symbols to not interfere with variants' pieces codes
-  static get CannibalKings() {
-    return {
-      "!": "p",
-      "#": "r",
-      "$": "n",
-      "%": "b",
-      "*": "q",
-      "k": "k"
-    };
-  }
-
-  static get CannibalKingCode() {
-    return {
-      "p": "!",
-      "r": "#",
-      "n": "$",
-      "b": "%",
-      "q": "*",
-      "k": "k"
-    };
-  }
-
-  isKing(symbol) {
-    return !!C.CannibalKings[symbol];
-  }
-
-  // For Madrasi:
-  // (redefined in Baroque etc, where Madrasi condition doesn't make sense)
-  isImmobilized([x, y]) {
-    if (!this.options["madrasi"])
-      return false;
+  // Generic method to find possible moves of "sliding or jumping" pieces
+  getPotentialMovesOf(piece, [x, y], color) {
     const color = this.getColor(x, y);
-    const oppCol = C.GetOppCol(color);
-    const piece = this.getPieceType(x, y); //ok not cannibal king
-    const stepSpec = this.pieces(color, x, y)[piece];
-    const attacks = stepSpec.attack || stepSpec.moves;
-    for (let a of attacks) {
-      outerLoop: for (let step of a.steps) {
-        let [i, j] = [x + step[0], y + step[1]];
-        let stepCounter = 1;
-        while (this.onBoard(i, j) && this.board[i][j] == "") {
-          if (a.range <= stepCounter++)
-            continue outerLoop;
-          i += step[0];
-          j = this.getY(j + step[1]);
-        }
-        if (
-          this.onBoard(i, j) &&
-          this.getColor(i, j) == oppCol &&
-          this.getPieceType(i, j) == piece
-        ) {
-          return true;
+    const specialAttack = !!this.getStepSpec(color, x, y).attack;
+    let squares = [];
+    if (specialAttack) {
+      squares = this.findDestSquares(
+        [x, y],
+        {
+          attackOnly: true,
+          segments: this.options["cylinder"]
+        },
+        ([i, j]) => {
+          return (
+            (!this.options["zen"] || this.getPieceType(i, j) == 'k') &&
+            this.canTake([x, y], [i, j])
+          );
         }
+      );
+    }
+    const noSpecials = this.findDestSquares(
+      [x, y],
+      {
+        moveOnly: specialAttack || this.options["zen"],
+        segments: this.options["cylinder"]
+      },
+      ([i, j]) => this.board[i][j] == "" || this.canTake([x, y], [i, j])
+    );
+    Array.prototype.push.apply(squares, noSpecials);
+    if (this.options["zen"]) {
+      let zenCaptures = this.findCapturesOn(
+        [x, y],
+        {},
+        ([i, j]) => this.getPieceType(i, j) != 'k'
+      );
+      // Technical step: segments (if any) are reversed
+      if (this.options["cylinder"]) {
+        zenCaptures.forEach(z => {
+          if (z.segments)
+            z.segments = z.segments.reverse().map(s => s.reverse())
+        });
       }
+      Array.prototype.push.apply(squares, zenCaptures);
     }
-    return false;
-  }
-
-  canStepOver(i, j) {
-    // In some variants, objects on boards don't stop movement (Chakart)
-    return this.board[i][j] == "";
+    if (
+      this.options["recycle"] ||
+      (this.options["teleport"] && this.subTurnTeleport == 1)
+    ) {
+      const selfCaptures = this.findDestSquares(
+        [x, y],
+        {
+          attackOnly: true,
+          segments: this.options["cylinder"]
+        },
+        ([i, j]) =>
+          this.getColor(i, j) == color && this.getPieceType(i, j) != 'k'
+      );
+      Array.prototype.push.apply(squares, selfCaptures);
+    }
+    return squares.map(s => {
+      let mv = this.getBasicMove([x, y], s.sq);
+      if (this.options["cylinder"] && s.segments.length >= 2)
+        mv.segments = s.segments;
+      return mv;
+    });
   }
 
-  // Generic method to find possible moves of "sliding or jumping" pieces
-  getPotentialMovesOf(piece, [x, y]) {
-    const color = this.getColor(x, y);
-    const stepSpec = this.pieces(color, x, y)[piece];
-    let moves = [];
-    // Next 3 for Cylinder mode:
+  findDestSquares([x, y], o, allowed) {
+    if (!allowed)
+      allowed = () => true;
+    const apparentPiece = this.getPiece(x, y); //how it looks
+    let res = [];
+    // Next 3 for Cylinder mode: (unused if !o.segments)
     let explored = {};
     let segments = [];
     let segStart = [];
-
-    const addMove = (start, end) => {
-      let newMove = this.getBasicMove(start, end);
-      if (segments.length > 0) {
-        newMove.segments = JSON.parse(JSON.stringify(segments));
-        newMove.segments.push([[segStart[0], segStart[1]], [end[0], end[1]]]);
-      }
-      moves.push(newMove);
+    const addSquare = ([i, j]) => {
+      let elt = {sq: [i, j]};
+      if (o.segments)
+        elt.segments = this.getSegments(segments, segStart, end);
+      res.push(elt);
     };
-
-    const findAddMoves = (type, stepArray) => {
+    const exploreSteps = (stepArray) => {
       for (let s of stepArray) {
         outerLoop: for (let step of s.steps) {
-          segments = [];
-          segStart = [x, y];
+          if (o.segments) {
+            segments = [];
+            segStart = [x, y];
+          }
           let [i, j] = [x, y];
           let stepCounter = 0;
           while (
             this.onBoard(i, j) &&
-            (this.canStepOver(i, j) || (i == x && j == y))
+            ((i == x && j == y) || this.canStepOver(i, j, apparentPiece))
           ) {
-            if (
-              type != "attack" &&
-              !explored[i + "." + j] &&
-              (i != x || j != y)
-            ) {
+            if (!explored[i + "." + j] && (i != x || j != y))
+            {
               explored[i + "." + j] = true;
-              addMove([x, y], [i, j]);
+              if (
+                allowed([i, j]) &&
+                (
+                  !o.captureTarget ||
+                  (o.captureTarget[0] == i && o.captureTarget[1] == j)
+                )
+              ) {
+                if (o.one && !o.attackOnly)
+                  return true;
+                if (!o.attackOnly)
+                  addSquare(!o.captureTarget ? [i, j] : [x, y]);
+                if (o.captureTarget)
+                  return res[0];
+              }
             }
             if (s.range <= stepCounter++)
               continue outerLoop;
             const oldIJ = [i, j];
             i += step[0];
             j = this.getY(j + step[1]);
-            if (Math.abs(j - oldIJ[1]) > 1) {
+            if (o.segments && Math.abs(j - oldIJ[1]) > 1) {
               // Boundary between segments (cylinder mode)
               segments.push([[segStart[0], segStart[1]], oldIJ]);
               segStart = [i, j];
@@ -1561,106 +1703,84 @@ export default class ChessRules {
           if (!this.onBoard(i, j))
             continue;
           const pieceIJ = this.getPieceType(i, j);
-          if (
-            type != "moveonly" &&
-            !explored[i + "." + j] &&
-            (
-              !this.options["zen"] ||
-              pieceIJ == "k"
-            ) &&
-            (
-              this.canTake([x, y], [i, j]) ||
-              (
-                (this.options["recycle"] || this.options["teleport"]) &&
-                pieceIJ != "k"
-              )
-            )
-          ) {
+          if (!explored[i + "." + j]) {
             explored[i + "." + j] = true;
-            addMove([x, y], [i, j]);
+            if (allowed([i, j])) {
+              if (o.one && !o.moveOnly)
+                return true;
+              if (!o.moveOnly)
+                addSquare(!o.captureTarget ? [i, j] : [x, y]);
+              if (
+                o.captureTarget &&
+                o.captureTarget[0] == i && o.captureTarget[1] == j
+              ) {
+                return res[0];
+              }
+            }
           }
         }
       }
     };
-
-    const specialAttack = !!stepSpec.attack;
-    if (specialAttack)
-      findAddMoves("attack", stepSpec.attack);
-    findAddMoves(specialAttack ? "moveonly" : "all", stepSpec.moves);
-    if (this.options["zen"]) {
-      Array.prototype.push.apply(moves,
-                                 this.findCapturesOn([x, y], {zen: true}));
+    if (o.captureTarget)
+      exploreSteps(o.captureSteps)
+    else {
+      const stepSpec = this.getStepSpec(this.getColor(x, y), x, y);
+      if (!o.attackOnly || !stepSpec.attack)
+        exploreSteps(stepSpec.moves);
+      if (!o.moveOnly && !!stepSpec.attack)
+        exploreSteps(stepSpec.attack);
     }
-    return moves;
+    return o.captureTarget ? null : (o.one ? false : res);
   }
 
   // Search for enemy (or not) pieces attacking [x, y]
-  findCapturesOn([x, y], args) {
-    let moves = [];
-    if (!args.oppCol)
-      args.oppCol = C.GetOppCol(this.getColor(x, y) || this.turn);
+  findCapturesOn([x, y], o, allowed) {
+    if (!allowed)
+      allowed = () => true;
+    let res = [];
+    if (!o.byCol)
+      o.byCol = [C.GetOppCol(this.getColor(x, y) || this.turn)];
     for (let i=0; i<this.size.x; i++) {
       for (let j=0; j<this.size.y; j++) {
+        const colIJ = this.getColor(i, j);
         if (
           this.board[i][j] != "" &&
-          this.getColor(i, j) == args.oppCol &&
+          o.byCol.includes(colIJ) &&
           !this.isImmobilized([i, j])
         ) {
-          if (args.zen && this.isKing(this.getPiece(i, j)))
-            continue; //king not captured in this way
-          const stepSpec =
-            this.pieces(args.oppCol, i, j)[this.getPieceType(i, j)];
+          const apparentPiece = this.getPiece(i, j);
+          // Quick check: does this potential attacker target x,y ?
+          if (this.canStepOver(x, y, apparentPiece))
+            continue;
+          const stepSpec = this.getStepSpec(colIJ, i, j);
           const attacks = stepSpec.attack || stepSpec.moves;
           for (let a of attacks) {
             for (let s of a.steps) {
               // Quick check: if step isn't compatible, don't even try
-              if (!C.CompatibleStep([i, j], [x, y], s, a.range))
+              if (!this.compatibleStep([i, j], [x, y], s, a.range))
                 continue;
               // Finally verify that nothing stand in-between
-              let [ii, jj] = [i + s[0], this.getY(j + s[1])];
-              let stepCounter = 1;
-              while (
-                this.onBoard(ii, jj) &&
-                this.board[ii][jj] == "" &&
-                (ii != x || jj != y) //condition to attack empty squares too
-              ) {
-                ii += s[0];
-                jj = this.getY(jj + s[1]);
-              }
-              if (ii == x && jj == y) {
-                if (args.zen)
-                  // Reverse capture:
-                  moves.push(this.getBasicMove([x, y], [i, j]));
-                else
-                  moves.push(this.getBasicMove([i, j], [x, y]));
-                if (args.one)
-                  return moves; //test for underCheck
+              const newSquare = this.findDestSquares(
+                [i, j],
+                {
+                  captureTarget: [x, y],
+                  captureSteps: [{steps: [s], range: a.range}],
+                  segments: this.options["cylinder"],
+                  attackOnly: true
+                },
+                ([ii, jj]) => this.canTake([ii, jj], [x, y])
+              );
+              if (newSquare)
+                if (o.one)
+                  return true;
+                res.push(newSquare);
               }
             }
           }
         }
       }
     }
-    return moves;
-  }
-
-  static CompatibleStep([x1, y1], [x2, y2], step, range) {
-    const rx = (x2 - x1) / step[0],
-          ry = (y2 - y1) / step[1];
-    if (
-      (!Number.isFinite(rx) && !Number.isNaN(rx)) ||
-      (!Number.isFinite(ry) && !Number.isNaN(ry))
-    ) {
-      return false;
-    }
-    let distance = (Number.isNaN(rx) ? ry : rx);
-    // TODO: 1e-7 here is totally arbitrary
-    if (Math.abs(distance - Math.round(distance)) > 1e-7)
-      return false;
-    distance = Math.round(distance); //in case of (numerical...)
-    if (range < distance)
-      return false;
-    return true;
+    return (one ? false : res);
   }
 
   // Build a regular move from its initial and destination squares.
@@ -1709,7 +1829,7 @@ export default class ChessRules {
       if (this.options["cannibal"] && destColor != initColor) {
         const lastIdx = mv.vanish.length - 1;
         let trPiece = mv.vanish[lastIdx].p;
-        if (this.isKing(this.getPiece(sx, sy)))
+        if (this.getPieceType(sx, sy) == 'k')
           trPiece = C.CannibalKingCode[trPiece];
         if (mv.appear.length >= 1)
           mv.appear[0].p = trPiece;
@@ -1752,8 +1872,15 @@ export default class ChessRules {
       s.y == e.y &&
       Math.abs(s.x - e.x) == 2 &&
       // Next conditions for variants like Atomic or Rifle, Recycle...
-      (move.appear.length > 0 && move.appear[0].p == "p") &&
-      (move.vanish.length > 0 && move.vanish[0].p == "p")
+      (
+        move.appear.length > 0 &&
+        this.getPieceType(0, 0, move.appear[0].p) == "p"
+      )
+      &&
+      (
+        move.vanish.length > 0 &&
+        this.getPieceType(0, 0, move.vanish[0].p) == "p"
+      )
     ) {
       return {
         x: (s.x + e.x) / 2,
@@ -1890,50 +2017,48 @@ export default class ChessRules {
   ////////////////////
   // MOVES VALIDATION
 
-  // Is (king at) given position under check by "oppCol" ?
+  // Is piece (or square) at given position attacked by "oppCol" ?
+  underAttack([x, y], oppCol) {
+    const king = this.getPieceType(x, y) == 'k';
+    return (
+      (
+        (!this.options["zen"] || king) &&
+        this.findCapturesOn([x, y],
+          {oppCol: oppCol, segments: this.options["cylinder"], one: true},
+          ([i, j]) => this.canTake([i, j], [x, y])).length == 1
+      )
+      ||
+      (
+        (this.options["zen"] && !king) &&
+        this.findDestSquares([x, y],
+          {attackOnly: true, segments: this.options["cylinder"], one: true},
+          ([i, j]) => this.canTake([i, j], [x, y])).length == 1
+      )
+    );
+  }
+
   underCheck([x, y], oppCol) {
     if (this.options["taking"] || this.options["dark"])
       return false;
-    return (
-      this.findCapturesOn([x, y], {oppCol: oppCol, one: true}).length >= 1
-    );
+    return this.underAttack([x, y], oppCol);
   }
 
   // Stop at first king found (TODO: multi-kings)
   searchKingPos(color) {
     for (let i=0; i < this.size.x; i++) {
       for (let j=0; j < this.size.y; j++) {
-        if (this.getColor(i, j) == color && this.isKing(this.getPiece(i, j)))
+        if (this.getColor(i, j) == color && this.getPieceType(i, j) == 'k')
           return [i, j];
       }
     }
     return [-1, -1]; //king not found
   }
 
-  // Some variants (e.g. Refusal) may need to check opponent moves too
+  // 'color' arg because some variants (e.g. Refusal) check opponent moves
   filterValid(moves, color) {
-    if (moves.length == 0)
-      return [];
     if (!color)
       color = this.turn;
     const oppCol = C.GetOppCol(color);
-    if (this.options["balance"] && [1, 3].includes(this.movesCount)) {
-      // Forbid moves either giving check or exploding opponent's king:
-      const oppKingPos = this.searchKingPos(oppCol);
-      moves = moves.filter(m => {
-        if (
-          m.vanish.some(v => v.c == oppCol && v.p == "k") &&
-          m.appear.every(a => a.c != oppCol || a.p != "k")
-        )
-          return false;
-        this.playOnBoard(m);
-        const res = !this.underCheck(oppKingPos, color);
-        this.undoOnBoard(m);
-        return res;
-      });
-    }
-    if (this.options["taking"] || this.options["dark"])
-      return moves;
     const kingPos = this.searchKingPos(color);
     let filtered = {}; //avoid re-checking similar moves (promotions...)
     return moves.filter(m => {
@@ -1943,12 +2068,12 @@ export default class ChessRules {
         let square = kingPos,
             res = true; //a priori valid
         if (m.vanish.some(v => {
-          return C.CannibalKings[v.p] && v.c == color;
+          return this.getPieceType(0, 0, v.p) == 'k' && v.c == color;
         })) {
           // Search king in appear array:
           const newKingIdx =
             m.appear.findIndex(a => {
-              return C.CannibalKings[a.p] && a.c == color;
+              return this.getPieceType(0, 0, a.p) == 'k' && a.c == color;
             });
           if (newKingIdx >= 0)
             square = [m.appear[newKingIdx].x, m.appear[newKingIdx].y];
@@ -2086,14 +2211,13 @@ export default class ChessRules {
 
   postPlay(move) {
     const color = this.turn;
-    const oppCol = C.GetOppCol(color);
     if (this.options["dark"])
       this.updateEnlightened();
     if (this.options["teleport"]) {
       if (
         this.subTurnTeleport == 1 &&
         move.vanish.length > move.appear.length &&
-        move.vanish[move.vanish.length - 1].c == color
+        move.vanish[1].c == color
       ) {
         const v = move.vanish[move.vanish.length - 1];
         this.captured = {x: v.x, y: v.y, c: v.c, p: v.p};
@@ -2103,35 +2227,40 @@ export default class ChessRules {
       this.subTurnTeleport = 1;
       this.captured = null;
     }
-    if (this.options["balance"]) {
-      if (![1, 3].includes(this.movesCount))
-        this.turn = oppCol;
-    }
-    else {
-      if (
-        (
-          this.options["doublemove"] &&
-          this.movesCount >= 1 &&
-          this.subTurn == 1
-        ) ||
-        (this.options["progressive"] && this.subTurn <= this.movesCount)
-      ) {
-        const oppKingPos = this.searchKingPos(oppCol);
-        if (
-          oppKingPos[0] >= 0 &&
-          (
-            this.options["taking"] ||
-            !this.underCheck(oppKingPos, color)
-          )
-        ) {
-          this.subTurn++;
-          return;
-        }
-      }
+    if (this.isLastMove(move)) {
       this.turn = oppCol;
+      this.movesCount++;
+      this.subTurn = 1;
     }
-    this.movesCount++;
-    this.subTurn = 1;
+    else if (!move.next)
+      this.subTurn++;
+  }
+
+  isLastMove(move) {
+    if (move.next)
+      return false;
+    const color = this.turn;
+    const oppCol = C.GetOppCol(color);
+    const oppKingPos = this.searchKingPos(oppCol);
+    if (oppKingPos[0] < 0 || this.underCheck(oppKingPos, color))
+      return true;
+    return (
+      (
+        !this.options["balance"] ||
+        ![1, 3].includes(this.movesCount)
+      )
+      &&
+      (
+        !this.options["doublemove"] ||
+        this.movesCount == 0 ||
+        this.subTurn == 2
+      )
+      &&
+      (
+        !this.options["progressive"] ||
+        this.subTurn == this.movesCount + 1
+      )
+    );
   }
 
   // "Stop at the first move found"
@@ -2190,7 +2319,8 @@ export default class ChessRules {
     const pieceWidth = this.getPieceWidth(r.width);
     move.appear.forEach(a => {
       this.g_pieces[a.x][a.y] = document.createElement("piece");
-      C.AddClass_es(this.g_pieces[a.x][a.y], this.pieces()[a.p]["class"]);
+      C.AddClass_es(this.g_pieces[a.x][a.y],
+                    this.pieces(a.c, a.x, a.y)[a.p]["class"]);
       this.g_pieces[a.x][a.y].classList.add(C.GetColorClass(a.c));
       this.g_pieces[a.x][a.y].style.width = pieceWidth + "px";
       this.g_pieces[a.x][a.y].style.height = pieceWidth + "px";
@@ -2206,12 +2336,41 @@ export default class ChessRules {
       this.graphUpdateEnlightened();
   }
 
-  playPlusVisual(move, r) {
+  // TODO: send stack receive stack, or allow incremental? (good/bad points)
+  buildMoveStack(move, r) {
+    this.moveStack.push(move);
+    this.computeNextMove(move);
     this.play(move);
-    this.playVisual(move, r);
-    this.afterPlay(move); //user method
+    const newTurn = this.turn;
+    if (this.moveStack.length == 1)
+      this.playVisual(move, r);
+    if (move.next) {
+      this.gameState = {
+        fen: this.getFen(),
+        board: JSON.parse(JSON.stringify(this.board)) //easier
+      };
+      this.buildMoveStack(move.next, r);
+    }
+    else {
+      if (this.moveStack.length == 1) {
+        // Usual case (one normal move)
+        this.afterPlay(this.moveStack, newTurn, {send: true, res: true});
+        this.moveStack = []
+      }
+      else {
+        this.afterPlay(this.moveStack, newTurn, {send: true, res: false});
+        this.re_initFromFen(this.gameState.fen, this.gameState.board);
+        this.playReceivedMove(this.moveStack.slice(1), () => {
+          this.afterPlay(this.moveStack, newTurn, {send: false, res: true});
+          this.moveStack = []
+        });
+      }
+    }
   }
 
+  // Implemented in variants using (automatic) moveStack
+  computeNextMove(move) {}
+
   getMaxDistance(r) {
     // Works for all rectangular boards:
     return Math.sqrt(r.width ** 2 + r.height ** 2);
@@ -2221,41 +2380,37 @@ export default class ChessRules {
     return (typeof x == "string" ? this.r_pieces : this.g_pieces)[x][y];
   }
 
-  animate(move, callback) {
-    if (this.noAnimate || move.noAnimate) {
-      callback();
-      return;
-    }
-    let initPiece = this.getDomPiece(move.start.x, move.start.y);
-    // NOTE: cloning generally not required, but light enough, and simpler
+  animateMoving(start, end, drag, segments, cb) {
+    let initPiece = this.getDomPiece(start.x, start.y);
+    // NOTE: cloning often not required, but light enough, and simpler
     let movingPiece = initPiece.cloneNode();
     initPiece.style.opacity = "0";
     let container =
       document.getElementById(this.containerId)
     const r = container.querySelector(".chessboard").getBoundingClientRect();
-    if (typeof move.start.x == "string") {
+    if (typeof start.x == "string") {
       // Need to bound width/height (was 100% for reserve pieces)
       const pieceWidth = this.getPieceWidth(r.width);
       movingPiece.style.width = pieceWidth + "px";
       movingPiece.style.height = pieceWidth + "px";
     }
     const maxDist = this.getMaxDistance(r);
-    const pieces = this.pieces();
-    if (move.drag) {
-      const startCode = this.getPiece(move.start.x, move.start.y);
+    const apparentColor = this.getColor(start.x, start.y);
+    const pieces = this.pieces(apparentColor, start.x, start.y);
+    if (drag) {
+      const startCode = this.getPiece(start.x, start.y);
       C.RemoveClass_es(movingPiece, pieces[startCode]["class"]);
-      C.AddClass_es(movingPiece, pieces[move.drag.p]["class"]);
-      const apparentColor = this.getColor(move.start.x, move.start.y);
-      if (apparentColor != move.drag.c) {
+      C.AddClass_es(movingPiece, pieces[drag.p]["class"]);
+      if (apparentColor != drag.c) {
         movingPiece.classList.remove(C.GetColorClass(apparentColor));
-        movingPiece.classList.add(C.GetColorClass(move.drag.c));
+        movingPiece.classList.add(C.GetColorClass(drag.c));
       }
     }
     container.appendChild(movingPiece);
     const animateSegment = (index, cb) => {
       // NOTE: move.drag could be generalized per-segment (usage?)
-      const [i1, j1] = move.segments[index][0];
-      const [i2, j2] = move.segments[index][1];
+      const [i1, j1] = segments[index][0];
+      const [i2, j2] = segments[index][1];
       const dep = this.getPixelPosition(i1, j1, r);
       const arr = this.getPixelPosition(i2, j2, r);
       movingPiece.style.transitionDuration = "0s";
@@ -2271,24 +2426,64 @@ export default class ChessRules {
         setTimeout(cb, duration * 1000);
       }, 50);
     };
-    if (!move.segments) {
-      move.segments = [
-        [[move.start.x, move.start.y], [move.end.x, move.end.y]]
-      ];
-    }
     let index = 0;
     const animateSegmentCallback = () => {
-      if (index < move.segments.length)
+      if (index < segments.length)
         animateSegment(index++, animateSegmentCallback);
       else {
         movingPiece.remove();
         initPiece.style.opacity = "1";
-        callback();
+        cb();
       }
     };
     animateSegmentCallback();
   }
 
+  // Input array of objects with at least fields x,y (e.g. PiPo)
+  animateFading(arr, cb) {
+    const animLength = 350; //TODO: 350ms? More? Less?
+    arr.forEach(v => {
+      let fadingPiece = this.getDomPiece(v.x, v.y);
+      fadingPiece.style.transitionDuration = (animLength / 1000) + "s";
+      fadingPiece.style.opacity = "0";
+    });
+    setTimeout(cb, animLength);
+  }
+
+  animate(move, callback) {
+    if (this.noAnimate || move.noAnimate) {
+      callback();
+      return;
+    }
+    let segments = move.segments;
+    if (!segments)
+      segments = [ [[move.start.x, move.start.y], [move.end.x, move.end.y]] ];
+    let targetObj = new TargetObj(callback);
+    if (move.start.x != move.end.x || move.start.y != move.end.y) {
+      targetObj.target++;
+      this.animateMoving(move.start, move.end, move.drag, segments,
+                         () => targetObj.increment());
+    }
+    if (move.vanish.length > move.appear.length) {
+      const arr = move.vanish.slice(move.appear.length)
+        // Ignore disappearing pieces hidden by some appearing ones:
+        .filter(v => move.appear.every(a => a.x != v.x || a.y != v.y));
+      if (arr.length > 0) {
+        targetObj.target++;
+        this.animateFading(arr, () => targetObj.increment());
+      }
+    }
+    targetObj.target +=
+      this.customAnimate(move, segments, () => targetObj.increment());
+    if (targetObj.target == 0)
+      callback();
+  }
+
+  // Potential other animations (e.g. for Suction variant)
+  customAnimate(move, segments, cb) {
+    return 0; //nb of targets
+  }
+
   playReceivedMove(moves, callback) {
     const launchAnimation = () => {
       const r = container.querySelector(".chessboard").getBoundingClientRect();