Guest User

Untitled

a guest
Mar 27th, 2026
83
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 23.81 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. Whale Shogi XBoard-compatible engine
  4. ======================================
  5. Rules:  https://en.wikipedia.org/wiki/Whale_shogi
  6. Board:  6×6
  7. Goal:   Capture the opponent's White Whale
  8. Author: Generated for XBoard / WinBoard protocol v2
  9.  
  10. Piece movements (Black's "forward" = higher row numbers):
  11.  W - White Whale  : steps 1 in any of the 8 directions            (King)
  12.  G - Grey Whale   : slides forward; slides diagonally backward
  13.  P - Porpoise     : steps 1 square sideways (left or right)
  14.  K - Killer Whale : slides orthogonally; steps diagonally          (Dragon King)
  15.  N - Narwhal      : jumps 2 squares forward; steps backward/sideways
  16.  H - Humpback     : steps diagonally (all 4); steps backward
  17.  B - Blue Whale   : steps forward, backward, diagonally-forward
  18.  D - Dolphin      : steps 1 forward; if on farthest rank, slides diagonally backward
  19.  
  20. Drop rules:
  21.  • Porpoise is never dropped – when captured it instantly promotes
  22.    to Killer Whale and is held in hand as K.
  23.  • Dolphin cannot be dropped on the farthest rank.
  24.  • Dolphin cannot be dropped in a file that already has ≥ 2 friendly dolphins.
  25.  • Dolphin cannot be dropped to give immediate checkmate ("dolphin-drop mate").
  26. """
  27.  
  28. import sys
  29. import random
  30. import re
  31.  
  32. # ── Colour constants ──────────────────────────────────────────────────────────
  33. BLACK, WHITE = 0, 1
  34.  
  35. # ── Piece-type constants ──────────────────────────────────────────────────────
  36. WW = 1   # White Whale  (king)
  37. PP = 2   # Porpoise     (promotes to Killer Whale on capture)
  38. HH = 3   # Humpback
  39. GG = 4   # Grey Whale
  40. NN = 5   # Narwhal
  41. BB = 6   # Blue Whale
  42. DD = 7   # Dolphin
  43. KK = 8   # Killer Whale (promoted Porpoise)
  44.  
  45. P2C = {WW: 'W', PP: 'P', HH: 'H', GG: 'G', NN: 'N', BB: 'B', DD: 'D', KK: 'K'}
  46. C2P = {v: k for k, v in P2C.items()}
  47.  
  48. # Approximate piece values for evaluation (White Whale = ∞ treated as 10 000)
  49. VALUE = {WW: 10000, KK: 8, GG: 6, NN: 5, HH: 4, BB: 4, PP: 3, DD: 1}
  50.  
  51. # ── Coordinate helpers ────────────────────────────────────────────────────────
  52.  
  53. def sq2str(col: int, row: int) -> str:
  54.     """(col 0-5, row 0-5)  →  'a1' … 'f6'"""
  55.     return chr(ord('a') + col) + str(row + 1)
  56.  
  57.  
  58. def str2sq(s: str):
  59.     """'a1' → (col=0, row=0),  'f6' → (col=5, row=5)"""
  60.     return ord(s[0]) - ord('a'), int(s[1]) - 1
  61.  
  62.  
  63. # ── Board ─────────────────────────────────────────────────────────────────────
  64.  
  65. class Board:
  66.     """
  67.    cells[row][col]  =  (piece_type, colour)  |  None
  68.    row 0 = rank 1 = Black's back rank
  69.    row 5 = rank 6 = White's back rank
  70.    """
  71.  
  72.     __slots__ = ('cells', 'hands', 'to_move')
  73.  
  74.     # ── Construction / reset ──────────────────────────────────────────────────
  75.  
  76.     def __init__(self):
  77.         self.cells: list = [[None] * 6 for _ in range(6)]
  78.         self.hands: dict = {BLACK: [], WHITE: []}
  79.         self.to_move: int = BLACK
  80.         self._setup()
  81.  
  82.     def _setup(self):
  83.         # Black – rank 1 (row 0):  H G W P N B
  84.         for col, piece in enumerate([HH, GG, WW, PP, NN, BB]):
  85.             self.cells[0][col] = (piece, BLACK)
  86.         # Black – rank 2 (row 1):  six dolphins
  87.         for col in range(6):
  88.             self.cells[1][col] = (DD, BLACK)
  89.         # White – rank 6 (row 5):  B N P W G H  (mirror of Black)
  90.         for col, piece in enumerate([BB, NN, PP, WW, GG, HH]):
  91.             self.cells[5][col] = (piece, WHITE)
  92.         # White – rank 5 (row 4):  six dolphins
  93.         for col in range(6):
  94.             self.cells[4][col] = (DD, WHITE)
  95.  
  96.     # ── Copy ─────────────────────────────────────────────────────────────────
  97.  
  98.     def copy(self) -> 'Board':
  99.         b = object.__new__(Board)
  100.         b.cells   = [row[:] for row in self.cells]
  101.         b.hands   = {BLACK: self.hands[BLACK][:], WHITE: self.hands[WHITE][:]}
  102.         b.to_move = self.to_move
  103.         return b
  104.  
  105.     # ── Low-level helpers ─────────────────────────────────────────────────────
  106.  
  107.     @staticmethod
  108.     def _fwd(color: int) -> int:
  109.         """Row-delta for 'forward' direction."""
  110.         return 1 if color == BLACK else -1
  111.  
  112.     @staticmethod
  113.     def _far_row(color: int) -> int:
  114.         """Index of the farthest rank for the given colour."""
  115.         return 5 if color == BLACK else 0
  116.  
  117.     @staticmethod
  118.     def _inbounds(r: int, c: int) -> bool:
  119.         return 0 <= r < 6 and 0 <= c < 6
  120.  
  121.     def _step(self, r, c, dr, dc, color, out):
  122.         """Add one-step destination if legal."""
  123.         r2, c2 = r + dr, c + dc
  124.         if self._inbounds(r2, c2):
  125.             tgt = self.cells[r2][c2]
  126.             if tgt is None or tgt[1] != color:
  127.                 out.append((r2, c2))
  128.  
  129.     def _slide(self, r, c, dr, dc, color, out):
  130.         """Add all slide-destinations in direction (dr,dc)."""
  131.         r2, c2 = r + dr, c + dc
  132.         while self._inbounds(r2, c2):
  133.             tgt = self.cells[r2][c2]
  134.             if tgt is None:
  135.                 out.append((r2, c2))
  136.             elif tgt[1] != color:
  137.                 out.append((r2, c2))
  138.                 break
  139.             else:
  140.                 break
  141.             r2 += dr
  142.             c2 += dc
  143.  
  144.     # ── Piece destinations (no check-filter) ─────────────────────────────────
  145.  
  146.     def get_dests(self, row: int, col: int) -> list:
  147.         """
  148.        Return all squares reachable by the piece at (row, col).
  149.        Does NOT check whether the move leaves own king in check.
  150.        """
  151.         cell = self.cells[row][col]
  152.         if not cell:
  153.             return []
  154.         piece, color = cell
  155.         f   = self._fwd(color)
  156.         out = []
  157.  
  158.         if piece == WW:          # King – all 8 neighbours
  159.             for dr in (-1, 0, 1):
  160.                 for dc in (-1, 0, 1):
  161.                     if dr or dc:
  162.                         self._step(row, col, dr, dc, color, out)
  163.  
  164.         elif piece == GG:        # Grey Whale: forward-slide + diag-backward-slide
  165.             self._slide(row, col,  f,  0, color, out)
  166.             self._slide(row, col, -f, -1, color, out)
  167.             self._slide(row, col, -f,  1, color, out)
  168.  
  169.         elif piece == PP:        # Porpoise: step left or right
  170.             self._step(row, col, 0, -1, color, out)
  171.             self._step(row, col, 0,  1, color, out)
  172.  
  173.         elif piece == KK:        # Killer Whale: orthogonal-slide + diagonal-step
  174.             for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)):
  175.                 self._slide(row, col, dr, dc, color, out)
  176.             for dr, dc in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
  177.                 self._step(row, col, dr, dc, color, out)
  178.  
  179.         elif piece == NN:        # Narwhal: jump 2f + step backward + step sideways
  180.             r2, c2 = row + 2 * f, col
  181.             if self._inbounds(r2, c2):
  182.                 tgt = self.cells[r2][c2]
  183.                 if tgt is None or tgt[1] != color:
  184.                     out.append((r2, c2))            # jump ignores intermediate square
  185.             self._step(row, col, -f,  0, color, out)
  186.             self._step(row, col,  0, -1, color, out)
  187.             self._step(row, col,  0,  1, color, out)
  188.  
  189.         elif piece == HH:        # Humpback: all 4 diagonals + backward
  190.             for dr, dc in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
  191.                 self._step(row, col, dr, dc, color, out)
  192.             self._step(row, col, -f, 0, color, out)
  193.  
  194.         elif piece == BB:        # Blue Whale: step f/b/diag-f
  195.             self._step(row, col,  f,  0, color, out)
  196.             self._step(row, col, -f,  0, color, out)
  197.             self._step(row, col,  f,  1, color, out)
  198.             self._step(row, col,  f, -1, color, out)
  199.  
  200.         elif piece == DD:        # Dolphin: step forward; on farthest rank slide diag-backward
  201.             if row == self._far_row(color):
  202.                 self._slide(row, col, -f, -1, color, out)
  203.                 self._slide(row, col, -f,  1, color, out)
  204.             else:
  205.                 self._step(row, col, f, 0, color, out)
  206.  
  207.         return out
  208.  
  209.     # ── Move generation ───────────────────────────────────────────────────────
  210.  
  211.     def _board_moves(self, color: int) -> list:
  212.         moves = []
  213.         for r in range(6):
  214.             for c in range(6):
  215.                 cell = self.cells[r][c]
  216.                 if cell and cell[1] == color:
  217.                     for tr, tc in self.get_dests(r, c):
  218.                         moves.append(('mv', r, c, tr, tc))
  219.         return moves
  220.  
  221.     def _drop_moves(self, color: int) -> list:
  222.         moves  = []
  223.         far    = self._far_row(color)
  224.         pieces = list(set(self.hands[color]))
  225.         for piece in pieces:
  226.             for r in range(6):
  227.                 for c in range(6):
  228.                     if self.cells[r][c] is not None:
  229.                         continue
  230.                     # ── Dolphin restrictions ────────────────────────────────
  231.                     if piece == DD:
  232.                         if r == far:
  233.                             continue                   # not on farthest rank
  234.                         # ≤ 1 other friendly dolphin already in this file
  235.                         dolphins_in_file = sum(
  236.                             1 for rr in range(6)
  237.                             if self.cells[rr][c] == (DD, color)
  238.                         )
  239.                         if dolphins_in_file >= 2:
  240.                             continue
  241.                     moves.append(('dr', piece, r, c))
  242.         return moves
  243.  
  244.     def legal_moves(self) -> list:
  245.         """All legal moves for the side to move."""
  246.         color      = self.to_move
  247.         candidates = self._board_moves(color) + self._drop_moves(color)
  248.         legal      = []
  249.         for mv in candidates:
  250.             # ── Dolphin-drop-mate restriction ────────────────────────────────
  251.             if mv[0] == 'dr' and mv[1] == DD:
  252.                 nb = self._apply_raw(mv)
  253.                 if self._is_mated_in(nb, 1 - color):
  254.                     continue
  255.             # ── Must not leave own White Whale in check ──────────────────────
  256.             nb = self._apply_raw(mv)
  257.             kp = nb._king_pos(color)
  258.             if kp and not nb._is_attacked(kp[0], kp[1], 1 - color):
  259.                 legal.append(mv)
  260.         return legal
  261.  
  262.     # ── Apply a move ──────────────────────────────────────────────────────────
  263.  
  264.     def _apply_raw(self, mv) -> 'Board':
  265.         """Apply move without legality checks; return new Board."""
  266.         b = self.copy()
  267.         if mv[0] == 'mv':
  268.             _, fr, fc, tr, tc = mv
  269.             piece, color = b.cells[fr][fc]
  270.             tgt = b.cells[tr][tc]
  271.             if tgt:
  272.                 cap = tgt[0]
  273.                 if cap == PP:
  274.                     cap = KK          # Porpoise → Killer Whale in hand
  275.                 b.hands[color].append(cap)
  276.             b.cells[fr][fc]  = None
  277.             b.cells[tr][tc]  = (piece, color)
  278.         else:                          # drop
  279.             _, piece, tr, tc = mv
  280.             color = b.to_move
  281.             b.hands[color].remove(piece)
  282.             b.cells[tr][tc] = (piece, color)
  283.         b.to_move ^= 1
  284.         return b
  285.  
  286.     apply = _apply_raw   # public alias
  287.  
  288.     # ── Check / mate helpers ──────────────────────────────────────────────────
  289.  
  290.     def _king_pos(self, color: int):
  291.         """(row, col) of colour's White Whale, or None."""
  292.         for r in range(6):
  293.             for c in range(6):
  294.                 cell = self.cells[r][c]
  295.                 if cell and cell[0] == WW and cell[1] == color:
  296.                     return r, c
  297.         return None
  298.  
  299.     def _is_attacked(self, row: int, col: int, by_color: int) -> bool:
  300.         """True if any piece of by_color can reach (row, col)."""
  301.         for r in range(6):
  302.             for c in range(6):
  303.                 cell = self.cells[r][c]
  304.                 if cell and cell[1] == by_color:
  305.                     if (row, col) in self.get_dests(r, c):
  306.                         return True
  307.         return False
  308.  
  309.     def in_check(self, color: int) -> bool:
  310.         kp = self._king_pos(color)
  311.         return kp is None or self._is_attacked(kp[0], kp[1], 1 - color)
  312.  
  313.     @staticmethod
  314.     def _is_mated_in(board: 'Board', color: int) -> bool:
  315.         """True if 'color' is in check and has no legal escape on board."""
  316.         if not board.in_check(color):
  317.             return False
  318.         old_tm       = board.to_move
  319.         board.to_move = color
  320.         for mv in board._board_moves(color) + board._drop_moves(color):
  321.             nb = board._apply_raw(mv)
  322.             if not nb.in_check(color):
  323.                 board.to_move = old_tm
  324.                 return False
  325.         board.to_move = old_tm
  326.         return True
  327.  
  328.     def winner(self):
  329.         """Colour that has won, or None."""
  330.         if self._king_pos(BLACK) is None:
  331.             return WHITE
  332.         if self._king_pos(WHITE) is None:
  333.             return BLACK
  334.         return None
  335.  
  336.     # ── Evaluation ────────────────────────────────────────────────────────────
  337.  
  338.     def evaluate(self) -> float:
  339.         """Signed material score: positive = Black is ahead."""
  340.         score = 0.0
  341.         for r in range(6):
  342.             for c in range(6):
  343.                 cell = self.cells[r][c]
  344.                 if cell:
  345.                     v = VALUE[cell[0]]
  346.                     score += v if cell[1] == BLACK else -v
  347.         for p in self.hands[BLACK]:
  348.             score += VALUE[p] * 0.9
  349.         for p in self.hands[WHITE]:
  350.             score -= VALUE[p] * 0.9
  351.         return score
  352.  
  353.     # ── Search (alpha-beta, depth 2) ─────────────────────────────────────────
  354.  
  355.     def best_move(self, depth: int = 2):
  356.         """Return the best move found by alpha-beta search."""
  357.         color  = self.to_move
  358.         moves  = self.legal_moves()
  359.         if not moves:
  360.             return None
  361.         random.shuffle(moves)        # break ties / add variety
  362.         sign       = 1 if color == BLACK else -1
  363.         best_score = -1_000_000
  364.         best       = moves[0]
  365.  
  366.         for mv in moves:
  367.             nb = self._apply_raw(mv)
  368.             w  = nb.winner()
  369.             if w == color:
  370.                 return mv            # immediate winning capture
  371.             score = sign * self._ab(nb, depth - 1, -1_000_000, 1_000_000)
  372.             if score > best_score:
  373.                 best_score = score
  374.                 best       = mv
  375.  
  376.         return best
  377.  
  378.     def _ab(self, board: 'Board', depth: int, alpha: float, beta: float) -> float:
  379.         """Alpha-beta minimax, returns score from Black's perspective."""
  380.         w = board.winner()
  381.         if w == BLACK:
  382.             return  10_000.0
  383.         if w == WHITE:
  384.             return -10_000.0
  385.         if depth == 0:
  386.             return board.evaluate()
  387.         moves = board.legal_moves()
  388.         if not moves:
  389.             return board.evaluate()
  390.  
  391.         if board.to_move == BLACK:
  392.             v = -1_000_000.0
  393.             for mv in moves:
  394.                 v     = max(v, self._ab(board._apply_raw(mv), depth - 1, alpha, beta))
  395.                 alpha = max(alpha, v)
  396.                 if beta <= alpha:
  397.                     break
  398.             return v
  399.         else:
  400.             v = 1_000_000.0
  401.             for mv in moves:
  402.                 v    = min(v, self._ab(board._apply_raw(mv), depth - 1, alpha, beta))
  403.                 beta = min(beta, v)
  404.                 if beta <= alpha:
  405.                     break
  406.             return v
  407.  
  408.     # ── Move encoding / decoding ──────────────────────────────────────────────
  409.  
  410.     def move_str(self, mv) -> str:
  411.         """Internal move tuple → XBoard string (e.g. 'c1d2' or 'K@d4')."""
  412.         if mv[0] == 'mv':
  413.             _, fr, fc, tr, tc = mv
  414.             return sq2str(fc, fr) + sq2str(tc, tr)
  415.         else:
  416.             _, piece, tr, tc = mv
  417.             return P2C[piece] + '@' + sq2str(tc, tr)
  418.  
  419.     def parse_move(self, s: str):
  420.         """XBoard string → internal move tuple, or None on failure."""
  421.         s = s.strip()
  422.         # Drop:  P@d4  or  P*d4
  423.         m = re.match(r'^([WPHGNBDKwphgnbdk])[@*]([a-f][1-6])$', s)
  424.         if m:
  425.             piece      = C2P[m.group(1).upper()]
  426.             col, row   = str2sq(m.group(2))
  427.             return ('dr', piece, row, col)
  428.         # Normal move:  c1d2
  429.         if (len(s) == 4
  430.                 and s[0].isalpha() and s[1].isdigit()
  431.                 and s[2].isalpha() and s[3].isdigit()):
  432.             try:
  433.                 fc, fr = str2sq(s[0:2])
  434.                 tc, tr = str2sq(s[2:4])
  435.                 return ('mv', fr, fc, tr, tc)
  436.             except (ValueError, IndexError):
  437.                 pass
  438.         return None
  439.  
  440.     # ── Debug display ─────────────────────────────────────────────────────────
  441.  
  442.     def display(self) -> str:
  443.         lines = ['  a  b  c  d  e  f']
  444.         for r in range(5, -1, -1):
  445.             row_str = f'{r+1} '
  446.             for c in range(6):
  447.                 cell = self.cells[r][c]
  448.                 if cell is None:
  449.                     row_str += '.  '
  450.                 else:
  451.                     ch = P2C[cell[0]]
  452.                     row_str += (ch.lower() + ' ') if cell[1] == WHITE else (ch + '  ')
  453.             lines.append(row_str)
  454.         lines.append(f'Black hand: {[P2C[p] for p in self.hands[BLACK]]}')
  455.         lines.append(f'White hand: {[P2C[p] for p in self.hands[WHITE]]}')
  456.         lines.append(f'Side to move: {"Black" if self.to_move == BLACK else "White"}')
  457.         return '\n'.join(lines)
  458.  
  459.  
  460. # ── XBoard protocol engine loop ───────────────────────────────────────────────
  461.  
  462. def main():
  463.     board        = Board()
  464.     engine_color = WHITE       # engine plays White after 'new'
  465.     force_mode   = True        # do not auto-play until 'new' or 'go'
  466.     move_history = []          # list of (board_before, move_str) for undo
  467.  
  468.     def out(msg: str):
  469.         sys.stdout.write(msg + '\n')
  470.         sys.stdout.flush()
  471.  
  472.     def engine_move():
  473.         nonlocal board
  474.         mv = board.best_move(depth=2)
  475.         if mv is None:
  476.             out('resign')
  477.             return
  478.         ms = board.move_str(mv)
  479.         move_history.append((board.copy(), ms))
  480.         board = board._apply_raw(mv)
  481.         out('move ' + ms)
  482.         w = board.winner()
  483.         if w == BLACK:
  484.             out('RESULT 0-1 {Black wins by capturing the White Whale}')
  485.         elif w == WHITE:
  486.             out('RESULT 1-0 {White wins by capturing the White Whale}')
  487.  
  488.     while True:
  489.         try:
  490.             raw = sys.stdin.readline()
  491.         except (EOFError, KeyboardInterrupt):
  492.             break
  493.         if not raw:
  494.             break
  495.  
  496.         line   = raw.strip()
  497.         tokens = line.split()
  498.         if not tokens:
  499.             continue
  500.         cmd = tokens[0]
  501.  
  502.         # ── Protocol handshake ────────────────────────────────────────────────
  503.         if cmd == 'xboard':
  504.             pass
  505.  
  506.         elif cmd == 'protover':
  507.             out('feature myname="Whale Shogi Engine"')
  508.             out('feature variants="whale-shogi"')
  509.             out('feature usermove=1')
  510.             out('feature reuse=1')
  511.             out('feature done=1')
  512.  
  513.         elif cmd in ('accepted', 'rejected'):
  514.             pass
  515.  
  516.         # ── Game control ──────────────────────────────────────────────────────
  517.         elif cmd == 'new':
  518.             board        = Board()
  519.             engine_color = WHITE
  520.             force_mode   = False
  521.             move_history.clear()
  522.  
  523.         elif cmd == 'variant':
  524.             pass   # accept any variant name; we only know whale-shogi
  525.  
  526.         elif cmd == 'force':
  527.             force_mode = True
  528.  
  529.         elif cmd == 'go':
  530.             force_mode   = False
  531.             engine_color = board.to_move
  532.             engine_move()
  533.  
  534.         elif cmd == 'usermove':
  535.             if len(tokens) >= 2:
  536.                 mv = board.parse_move(tokens[1])
  537.                 if mv is not None:
  538.                     move_history.append((board.copy(), tokens[1]))
  539.                     board = board._apply_raw(mv)
  540.                     if not force_mode and board.to_move == engine_color:
  541.                         engine_move()
  542.                 else:
  543.                     out(f'Error (illegal move): {tokens[1]}')
  544.  
  545.         # ── Undo ──────────────────────────────────────────────────────────────
  546.         elif cmd == 'undo':
  547.             if move_history:
  548.                 board, _ = move_history.pop()
  549.  
  550.         elif cmd == 'remove':       # take back 2 moves
  551.             for _ in range(2):
  552.                 if move_history:
  553.                     board, _ = move_history.pop()
  554.  
  555.         # ── Misc protocol commands ────────────────────────────────────────────
  556.         elif cmd == 'ping':
  557.             n = tokens[1] if len(tokens) > 1 else '0'
  558.             out(f'pong {n}')
  559.  
  560.         elif cmd == 'result':
  561.             pass   # game over notification; nothing to do
  562.  
  563.         elif cmd == 'setboard':
  564.             pass   # not implemented; would need FEN parser for this variant
  565.  
  566.         elif cmd in ('hint', 'bk', 'draw', 'hard', 'easy', 'level', 'st',
  567.                      'sd', 'nps', 'time', 'otim', 'edit', 'analyze', 'exit',
  568.                      'computer', 'white', 'black', 'random', 'post', 'nopost',
  569.                      'cores', 'memory', 'option'):
  570.             pass   # silently ignore time/analysis/config commands
  571.  
  572.         elif cmd == 'quit':
  573.             break
  574.  
  575.         else:
  576.             # Older XBoard protocol sends raw move strings without 'usermove'
  577.             mv = board.parse_move(cmd)
  578.             if mv is not None:
  579.                 move_history.append((board.copy(), cmd))
  580.                 board = board._apply_raw(mv)
  581.                 if not force_mode and board.to_move == engine_color:
  582.                     engine_move()
  583.             # Unknown commands are silently ignored to keep the protocol clean
  584.  
  585.     sys.exit(0)
  586.  
  587.  
  588. if __name__ == '__main__':
  589.     main()
  590.  
Advertisement
Add Comment
Please, Sign In to add comment