pygo/lib/goban.py

578 lines
16 KiB
Python
Raw Permalink Normal View History

from gomill import sgf
# leftoffat: implementing navigation forwards and backward through the game.
class Goban:
"""Represents the go board. Handles stone placement, captures, etc"""
# enum values for the board array
EMPTY='.'
WHITE='w'
BLACK='b'
SCORE_BLACK='B'
SCORE_WHITE='W'
SCORE_DAME='d'
SCORING='s'
def __init__(self, board_size=19, file_name=None):
# Build the board intersections
self.board_size = board_size
num_points = board_size * board_size
self.board = [Goban.EMPTY] * num_points
self.def_draw_codes = self._make_default_draw_codes()
self.to_move = Goban.BLACK
self.black_captures = 0
self.white_captures = 0
self.last_move = None
self.passed_last = False
self.ko = None
self.hover = None
self.elapsed_time = 0
self.winner = Goban.EMPTY
self.move = 0
self.file_name = file_name
# Track our game in an easily saveable format!
self.sgf_game = None
if self.file_name is not None:
self.load_sgf(file_name)
else:
self.sgf_game = sgf.Sgf_game(self.board_size)
def load_sgf(self, file_name):
try:
with open(file_name, 'r') as fn:
self.sgf_game = sgf.Sgf_game.from_string(fn.read())
except IOError:
# fixme - this should be convertable into a dialog box... perhaps it should throw an exception of its own
print 'There was a problem loading the SGF file.'
# Do initial layout
self._place_initial_stones()
for node in self.sgf_game.get_main_sequence():
self._play_node(node)
if self.sgf_game.get_winner() is not None:
self.winner = self.sgf_game.get_winner()
self.to_move = None
def _place_initial_stones(self):
root = self.sgf_game.get_root()
if root.has_setup_stones():
black, white, empty = root.get_setup_stones()
for point in black:
2012-04-24 01:19:56 +00:00
self.board[self._real_pos(self._sgf_to_move(point))] = Goban.BLACK
for point in white:
2012-04-24 01:19:56 +00:00
self.board[self._real_pos(self._sgf_to_move(point))] = Goban.WHITE
for point in empty:
self.board[self._real_pos(self._sgf_to_move(point))] = Goban.EMPTY
2012-04-24 01:19:56 +00:00
# Play a single node from an sgf tree. Useful for warking the tree.
def _play_node(self, node):
color, pos = node.get_move()
if color is not None:
if pos is None:
self.pass_move(color)
else:
self.play_move(self._sgf_to_move(pos), color, add_sgf=False)
# fixme - add resignation detection
# Play all moves up to and including node, which should be
# an sgf.Tree_node
def play_to_node(self, node):
self.soft_reset()
for n in self.sgf_game.get_main_sequence():
self._sgf_play_node(self, n)
if n == node:
return
def play_to_move_n(self, n=None):
''' Play to the nth move. If n is None, play to the latest move '''
self.soft_reset()
if n is None or len(self.sgf_game.get_main_sequence()) < n:
n = len(self.sgf_game.get_main_sequence())
portion = self.sgf_game.get_main_sequence()[:n]
for node in portion:
self._play_node(node)
def save_sgf(self, file_name=None):
'''Saves the current game as an SGF file. If file_name is None, we use the previously specified filename.
If there is no previously specified filename, we raise an exception.'''
if self.file_name is None and file_name is not None:
self.file_name = file_name
if file_name is None:
file_name = self.file_name
if file_name is None:
return # fixme - this should be an exception instead
with open(file_name, 'w') as fn:
fn.write(self.sgf_game.serialise())
def set_hover(self, pos):
rpos = self._real_pos(pos)
if rpos == self.hover:
return
if not self._valid_move(rpos) or self.to_move == Goban.EMPTY:
self.clear_hover()
return
self.hover = rpos
def soft_reset(self):
"""Reset the board to a pre-game state, but preserves move history.
In other words, goes back to before move #1"""
# Clear the board by setting it to the same size it currently is at
self.set_board_size(self.board_size)
self.to_move = Goban.BLACK
self.black_captures = 0
self.white_captures = 0
self.last_move = None
self.passed_last = False
self.ko = None
self.hover = None
self.elapsed_time = 0
self.winner = Goban.EMPTY
self.move = 0
2012-04-26 04:37:34 +00:00
self._place_initial_stones()
def reset(self):
'''Fully resets the game. The only thing preserved is the SGF filename, if it is set '''
self.sgf_game = sgf.Sgf_game(self.board_size)
self.soft_reset()
def set_board_size(self, new_size):
"""Set the board to a new size. This will also
reset the board to a blank state, but will *not* reset captures, etc
(call reset() first if you want that)"""
self.board_size = new_size
num_points = self.board_size * self.board_size
self.board = [Goban.EMPTY] * num_points
def clear_hover(self):
self.hover = None
2012-04-24 01:19:56 +00:00
def play_move(self, pos, color=None, add_sgf=True):
'''
Plays a move
pos: a tuple containing row and column to play on
color: which color is playing. If not specified, the player whose turn it is is assumed
add_sgf: if True, this is a new move and should be recorded in the move history
return:
To help with drawing code efficiency, any modified positions are returned in a list.
This includes anything that the GUI may want to redraw in the wake of this move.
'''
if add_sgf and self.move != len(self.sgf_game.get_main_sequence()):
return
if color is None:
color = self.to_move
if self.to_move == Goban.EMPTY:
return None
rpos = self._real_pos(pos)
if not self._valid_move(rpos, color):
return None
self.board[rpos] = color
self._changed = []
self._changed.append(rpos)
if self.ko is not None:
self._changed.append(self.ko)
if self.last_move is not None:
self._changed.append(self.last_move)
self._capture(rpos)
self.last_move = rpos
self.passed_last = False
2012-04-24 01:19:56 +00:00
if add_sgf:
node = self.sgf_game.extend_main_sequence()
node.set_move(color, self._pos_to_sgf(pos))
self.to_move = self._other_color(color)
self.clear_hover()
# If there is a new ko, send that back too
if self.ko is not None:
self._changed.append(self.ko)
self.move += 1
return self._changed
def pass_move(self, color=None):
if color is None:
color = self.to_move
2012-04-24 01:19:56 +00:00
# If the game is over, fail silently
if color is None:
return
self._changed = []
if self.ko is not None:
self._changed.append(self.ko)
if self.last_move is not None:
self._changed.append(self.last_move)
node = self.sgf_game.extend_main_sequence()
2012-04-24 01:19:56 +00:00
node.set_move(color, None)
if self.passed_last:
self.to_move = Goban.EMPTY
self.winner = Goban.SCORING
self.auto_score()
return range(len(self.board))
else:
self.to_move = self._other_color(color)
self.passed_last = True
self.last_move = None
self.ko = None
return self._changed
def resign(self, color=None):
if color is None:
color = self.to_move
2012-04-24 01:19:56 +00:00
# If the game is over, fail silently
if color is None:
return
self._changed = []
if self.ko is not None:
self._changed.append(self.ko)
if self.last_move is not None:
self._changed.append(self.last_move)
self.passed_last = False
self.last_move = None
self.ko = None
self.winner = self._other_color(color)
self.to_move = Goban.EMPTY
return self._changed
def _capture(self, pos, color=None):
"""Look for stones captured on the 4 sides of pos, remove them and increment
capture counter. This pos must be a *real* position value, not an x,y tuple."""
if color is None:
color = self.to_move
2012-04-14 06:26:17 +00:00
# If we get here, we've definitely played a move,
# clearing any existing ko point
self.ko = None
# Who are we capturing
who = self._other_color(color)
2012-04-14 06:26:17 +00:00
captures = 0
for p in self._neighbors(pos):
if not self._on_board(p):
continue
2012-04-14 06:26:17 +00:00
if not self._num_liberties(p, who):
captures += self._delete_group(p)
if color == Goban.BLACK:
2012-04-14 06:26:17 +00:00
self.black_captures += captures
elif color == Goban.WHITE:
2012-04-14 06:26:17 +00:00
self.white_captures += captures
# Check for ko
2012-04-18 21:57:58 +00:00
if captures == 1 and self._num_liberties(pos, color) == 1:
2012-04-14 06:26:17 +00:00
# find the empty point
for p in self._neighbors(pos):
if self.board[p] == Goban.EMPTY:
self.ko = p
break
def _valid_move(self, pos, color=None):
if not self._on_board(pos):
return False
if color is None:
color = self.to_move
2012-04-14 06:26:17 +00:00
# Can't play atop another stone or on the ko point
if self.board[pos] != Goban.EMPTY or pos == self.ko:
return False
# Temporarily place the stone
self.board[pos] = color
liberties = self._num_liberties(pos, color)
opponent = self._other_color(color)
kills_group = False
for d in self._neighbors(pos):
if not self._on_board(d):
continue
2012-04-14 06:26:17 +00:00
if self._num_liberties(d, opponent) == 0:
kills_group = True
break
# Remove temporary stone
self.board[pos] = Goban.EMPTY
return liberties > 0 or kills_group
# Recursively find whether there are liberties for the group
2012-04-14 06:26:17 +00:00
# at pos.
def _num_liberties(self, pos, who):
if not self._on_board(pos) or self.board[pos] != who:
return -1
bs = self.board_size * self.board_size
checked = [False] * bs
2012-04-14 06:26:17 +00:00
return self._num_liberties_r(pos, who, checked)
2012-04-14 06:26:17 +00:00
def _num_liberties_r(self, pos, who, checked=None):
if checked[pos]:
return 0
else:
checked[pos] = True
square = self.board[pos]
if square == Goban.EMPTY:
return 1
elif square != who:
return 0
else:
liberties = 0
for d in self._neighbors(pos):
2012-04-14 06:26:17 +00:00
liberties += self._num_liberties_r(d, who, checked)
return liberties
# We don't need to worry about crossing ourselves with the
# recursion here, because we've already deleted the stone.
def _delete_group(self, pos):
if not self._on_board(pos):
return
who = self.board[pos]
if who == Goban.EMPTY:
return 0
return self._delete_group_r(pos, who)
def _delete_group_r(self, pos, who):
if not self._on_board(pos):
return 0
if self.board[pos] != who:
return 0
self.board[pos] = Goban.EMPTY
self._changed.append(pos)
num_deleted = 1
num_deleted += self._delete_group_r(pos + 1, who)
num_deleted += self._delete_group_r(pos - 1, who)
num_deleted += self._delete_group_r(pos + self.board_size, who)
num_deleted += self._delete_group_r(pos - self.board_size, who)
return num_deleted
2012-04-17 22:07:04 +00:00
def draw_code(self, pos):
if not self._on_board(pos):
return None
point = self.board[pos]
code = None
2012-04-17 22:07:04 +00:00
if point == Goban.EMPTY or point == Goban.SCORE_DAME:
2012-04-17 22:07:04 +00:00
code = self.def_draw_codes[pos]
elif point == Goban.BLACK:
code = 'b'
if pos == self.last_move:
code += 'Cw'
2012-04-17 22:07:04 +00:00
elif point == Goban.WHITE:
code = 'w'
if pos == self.last_move:
code += 'Cb'
elif point == Goban.SCORE_WHITE:
code = self.def_draw_codes[pos] + 'ws'
elif point == Goban.SCORE_BLACK:
code = self.def_draw_codes[pos] + 'bs'
2012-04-17 22:07:04 +00:00
if pos == self.ko:
code += 'S'
2012-04-17 22:07:04 +00:00
return code
def auto_score(self):
'''
Detects regions and assigns them to the appropriate player
This may not always guess correctly, so we also have API that
can manually change these things.
After calling this function, the entire board should be redrawn.
'''
for i in range(len(self.board)):
if self.board[i] == Goban.EMPTY:
bs = self.board_size * self.board_size
checked = set()
score = self._score_space(i, checked)
for c in checked:
self.board[c] = score
def _score_space(self, pos, checked):
if pos in checked:
return None
if self.board[pos] == Goban.BLACK:
return Goban.SCORE_BLACK
elif self.board[pos] == Goban.WHITE:
return Goban.SCORE_WHITE
checked.add(pos)
possible = set()
for i in self._neighbors(pos):
score = self._score_space(i, checked)
possible.add(score)
if Goban.SCORE_DAME in possible or (Goban.SCORE_BLACK in possible and Goban.SCORE_WHITE in possible):
return Goban.SCORE_DAME
elif Goban.SCORE_BLACK in possible:
return Goban.SCORE_BLACK
elif Goban.SCORE_WHITE in possible:
return Goban.SCORE_WHITE
else:
return None
def _make_default_draw_codes(self):
ret = []
for pos in range(len(self.board)):
if pos == 0:
ret.append('ul')
elif pos == self.board_size - 1:
ret.append('ur')
elif pos == self.board_size * self.board_size - self.board_size:
ret.append('dl')
elif pos == self.board_size * self.board_size - 1:
ret.append('dr')
elif pos in [60, 66, 72, 174, 180, 186, 288, 294, 300]:
ret.append('h')
elif pos < self.board_size - 1:
ret.append('u')
elif pos % self.board_size == 0:
ret.append('l')
elif pos > (self.board_size * self.board_size - self.board_size - 1):
ret.append('d')
elif pos % self.board_size == 18:
ret.append('r')
else:
ret.append('m')
return ret
def _real_pos(self, pos):
x,y = pos
return x * self.board_size + y
def _on_board(self, pos):
return pos >= 0 and pos < self.board_size * self.board_size
def _other_color(self, color):
if color == Goban.BLACK:
return Goban.WHITE
elif color == Goban.WHITE:
return Goban.BLACK
def _neighbors(self, pos):
neighbors = []
if pos >= self.board_size:
neighbors.append(pos - self.board_size)
if pos <= self.board_size * self.board_size - self.board_size - 1:
neighbors.append(pos + self.board_size)
if pos % self.board_size != 0:
neighbors.append(pos - 1)
if (pos + 1) % self.board_size != 0:
neighbors.append(pos + 1)
return neighbors
2012-04-24 01:19:56 +00:00
# Convert an sgf vector to a move tuple
def _sgf_to_move(self, move):
if move is None:
return None
2012-04-24 01:19:56 +00:00
x,y = move
new_x = self.board_size - 1 - x
return (new_x, y)
# Convert a 1-dimensional position to an sgf move
def _pos_to_sgf(self, pos):
x = self.board_size - 1 + (pos / self.board_size)
y = pos % self.board_size
return (x,y)