Support Xiangqi chasing rules
authorFabian Fichter <ianfab@users.noreply.github.com>
Wed, 27 Apr 2022 21:27:36 +0000 (23:27 +0200)
committerFabian Fichter <ianfab@users.noreply.github.com>
Thu, 28 Apr 2022 07:49:19 +0000 (09:49 +0200)
Add basic support for AXF chasing rules.
Some of the more complex cases are not handled yet.

Closes #55.

src/bitboard.h
src/parser.cpp
src/position.cpp
src/position.h
src/types.h
src/variant.cpp
src/variant.h
src/variants.ini
test.py
tests/js/test.js

index 6be323e..60a57a4 100644 (file)
@@ -195,6 +195,11 @@ constexpr bool more_than_one(Bitboard b) {
   return b & (b - 1);
 }
 
+
+inline Bitboard undo_move_board(Bitboard b, Move m) {
+  return (from_sq(m) != SQ_NONE && (b & to_sq(m))) ? (b ^ to_sq(m)) | from_sq(m) : b;
+}
+
 /// board_size_bb() returns a bitboard representing all the squares
 /// on a board with given size.
 
index 5ac3596..151e294 100644 (file)
@@ -95,6 +95,12 @@ namespace {
         return value == "makruk" || value == "asean" || value == "none";
     }
 
+    template <> bool set(const std::string& value, ChasingRule& target) {
+        target =  value == "axf"  ? AXF_CHASING
+                : NO_CHASING;
+        return value == "axf" || value == "none";
+    }
+
     template <> bool set(const std::string& value, EnclosingRule& target) {
         target =  value == "reversi"  ? REVERSI
                 : value == "ataxx" ? ATAXX
@@ -129,6 +135,8 @@ template <class T> void VariantParser<DoCheck>::parse_attribute(const std::strin
                                   : std::is_same<T, Value>() ? "Value"
                                   : std::is_same<T, MaterialCounting>() ? "MaterialCounting"
                                   : std::is_same<T, CountingRule>() ? "CountingRule"
+                                  : std::is_same<T, ChasingRule>() ? "ChasingRule"
+                                  : std::is_same<T, EnclosingRule>() ? "EnclosingRule"
                                   : std::is_same<T, Bitboard>() ? "Bitboard"
                                   : typeid(T).name();
             std::cerr << key << " - Invalid value " << it->second << " for type " << typeName << std::endl;
@@ -335,6 +343,7 @@ Variant* VariantParser<DoCheck>::parse(Variant* v) {
     parse_attribute("nFoldValueAbsolute", v->nFoldValueAbsolute);
     parse_attribute("perpetualCheckIllegal", v->perpetualCheckIllegal);
     parse_attribute("moveRepetitionIllegal", v->moveRepetitionIllegal);
+    parse_attribute("chasingRule", v->chasingRule);
     parse_attribute("stalemateValue", v->stalemateValue);
     parse_attribute("stalematePieceCount", v->stalematePieceCount);
     parse_attribute("checkmateValue", v->checkmateValue);
index 96b4dfb..7009ff6 100644 (file)
@@ -96,6 +96,10 @@ std::ostream& operator<<(std::ostream& os, const Position& pos) {
   for (Bitboard b = pos.checkers(); b; )
       os << UCI::square(pos, pop_lsb(b)) << " ";
 
+  os << "\nChased: ";
+  for (Bitboard b = pos.state()->chased; b; )
+      os << UCI::square(pos, pop_lsb(b)) << " ";
+
   if (    int(Tablebases::MaxCardinality) >= popcount(pos.pieces())
       && Options["UCI_Variant"] == "chess"
       && !pos.can_castle(ANY_CASTLING))
@@ -538,6 +542,7 @@ void Position::set_check_info(StateInfo* si) const {
   }
   si->shak = si->checkersBB & (byTypeBB[KNIGHT] | byTypeBB[ROOK] | byTypeBB[BERS]);
   si->bikjang = var->bikjangRule && ksq != SQ_NONE ? bool(attacks_bb(sideToMove, ROOK, ksq, pieces()) & pieces(sideToMove, KING)) : false;
+  si->chased = var->chasingRule ? chased() : Bitboard(0);
   si->legalCapture = NO_VALUE;
   if (var->extinctionPseudoRoyal)
   {
@@ -564,6 +569,7 @@ void Position::set_state(StateInfo* si) const {
   si->pawnKey = Zobrist::noPawns;
   si->nonPawnMaterial[WHITE] = si->nonPawnMaterial[BLACK] = VALUE_ZERO;
   si->checkersBB = count<KING>(sideToMove) ? attackers_to(square<KING>(sideToMove), ~sideToMove) : Bitboard(0);
+  si->move = MOVE_NONE;
 
   set_check_info(si);
 
@@ -784,10 +790,14 @@ Bitboard Position::slider_blockers(Bitboard sliders, Square s, Bitboard& pinners
 
   // Snipers are sliders that attack 's' when a piece and other snipers are removed
   Bitboard snipers = 0;
+  Bitboard slidingSnipers = 0;
 
   if (var->fastAttacks)
+  {
       snipers = (  (attacks_bb<  ROOK>(s) & pieces(c, QUEEN, ROOK, CHANCELLOR))
                  | (attacks_bb<BISHOP>(s) & pieces(c, QUEEN, BISHOP, ARCHBISHOP))) & sliders;
+      slidingSnipers = snipers;
+  }
   else
       for (PieceType pt : piece_types())
       {
@@ -807,16 +817,19 @@ Bitboard Position::slider_blockers(Bitboard sliders, Square s, Bitboard& pinners
               }
               else
                   snipers |= b & ~attacks_bb(~c, pt, s, pieces());
+              if (AttackRiderTypes[pt] & ~HOPPING_RIDERS)
+                  slidingSnipers |= snipers & pieces(pt);
           }
       }
-  Bitboard occupancy = pieces() ^ snipers;
+  Bitboard occupancy = pieces() ^ slidingSnipers;
 
   while (snipers)
   {
     Square sniperSq = pop_lsb(snipers);
-    Bitboard b = between_bb(s, sniperSq, type_of(piece_on(sniperSq))) & occupancy;
+    bool isHopper = AttackRiderTypes[type_of(piece_on(sniperSq))] & HOPPING_RIDERS;
+    Bitboard b = between_bb(s, sniperSq, type_of(piece_on(sniperSq))) & (isHopper ? (pieces() ^ sniperSq) : occupancy);
 
-    if (b && (!more_than_one(b) || ((AttackRiderTypes[type_of(piece_on(sniperSq))] & HOPPING_RIDERS) && popcount(b) == 2)))
+    if (b && (!more_than_one(b) || (isHopper && popcount(b) == 2)))
     {
         // Janggi cannons block each other
         if ((pieces(JANGGI_CANNON) & sniperSq) && (pieces(JANGGI_CANNON) & b))
@@ -2316,6 +2329,8 @@ bool Position::is_optional_game_end(Value& result, int ply, int countStarted) co
           int cnt = 0;
           bool perpetualThem = st->checkersBB && stp->checkersBB;
           bool perpetualUs = st->previous->checkersBB && stp->previous->checkersBB;
+          Bitboard chaseThem = undo_move_board(st->chased, st->previous->move) & stp->chased;
+          Bitboard chaseUs = undo_move_board(st->previous->chased, stp->move) & stp->previous->chased;
           int moveRepetition = var->moveRepetitionIllegal
                                && type_of(st->move) == NORMAL
                                && !st->previous->checkersBB && !stp->previous->checkersBB
@@ -2348,6 +2363,9 @@ bool Position::is_optional_game_end(Value& result, int ply, int countStarted) co
                           moveRepetition = 0;
                   }
               }
+              // Chased pieces are empty when there is no previous move
+              if (i != st->pliesFromNull)
+                  chaseThem = undo_move_board(chaseThem, stp->previous->move) & stp->previous->previous->chased;
               stp = stp->previous->previous;
               perpetualThem &= bool(stp->checkersBB);
 
@@ -2356,8 +2374,8 @@ bool Position::is_optional_game_end(Value& result, int ply, int countStarted) co
               if (   stp->key == st->key
                   && ++cnt + 1 == (ply > i && !var->moveRepetitionIllegal ? 2 : n_fold_rule()))
               {
-                  result = convert_mate_value(  var->perpetualCheckIllegal && perpetualThem ? VALUE_MATE
-                                              : var->perpetualCheckIllegal && perpetualUs ? -VALUE_MATE
+                  result = convert_mate_value(  var->perpetualCheckIllegal && (perpetualThem || perpetualUs) ? (!perpetualUs ? VALUE_MATE : !perpetualThem ? -VALUE_MATE : VALUE_DRAW)
+                                              : var->chasingRule && (chaseThem || chaseUs) ? (!chaseUs ? VALUE_MATE : !chaseThem ? -VALUE_MATE : VALUE_DRAW)
                                               : var->nFoldValueAbsolute && sideToMove == BLACK ? -var->nFoldValue
                                               : var->nFoldValue, ply);
                   if (result == VALUE_DRAW && var->materialCounting)
@@ -2366,7 +2384,10 @@ bool Position::is_optional_game_end(Value& result, int ply, int countStarted) co
               }
 
               if (i + 1 <= end)
+              {
                   perpetualUs &= bool(stp->previous->checkersBB);
+                  chaseUs = undo_move_board(chaseUs, stp->move) & stp->previous->chased;
+              }
           }
       }
   }
@@ -2507,6 +2528,126 @@ bool Position::is_immediate_game_end(Value& result, int ply) const {
   return false;
 }
 
+// Position::chased() tests whether the last move was a chase.
+
+Bitboard Position::chased() const {
+  Bitboard b = 0;
+  if (st->move == MOVE_NONE)
+      return b;
+
+  Bitboard pins = blockers_for_king(sideToMove);
+  if (var->flyingGeneral)
+  {
+      Bitboard kingFilePieces = file_bb(file_of(square<KING>(~sideToMove))) & pieces(sideToMove);
+      if ((kingFilePieces & pieces(sideToMove, KING)) && !more_than_one(kingFilePieces & ~pieces(KING)))
+          pins |= kingFilePieces & ~pieces(KING);
+  }
+  auto addChased = [&](Square attackerSq, PieceType attackerType, Bitboard attacks) {
+      if (attacks & ~b)
+      {
+          // Exclude attacks on unpromoted soldiers and checks
+          attacks &= ~(pieces(sideToMove, KING, SOLDIER) ^ promoted_soldiers(sideToMove));
+          // Attacks against stronger pieces
+          if (attackerType == HORSE || attackerType == CANNON)
+              b |= attacks & pieces(sideToMove, ROOK);
+          if (attackerType == ELEPHANT || attackerType == FERS)
+              b |= attacks & pieces(sideToMove, ROOK, CANNON, HORSE);
+          // Exclude mutual/symmetric attacks
+          // Exceptions:
+          // - asymmetric pieces ("impaired horse")
+          // - pins
+          if (attackerType == HORSE && (PseudoAttacks[WHITE][FERS][attackerSq] & pieces()))
+          {
+              Bitboard horses = attacks & pieces(sideToMove, attackerType);
+              while (horses)
+              {
+                  Square s = pop_lsb(horses);
+                  if (attacks_bb(sideToMove, attackerType, s, pieces()) & attackerSq)
+                      attacks ^= s;
+              }
+          }
+          else
+              attacks &= ~pieces(sideToMove, attackerType) | pins;
+          // Attacks against potentially unprotected pieces
+          while (attacks)
+          {
+              Square s = pop_lsb(attacks);
+              Bitboard roots = attackers_to(s, pieces() ^ attackerSq, sideToMove) & ~pins;
+              if (!roots || (var->flyingGeneral && roots == pieces(sideToMove, KING) && (attacks_bb(sideToMove, ROOK, square<KING>(~sideToMove), pieces() ^ attackerSq) & s)))
+                  b |= s;
+          }
+      }
+  };
+
+  // Direct attacks
+  Square from = from_sq(st->move);
+  Square to = to_sq(st->move);
+  PieceType movedPiece = type_of(piece_on(to));
+  if (movedPiece != KING && movedPiece != SOLDIER)
+  {
+      Bitboard directAttacks = attacks_from(~sideToMove, movedPiece, to) & pieces(sideToMove);
+      // Only new attacks count. This avoids expensive comparison of previous and new attacks.
+      if (movedPiece == ROOK || movedPiece == CANNON)
+          directAttacks &= ~line_bb(from, to);
+      addChased(to, movedPiece, directAttacks);
+  }
+
+  // Discovered attacks
+  Bitboard discoveryCandidates =  (PseudoAttacks[WHITE][WAZIR][from] & pieces(~sideToMove, HORSE))
+                                | (PseudoAttacks[WHITE][FERS][from] & pieces(~sideToMove, ELEPHANT))
+                                | (PseudoAttacks[WHITE][ROOK][from] & pieces(~sideToMove, CANNON, ROOK))
+                                | (PseudoAttacks[WHITE][ROOK][to] & pieces(~sideToMove, CANNON));
+  while (discoveryCandidates)
+  {
+      Square s = pop_lsb(discoveryCandidates);
+      PieceType discoveryPiece = type_of(piece_on(s));
+      Bitboard discoveries =   pieces(sideToMove)
+                            &  attacks_bb(~sideToMove, discoveryPiece, s, pieces())
+                            & ~attacks_bb(~sideToMove, discoveryPiece, s, (captured_piece() ? pieces() : pieces() ^ to) ^ from);
+      addChased(s, discoveryPiece, discoveries);
+  }
+
+  // Changes in real roots and discovered checks
+  if (st->pliesFromNull > 0)
+  {
+      // Fake roots
+      Bitboard newPins = st->blockersForKing[sideToMove] & ~st->previous->blockersForKing[sideToMove] & pieces(sideToMove);
+      while (newPins)
+      {
+          Square s = pop_lsb(newPins);
+          PieceType pinnedPiece = type_of(piece_on(s));
+          Bitboard fakeRooted =  pieces(sideToMove)
+                               & ~(pieces(sideToMove, KING, SOLDIER) ^ promoted_soldiers(sideToMove))
+                               & attacks_bb(sideToMove, pinnedPiece, s, pieces());
+          while (fakeRooted)
+          {
+              Square s2 = pop_lsb(fakeRooted);
+              if (attackers_to(s2, ~sideToMove) & ~blockers_for_king(~sideToMove))
+                  b |= s2;
+          }
+      }
+      // Discovered checks
+      Bitboard newDiscoverers = st->blockersForKing[sideToMove] & ~st->previous->blockersForKing[sideToMove] & pieces(~sideToMove);
+      while (newDiscoverers)
+      {
+          Square s = pop_lsb(newDiscoverers);
+          PieceType discoveryPiece = type_of(piece_on(s));
+          Bitboard discoveryAttacks = attacks_from(~sideToMove, discoveryPiece, s) & pieces(sideToMove);
+          // Include all captures except where the king can pseudo-legally recapture
+          b |= discoveryAttacks & ~attacks_from(sideToMove, KING, square<KING>(sideToMove));
+          // Include captures where king can not legally recapture
+          discoveryAttacks &= attacks_from(sideToMove, KING, square<KING>(sideToMove));
+          while (discoveryAttacks)
+          {
+              Square s2 = pop_lsb(discoveryAttacks);
+              if (attackers_to(s2, pieces() ^ s ^ square<KING>(sideToMove), ~sideToMove) & ~square_bb(s))
+                  b |= s2;
+          }
+      }
+  }
+
+  return b;
+}
 
 // Position::has_repeated() tests whether there has been at least one repetition
 // of positions since the last capture or pawn move.
index 3d232bd..569281d 100644 (file)
@@ -75,6 +75,7 @@ struct StateInfo {
   bool       capturedpromoted;
   bool       shak;
   bool       bikjang;
+  Bitboard   chased;
   bool       pass;
   Move       move;
   int        repetition;
@@ -298,6 +299,7 @@ public:
   bool is_draw(int ply) const;
   bool has_game_cycle(int ply) const;
   bool has_repeated() const;
+  Bitboard chased() const;
   int counting_limit() const;
   int counting_ply(int countStarted) const;
   int rule50_count() const;
index 43d79ad..084b492 100644 (file)
@@ -296,6 +296,10 @@ enum CountingRule {
   NO_COUNTING, MAKRUK_COUNTING, ASEAN_COUNTING
 };
 
+enum ChasingRule {
+  NO_CHASING, AXF_CHASING
+};
+
 enum EnclosingRule {
   NO_ENCLOSING, REVERSI, ATAXX
 };
index 5259a41..2baac0f 100644 (file)
@@ -1261,7 +1261,8 @@ namespace {
 #endif
     // Xiangqi (Chinese chess)
     // https://en.wikipedia.org/wiki/Xiangqi
-    Variant* xiangqi_variant() {
+    // Xiangqi base variant for inheriting rules without chasing rules
+    Variant* xiangqi_variant_base() {
         Variant* v = minixiangqi_variant()->init();
         v->pieceToCharTable = "PN.R.AB..K.C..........pn.r.ab..k.c..........";
         v->maxRank = RANK_10;
@@ -1278,11 +1279,16 @@ namespace {
         v->soldierPromotionRank = RANK_6;
         return v;
     }
+    Variant* xiangqi_variant() {
+        Variant* v = xiangqi_variant_base()->init();
+        v->chasingRule = AXF_CHASING;
+        return v;
+    }
     // Manchu/Yitong chess
     // Asymmetric Xiangqi variant with a super-piece
     // https://en.wikipedia.org/wiki/Manchu_chess
     Variant* manchu_variant() {
-        Variant* v = xiangqi_variant()->init();
+        Variant* v = xiangqi_variant_base()->init();
         v->pieceToCharTable = "PN.R.AB..K.C....M.....pn.r.ab..k.c..........";
         v->add_piece(BANNER, 'm');
         v->startFen = "rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/9/9/M1BAKAB2 w - - 0 1";
@@ -1291,7 +1297,7 @@ namespace {
     // Supply chess
     // https://en.wikipedia.org/wiki/Xiangqi#Variations
     Variant* supply_variant() {
-        Variant* v = xiangqi_variant()->init();
+        Variant* v = xiangqi_variant_base()->init();
         v->variantTemplate = "bughouse";
         v->startFen = "rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR[] w - - 0 1";
         v->twoBoards = true;
@@ -1305,7 +1311,7 @@ namespace {
     // https://en.wikipedia.org/wiki/Janggi
     // Official tournament rules with bikjang and material counting.
     Variant* janggi_variant() {
-        Variant* v = xiangqi_variant()->init();
+        Variant* v = xiangqi_variant_base()->init();
         v->variantTemplate = "janggi";
         v->pieceToCharTable = ".N.R.AB.P..C.........K.n.r.ab.p..c.........k";
         v->remove_piece(FERS);
index ffbbcbf..ebb9876 100644 (file)
@@ -113,6 +113,7 @@ struct Variant {
   bool nFoldValueAbsolute = false;
   bool perpetualCheckIllegal = false;
   bool moveRepetitionIllegal = false;
+  ChasingRule chasingRule = NO_CHASING;
   Value stalemateValue = VALUE_DRAW;
   bool stalematePieceCount = false; // multiply stalemate value by sign(count(~stm) - count(stm))
   Value checkmateValue = -VALUE_MATE;
index 7683cfa..ac2348d 100644 (file)
 # [Value]: game result for the side to move [win, loss, draw]
 # [MaterialCounting]: material couting rules for adjudication [janggi, unweighted, whitedrawodds, blackdrawodds, none]
 # [CountingRule]: makruk or ASEAN counting rules [makruk, asean, none]
+# [ChasingRule]: xiangqi chasing rules [axf, none]
 # [EnclosingRule]: reversi or ataxx enclosing rules [reversi, ataxx, none]
 
 ### Additional options relevant for usage in Winboard/XBoard
 # nFoldValueAbsolute: result in case of 3/n-fold repetition is from white's point of view [bool] (default: false)
 # perpetualCheckIllegal: prohibit perpetual checks [bool] (default: false)
 # moveRepetitionIllegal: prohibit moving back and forth with the same piece nFoldRule-1 times [bool] (default: false)
+# chasingRule: enable chasing rules [ChasingRule] (default: none)
 # stalemateValue: result in case of stalemate [Value] (default: draw)
 # stalematePieceCount: count material in case of stalemate [bool] (default: false)
 # checkmateValue: result in case of checkmate [Value] (default: loss)
diff --git a/test.py b/test.py
index 2996862..6b9cb91 100644 (file)
--- a/test.py
+++ b/test.py
@@ -779,6 +779,132 @@ class TestPyffish(unittest.TestCase):
         self.assertTrue(result[0])
         self.assertEqual(result[1], sf.VALUE_DRAW)
 
+        # Xiangqi chasing rules
+        # Also see http://www.asianxiangqi.org/English/AXF_rules_Eng.pdf
+        # Direct chase by cannon
+        result = sf.is_optional_game_end("xiangqi", "2bakabnr/9/r1n1c4/2p1p1p1p/PP7/9/4P1P1P/2C3NC1/9/1NBAKAB1R w - - 0 1", ["c3a3", "a8b8", "a3b3", "b8a8", "b3a3", "a8b8", "a3b3", "b8a8", "b3a3"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Chase with chasing side to move
+        result = sf.is_optional_game_end("xiangqi", "2bakabnr/9/r1n1c4/2p1p1p1p/PP7/9/4P1P1P/2C3NC1/9/1NBAKAB1R w - - 0 1", ["c3a3", "a8b8", "a3b3", "b8a8", "b3a3", "a8b8", "a3b3", "b8a8", "b3a3", "a8b8", "a3b3", "b8a8"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], -sf.VALUE_MATE)
+        # Discovered chase by cannon (including pawn capture)
+        result = sf.is_optional_game_end("xiangqi", "2bakabr1/9/9/r1p1p1p2/p7R/P8/9/9/9/CC1AKA3 w - - 0 1", ["a5a6", "a7b7", "a6b6", "b7a7", "b6a6", "a7b7", "a6b6", "b7a7", "b6a6"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Chase by soldier (draw)
+        result = sf.is_optional_game_end("xiangqi", "2bakabr1/9/9/r1p1p1p2/p7R/P8/9/9/9/1C1AKA3 w - - 0 1", ["a5a6", "a7b7", "a6b6", "b7a7", "b6a6", "a7b7", "a6b6", "b7a7", "b6a6"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        # Discovered and anti-discovered chase by cannon
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/5C3/5c3/5C3/9/9/5p3/4K4 w - - 0 1", ["f5d5", "f6d6", "d5f5", "d6f6", "f5d5", "f6d6", "d5f5", "d6f6"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], -sf.VALUE_MATE)
+        # Mutual chase (draw)
+        result = sf.is_optional_game_end("xiangqi", "4k4/7n1/9/4pR3/9/9/4P4/9/9/4K4 w - - 0 1", ["f7h7"] + 2 * ["h9f8", "h7h8", "f8g6", "h8g8", "g6i7", "g8g7", "i7h9", "g7h7"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        # Perpetual check vs. intermittent checks
+        result = sf.is_optional_game_end("xiangqi", "9/3kc4/3a5/3P5/9/4p4/9/4K4/9/3C5 w - - 0 1", 2 * ['d7e7', 'e5d5', 'e7d7', 'd5e5'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Perpetual check by soldier
+        result = sf.is_optional_game_end("xiangqi", "3k5/9/9/9/9/5p3/9/5p3/5K3/5C3 w - - 0 1", 2 * ['f2e2', 'f3e3', 'e2f2', 'e3f3'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "3k5/4P4/4b4/3C5/4c4/9/9/9/9/5K3 w - - 0 1", 2 * ['d7e7', 'e8g6', 'e7d7', 'g6e8'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "3k5/9/9/9/9/9/9/9/cr1CAK3/9 w - - 0 1", 2 * ['d2d4', 'b2b4', 'd4d2', 'b4b2'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/5C3/5c3/5C3/9/9/5p3/4K4 w - - 0 1", 2 * ['f5d5', 'f6d6', 'd5f5', 'd6f6'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], -sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "4k4/9/4b4/2c2nR2/9/9/9/9/9/3K5 w - - 0 1", 2 * ['g7g6', 'f7g9', 'g6g7', 'g9f7'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "3P5/3k5/3nn4/9/9/9/9/9/9/5K3 w - - 0 1", 2 * ['d10e10', 'd9e9', 'e10d10', 'e9d9'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "4ck3/9/9/9/9/2r1R4/9/9/4A4/3AK4 w - - 0 1", 2 * ['e5e4', 'c5c4', 'e4e5', 'c4c5'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        result = sf.is_optional_game_end("xiangqi", "4k4/9/9/c1c6/9/r8/9/9/C8/3K5 w - - 0 1", 2 * ['a2c2', 'a5c5', 'c2a2', 'c5a5'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Mutual perpetual check
+        result = sf.is_optional_game_end("xiangqi", "9/4c4/3k5/3r5/9/9/4C4/9/4K4/3R5 w - - 0 1", 2 * ['e4d4', 'd7e7', 'd4e4', 'e7d7'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        result = sf.is_optional_game_end("xiangqi", "3k5/6c2/9/7P1/6c2/6P2/9/9/9/5K3 w - - 0 1", 2 * ['h7g7', 'g6h6', 'g7h7', 'h6g6'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        result = sf.is_optional_game_end("xiangqi", "4ck3/9/9/9/9/2r1R1N2/6N2/9/4A4/3AK4 w - - 0 1", 2 * ['e5e4', 'c5c4', 'e4e5', 'c4c5'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/c8/9/P1P6/9/2C6/9/3K5 w - - 0 1", 2 * ['c3a3', 'a7c7', 'a3c3', 'c7a7'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        result = sf.is_optional_game_end("xiangqi", "4k4/9/r1r6/9/PPPP5/9/9/9/1C7/5K3 w - - 0 1", ['b2a2'] + 2 * ['a8b8', 'a2c2', 'c8d8', 'c2b2', 'b8a8', 'b2d2', 'd8c8', 'd2a2'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+
+        # Corner cases
+        # D106: Chariot chases cannon, but attack actually does not change (draw)
+        result = sf.is_optional_game_end("xiangqi", "3k2b2/4P4/4b4/9/8p/6Bc1/6P1P/3AB4/4pp3/1p1K3R1[] w - - 0 1", 2 * ["h1h2", "h5h4", "h2h1", "h4h5"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        # D39: Chased chariot pinned by horse + mutual chase (controversial if pinned chariot chases)
+        result = sf.is_optional_game_end("xiangqi", "2baka1r1/C4rN2/9/1Rp1p4/9/9/4P4/9/4A4/4KA3 w - - 0 1", ["b7b9"] + 2 * ["f10e9", "b9b10", "e9f10", "b10b9"])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # D39: Chased chariot pinned by horse + mutual chase (controversial if pinned chariot chases)
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/9/9/9/7r1/9/2nRA3c/4K4 w - - 0 1", 2 * ['e2f1', 'h4h2', 'f1e2', 'h2h4'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Creating pins to undermine root
+        result = sf.is_optional_game_end("xiangqi", "4k4/4c4/9/4p4/9/9/3rn4/3NR4/4K4/9 b - - 0 1", 2 * ['e4g5', 'e2f2', 'g5e4', 'f2e2'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], -sf.VALUE_MATE)
+        # Discovered check capture threat by rook
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/9/9/1N2P1C2/9/4BC3/9/cr1RK4 w - - 0 1", 2 * ['b5c3', 'b1c1', 'c3b5', 'c1b1'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Creating a pin to undermine root + discovered check threat by horse
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/9/9/4c4/3n5/3NBA3/4A4/4K4 w - - 0 1", 2 * ['e1d1', 'e5d5', 'd1e1', 'd5e5'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Creating a pin to undermine root + discovered check threat by rook
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/9/9/4c4/3r5/3NB4/4A4/4K4 w - - 0 1", 2 * ['e1d1', 'e5d5', 'd1e1', 'd5e5'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # X-Ray protected discovered check
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/9/9/9/9/9/9/3NK1cr1 w - - 0 1", 2 * ['d1c3', 'h1h3', 'c3d1', 'h3h1'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # No overprotection by king
+        result = sf.is_optional_game_end("xiangqi", "3k5/9/9/3n5/9/9/3r5/9/9/3NK4 w - - 0 1", 2 * ['d1c3', 'd4c4', 'c3d1', 'c4d4'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+        # Overprotection by king
+        result = sf.is_optional_game_end("xiangqi", "3k5/9/9/9/9/9/3r5/9/9/3NK4 w - - 0 1", 2 * ['d1c3', 'd4c4', 'c3d1', 'c4d4'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Mutual pins by flying generals
+        result = sf.is_optional_game_end("xiangqi", "4k4/9/9/9/4n4/9/5C3/9/4N4/4K4 w - - 0 1", 2 * ['e2g1', 'e10f10', 'g1e2', 'f10e10'])
+        self.assertTrue(result[0])
+        #self.assertEqual(result[1], sf.VALUE_MATE)
+        # Fake protection by cannon
+        result = sf.is_optional_game_end("xiangqi", "5k3/9/9/9/9/1C7/1r7/9/1C7/4K4 w - - 0 1", 2 * ['b5c5', 'b4c4', 'c5b5', 'c4b4'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_MATE)
+        # Fake protection by cannon + mutual chase
+        result = sf.is_optional_game_end("xiangqi", "4ka3/c2R1R2c/4b4/9/9/9/9/9/9/4K4 w - - 0 1", 2 * ['f9f7', 'f10e9', 'f7f9', 'e9f10'])
+        self.assertTrue(result[0])
+        self.assertEqual(result[1], sf.VALUE_DRAW)
+
     def test_has_insufficient_material(self):
         for variant, positions in variant_positions.items():
             for fen, expected_result in positions.items():
index d7598b6..c4dc366 100644 (file)
@@ -605,7 +605,7 @@ describe('board.toVerboseString()', function () {
                                                   "   a   b   c   d   e   f   g   h\n\n" +
                                                   "Fen: rnb1kbnr/ppp1pppp/8/3q4/8/8/PPPP1PPP/RNBQKBNR w KQkq - 0 3\n" +
                                                   "Sfen: rnb1kbnr/ppp1pppp/8/3q4/8/8/PPPP1PPP/RNBQKBNR b - 5\n" +
-                                                  "Key: 39B6F80E84D75BFB\nCheckers: ")
+                                                  "Key: 39B6F80E84D75BFB\nCheckers: \nChased: ")
     board.delete();
     const board2 = new ffish.Board("xiangqi");
     chai.expect(board2.toVerboseString()).to.equal("\n +---+---+---+---+---+---+---+---+---+\n" +
@@ -632,7 +632,7 @@ describe('board.toVerboseString()', function () {
                                                    "   a   b   c   d   e   f   g   h   i\n\n" +
                                                    "Fen: rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1\n" +
                                                    "Sfen: rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR b - 1\n" +
-                                                   "Key: CF494C075A7D927E\nCheckers: ");
+                                                   "Key: CF494C075A7D927E\nCheckers: \nChased: ");
     board2.delete();
   });
 });