作者:happyfly
2022.03.03
这些天用不同来源的围棋程序改道棋程序,今天终于改成了一个。主要是要实现如下要求:
1、改成道棋规则
在围棋中每个点都要做这样的判断:a. 直线相连的四个点是什么情况;b. 斜角相邻的四个点是什么情况。
直线相连:(x-1, y), (x+1, y), (x, y-1), (x, y+1)
斜角相邻:(x-1, y-1), (x+1, y+1), (x+1, y-1), (x-1, y+1)
围棋程序会有一个检测函数检测这些点是否落在棋盘的范围内,不在棋盘范围内就说明该点处于边角,需要做相应的处理。因为道棋棋盘是循环的,所以不用判断是否在棋盘范围内,而只需要让坐标循环就可以了。具体可以用python取余运算‘%’:
直线相连:((x-1)%size, y), ((x+1)%size, y), (x, (y-1)%size), (x, (y+1)%size)
斜角相邻:((x-1)%size, (y-1)%size), ((x+1)%size, (y+1)%size), ((x+1)%size, (y-1)%size), ((x-1)%size, (y+1)%size)
一般来说,行棋规则只要改这两个部分就可以了。
2、允许自杀
这是为了让道棋规则更加简洁。围棋的核心规则就三条:交替落子、气尽提子、禁止循环,其他都不应该算核心规则,这就包括“禁着点”。目前大部分规则都有禁着点,但应氏规则没有。为了让道棋更有"道"的极简味道,也不设禁着点。事实上,有了禁全同(禁止全局同形再现)规则之后,禁止自杀就是无的放矢了。比如一方“颗子自尽”,等同于棋局没变,只是行棋方发生了改变。这不违反禁全同。如果紧接着下一手另一方又“颗子自尽”,此时棋局还是没变,但却使对方面临曾面临过的局面,违反了禁全同规则,这是不允许了。换个角度看,颗子自尽相当于“虚着”,棋规规定“双方连续使用虚着,为终局”,与前面的情形是自洽的。
3、让棋盘能够平移
这应该是道棋平面化所带来的特点,毕竟现实中我们不能在环面上下棋。而为了观察视野边缘的棋形,平移棋盘的功能是必需的。
程序包含go.py(行棋的所有规则)和play.py(下棋程序)。
go.py:
import numpy as np WHITE = -1 BLACK = +1 EMPTY = 0 PASS_MOVE = None board_size = 16 komi = 4 ''' This code is based on 'https://github.com/sanghyunyi/alphago_zero' ''' class GameState(object): """State of a game of Go and some basic functions to interact with it """ # Looking up positions adjacent to a given position takes a surprising # amount of time, hence this shared lookup table {boardsize: {position: [neighbors]}} __NEIGHBORS_CACHE = {} def __init__(self, size=board_size, komi=komi, enforce_superko=False): self.board = np.zeros((size, size)) self.board.fill(EMPTY) self.size = size self.current_player = BLACK self.ko = None self.komi = komi # Komi is number of extra points WHITE gets for going 2nd self.history = [] # This is for history of actions self.board_history = [] # This is for history of boards for _ in range(8): # Initialize board history with empty boards. self.board_history.append(self.board) self.num_black_prisoners = 0 self.num_white_prisoners = 0 self.is_end_of_game = False # Each pass move by a player subtracts a point self.passes_white = 0 self.passes_black = 0 # `self.liberty_sets` is a 2D array with the same indexes as `board` # each entry points to a set of tuples - the liberties of a stone's # connected block. By caching liberties in this way, we can directly # optimize update functions (e.g. do_move) and in doing so indirectly # speed up any function that queries liberties self._create_neighbors_cache() self.liberty_sets = [[set() for _ in range(size)] for _ in range(size)] for x in range(size): for y in range(size): self.liberty_sets[x][y] = set(self._neighbors((x, y))) # separately cache the 2D numpy array of the _size_ of liberty sets # at each board position self.liberty_counts = np.zeros((size, size), dtype=np.int) self.liberty_counts.fill(-1) # initialize liberty_sets of empty board: the set of neighbors of each position # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples # containing all (x',y') pairs in the group connected to (x,y) self.group_sets = [[set() for _ in range(size)] for _ in range(size)] # cache of list of legal moves (actually 'sensible' moves, with a # separate list for eye-moves on request) self.__legal_move_cache = None self.__legal_eyes_cache = None # on-the-fly record of 'age' of each stone self.stone_ages = np.zeros((size, size), dtype=np.int) - 1 # setup Zobrist hash to keep track of board state self.enforce_superko = enforce_superko rng = np.random.RandomState(0) self.hash_lookup = { WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} self.current_hash = np.uint64(0) self.previous_hashes = set() def _create_neighbors_cache(self): if self.size not in GameState.__NEIGHBORS_CACHE: GameState.__NEIGHBORS_CACHE[self.size] = {} for x in range(self.size): for y in range(self.size): neighbors = [((x - 1) % self.size, y), ((x + 1) % self.size, y), (x, (y - 1) % self.size), (x, (y + 1) % self.size )] GameState.__NEIGHBORS_CACHE[self.size][(x, y)] = neighbors def _neighbors(self, position): """A private helper function that simply returns a list of positions neighboring the given (x,y) position. """ return GameState.__NEIGHBORS_CACHE[self.size][position] def _diagonals(self, position): """Like _neighbors but for diagonal positions """ (x, y) = position return [((x - 1) % self.size, (y - 1) % self.size), ((x + 1) % self.size, (y + 1) % self.size), ((x + 1) % self.size, (y - 1) % self.size), ((x - 1) % self.size, (y + 1) % self.size)] def _update_neighbors(self, position): """A private helper function to update self.group_sets and self.liberty_sets given that a stone was just played at `position` """ (x, y) = position merged_group = set() merged_group.add(position) merged_libs = self.liberty_sets[x][y] for (nx, ny) in self._neighbors(position): # remove (x,y) from liberties of neighboring positions self.liberty_sets[nx][ny] -= set([position]) # if neighbor was opponent, update group's liberties count # (current_player's groups will be updated below regardless) if self.board[nx][ny] == -self.current_player: new_liberty_count = len(self.liberty_sets[nx][ny]) for (gx, gy) in self.group_sets[nx][ny]: self.liberty_counts[gx][gy] = new_liberty_count # MERGE group/liberty sets if neighbor is the same color # note: this automatically takes care of merging two separate # groups that just became connected through (x,y) elif self.board[x][y] == self.board[nx][ny]: merged_group |= self.group_sets[nx][ny] merged_libs |= self.liberty_sets[nx][ny] # now that we have one big 'merged' set for groups and liberties, loop # over every member of the same-color group to update them # Note: neighboring opponent groups are already updated in the previous loop count_merged_libs = len(merged_libs) for (gx, gy) in merged_group: self.group_sets[gx][gy] = merged_group self.liberty_sets[gx][gy] = merged_libs self.liberty_counts[gx][gy] = count_merged_libs def _update_hash(self, action, color): (x, y) = action self.current_hash = np.bitwise_xor(self.current_hash, self.hash_lookup[color][x][y]) def _remove_group(self, group): """A private helper function to take a group off the board (due to capture), updating group sets and liberties along the way """ for (x, y) in group: self._update_hash((x, y), self.board[x, y]) self.board[x, y] = EMPTY for (x, y) in group: # clear group_sets for all positions in 'group' self.group_sets[x][y] = set() self.liberty_sets[x][y] = set() self.liberty_counts[x][y] = -1 self.stone_ages[x][y] = -1 for (nx, ny) in self._neighbors((x, y)): if self.board[nx, ny] == EMPTY: # add empty neighbors of (x,y) to its liberties self.liberty_sets[x][y].add((nx, ny)) else: # add (x,y) to the liberties of its nonempty neighbors self.liberty_sets[nx][ny].add((x, y)) for (gx, gy) in self.group_sets[nx][ny]: self.liberty_counts[gx][gy] = len(self.liberty_sets[nx][ny]) def copy(self): """get a copy of this Game state """ other = GameState(self.size, self.komi) other.board = self.board.copy() other.current_player = self.current_player other.ko = self.ko other.history = list(self.history) other.board_history = list(self.board_history) other.num_black_prisoners = self.num_black_prisoners other.num_white_prisoners = self.num_white_prisoners other.enforce_superko = self.enforce_superko other.current_hash = self.current_hash.copy() other.previous_hashes = self.previous_hashes.copy() # update liberty and group sets. # # group_sets and liberty_sets are shared between stones in the same # group. We need to make sure this is the case in the copy, as well. # # we store set copies indexed by original id() in set_copies def get_copy(s, set_copies={}): if id(s) not in set_copies: set_copies[id(s)] = set(s) # makes a copy of s return set_copies[id(s)] for x in range(self.size): for y in range(self.size): other.group_sets[x][y] = get_copy(self.group_sets[x][y]) other.liberty_sets[x][y] = get_copy(self.liberty_sets[x][y]) other.liberty_counts = self.liberty_counts.copy() return other def is_positional_superko(self, action): """Find all actions that the current_player has done in the past, taking into account the fact that history starts with BLACK. """ if self.current_player == BLACK: player_history = self.history[0::2] else: player_history = self.history[1::2] if action not in player_history: return False state_copy = self.copy() state_copy.enforce_superko = False state_copy.do_move(action) if state_copy.current_hash in self.previous_hashes: return True else: return False def is_legal(self, action): """determine if the given action (x,y tuple) is a legal move note: we only check ko, not superko at this point (TODO?) """ # passing is always legal if action is PASS_MOVE: return True (x, y) = action if self.board[x][y] != EMPTY: #print("not empty") return False if action == self.ko: #print("ko") return False if self.enforce_superko and self.is_positional_superko(action): #print("lala") return False return True def is_eyeish(self, position, owner): """returns whether the position is empty and is surrounded by all stones of 'owner' """ (x, y) = position if self.board[x, y] != EMPTY: return False for (nx, ny) in self._neighbors(position): if self.board[nx, ny] != owner: return False return True def get_score_of_empty(self): """Calculate score for empty positions of board state using Tromp-Taylor scoring. Returns BLACK_score, WHITE_score """ empties = zip(*np.where(self.board == EMPTY)) empty_neighbors = {} reachability = {} for empty in empties: reachability[empty] = (False, False) # reachability of the empty postion from BLACK, WHITE. empty_neighbors[empty] = [] for empty in empties: for (nx, ny) in self._neighbors(empty): if self.board[nx, ny] == BLACK: reachability[empty] = (reachability[empty][0]|True, reachability[empty][1]) elif self.board[nx, ny] == WHITE: reachability[empty] = (reachability[empty][0], reachability[empty][1]|True) else : empty_neighbors[empty].append((nx, ny)) # propagate reachability reachability_old = reachability.copy() while True: for empty in empties: for (nx, ny) in empty_neighbors[empty]: black_reach, white_reach = reachability[(nx, ny)] reachability[empty] = (reachability[empty][0]|black_reach,reachability[empty][1]|white_reach) if reachability_old == reachability: break else : reachability_old = reachability.copy() BLACK_score = 0 WHITE_score = 0 for empty, (br, wr) in reachability.items(): if (br, wr) == (True, False): BLACK_score += 1 elif (br, wr) == (False, True): WHITE_score += 1 else: pass return BLACK_score, WHITE_score def get_winner(self, tromp=True): """Calculate score of board state and return player ID (1, -1) corresponding to winner. Uses 'Tromp-Taylor scoring' or 'Area scoring'. """ # Count number of positions filled by each player, plus 1 for each eye-ish space owned score_white = np.sum(self.board == WHITE) score_black = np.sum(self.board == BLACK) if tromp: black_reach, white_reach = self.get_score_of_empty() score_white += white_reach score_black += black_reach else: empties = zip(*np.where(self.board == EMPTY)) for empty in empties: # Check that all surrounding points are of one color if self.is_eyeish(empty, BLACK): score_black += 1 elif self.is_eyeish(empty, WHITE): score_white += 1 score_white -= self.passes_white score_black -= self.passes_black score_white += self.komi if score_black > score_white: winner = BLACK else: winner = WHITE return winner def get_current_player(self): """Returns the color of the player who will make the next move. """ return self.current_player def do_move(self, action, color=None): """Play stone at action=(x,y). If color is not specified, current_player is used If it is a legal move, current_player switches to the opposite color If not, an IllegalMove exception is raised """ color = color or self.current_player reset_player = self.current_player self.current_player = color if self.is_legal(action): # reset ko self.ko = None # increment age of stones by 1 self.stone_ages[self.stone_ages >= 0] += 1 if action is not PASS_MOVE: (x, y) = action self.board[x][y] = color self._update_hash(action, color) self._update_neighbors(action) self.stone_ages[x][y] = 0 if len(self.liberty_sets[x][y]) == 0: suicide = True # no liberties here 'immediately' # but this may still connect to another group of the same color for (nx, ny) in self._neighbors(action): if self.board[nx, ny] == -color and len(self.liberty_sets[nx][ny]) == 0: suicide = False if suicide: # suicide captured_group = self.group_sets[x][y] num_captured = len(captured_group) self._remove_group(captured_group) if color == BLACK: self.num_black_prisoners += num_captured else: self.num_white_prisoners += num_captured # check neighboring groups' liberties for captures for (nx, ny) in self._neighbors(action): if self.board[nx, ny] == -color and len(self.liberty_sets[nx][ny]) == 0: # capture occurred! captured_group = self.group_sets[nx][ny] num_captured = len(captured_group) self._remove_group(captured_group) if color == BLACK: self.num_white_prisoners += num_captured else: self.num_black_prisoners += num_captured # check for ko if num_captured == 1: # it is a ko iff, were the opponent to play at the captured position, # it would recapture (x,y) only # (a bigger group containing xy may be captured - this is 'snapback') would_recapture = len(self.liberty_sets[x][y]) == 1 recapture_size_is_1 = len(self.group_sets[x][y]) == 1 if would_recapture and recapture_size_is_1: # note: (nx,ny) is the stone that was captured self.ko = (nx, ny) # _remove_group has finished updating the hash self.previous_hashes.add(self.current_hash) else: if color == BLACK: self.passes_black += 1 if color == WHITE: self.passes_white += 1 # next turn self.current_player = -color self.history.append(action) self.board_history.append(self.board) self.board_history.pop(0) self.__legal_move_cache = None else: self.current_player = reset_player raise IllegalMove(str(action)) # Check for end of game if len(self.history) > 1: if self.history[-1] is PASS_MOVE and self.history[-2] is PASS_MOVE: self.is_end_of_game = True else : pass return self.is_end_of_game class IllegalMove(Exception): pass
play.py
import numpy as np import re import go # 定义一个玩家的类 class Player(object): def __init__(self, color): self.color = color # 下棋程序 def run_a_game(black, white): global h_shift global v_shift v_shift = 0 h_shift = 0 # 实例化一局棋 state = go.GameState(size=go.board_size, komi=go.komi) current = black other = white # 打印空白棋盘 print_board(state.board) # 棋局循环 while not state.is_end_of_game: query = input(current.color + " move (Enter for PSAA, 0 for shift): ") # 直接回车代表虚着,两虚棋终 if len(query)==0: state.do_move(go.PASS_MOVE) current, other = other, current continue # 如果需要平移棋盘的话,需要先输入“0”,改到平移状态 if query == '0': query = input("For board shift, input L/R/U/D0~16: ") try: alphabet, number = re.match(r"([a-z]+)([0-9]+)", query, re.I).groups() if alphabet.upper() == 'L': h_shift -= int(number) if alphabet.upper() == 'R': h_shift += int(number) if alphabet.upper() == 'U': v_shift -= int(number) if alphabet.upper() == 'D': v_shift += int(number) print_board(state.board) continue except: print("Input format error!") continue # 既不是虚着也不是平移,此时判断输入格式后调用go.py中的do_move行棋 try: alphabet, number = re.match(r"([a-z]+)([0-9]+)", query, re.I).groups() y = ord(alphabet.upper()) - ord('A') x = go.board_size - int(number) x = (x - v_shift) % go.board_size y = (y - h_shift) % go.board_size try: state.do_move((x,y)) except: print("Illegal move!") continue current, other = other, current print_board(state.board) except: print("The input should have the form like 'a1' or 'A1'.") continue # 最后调用go.py中的get_winner判断赢家(Tromp-Taylor计分法) print('Black' if state.get_winner() == 1 else 'White', "won the game.") # 棋盘平移 def boardshift(matrix, v_shift, h_shift): v, h = matrix.shape matrix=np.vstack((matrix[(v - v_shift % v):,:],matrix[:(v - v_shift % v),:])) matrix=np.hstack((matrix[:,(h - h_shift % h):],matrix[:,:(h - h_shift % h)])) return matrix # 在终端打印棋盘 def print_board(board): out0 = ' ' for j in range(go.board_size): out0 += chr(65+j) +' ' print(out0) for j in range(go.board_size): if go.board_size-j < 10: out = ' ' + str(go.board_size-j) + ' ' else : out = str(go.board_size-j) + ' ' # 根据横纵坐标位移参数打印平移后的棋盘(程序中实际的棋盘一直不变) line = list(boardshift(board, v_shift, h_shift)[j]) for pt in line: if pt == go.EMPTY: out += '. ' elif pt == go.BLACK: out += 'X ' else: out += 'O ' out += str(go.board_size-j) print(out) print(out0) if __name__ == '__main__': # 实例化两个玩家 black = Player(color='Black') white = Player(color='White') # 开始一个棋局 run_a_game(black, white)
程序需要python3,没用到太特殊的库。我试过3.5、3.6和3.10,都可以运行。
目前只有字符界面,也只能左右互搏。在终端字符界面下程序运行看起来是这样的:
White move (Enter for PSAA, 0 for shift): i2 A B C D E F G H I J K L M N O P 16 . . . . . . . . . . . . . . . . 16 15 . . . . . . . . . . . . . . . . 15 14 . . . . . . . . . . . . . . . . 14 13 . . . . . . . . . . . . . . . . 13 12 . . O . . . . . . . . . . . . . 12 11 O X . . . . . . . . . . . . . . 11 10 X O X . . . . . . . . . . . . . 10 9 X O O X . . . . . . . . . . . . 9 8 . X O O X . . . . . . . . . . . 8 7 . . X O O X . . . . . . . . . . 7 6 . . . X O O X . . . . . . . . . 6 5 . . . . X O O X . . . . . . . . 5 4 . . . . . X O O X . . . . . . . 4 3 . . . . . . X O O X . . . . . . 3 2 . . . . . . . X O . . . . . . . 2 1 . . . . . . . . . . . . . . . . 1 A B C D E F G H I J K L M N O P Black move (Enter for PSAA, 0 for shift): 0 For board shift, input L/R/U/D0~16: u6 A B C D E F G H I J K L M N O P 16 X O X . . . . . . . . . . . . . 16 15 X O O X . . . . . . . . . . . . 15 14 . X O O X . . . . . . . . . . . 14 13 . . X O O X . . . . . . . . . . 13 12 . . . X O O X . . . . . . . . . 12 11 . . . . X O O X . . . . . . . . 11 10 . . . . . X O O X . . . . . . . 10 9 . . . . . . X O O X . . . . . . 9 8 . . . . . . . X O . . . . . . . 8 7 . . . . . . . . . . . . . . . . 7 6 . . . . . . . . . . . . . . . . 6 5 . . . . . . . . . . . . . . . . 5 4 . . . . . . . . . . . . . . . . 4 3 . . . . . . . . . . . . . . . . 3 2 . . O . . . . . . . . . . . . . 2 1 O X . . . . . . . . . . . . . . 1 A B C D E F G H I J K L M N O P Black move (Enter for PSAA, 0 for shift):