diff --git a/setup.py b/setup.py index d73f5d6..ea92fb6 100755 --- a/setup.py +++ b/setup.py @@ -17,11 +17,12 @@ setup( long_description='', python_requires='>=3.6', install_requires=[ - 'jinja2==2.11.1', - 'bottle==0.12.18', + 'bottle==0.12.23', + 'Jinja2==3.1.2', + 'MarkupSafe==2.1.1', + 'namesgenerator==0.3', 'sudoku-manager==1.0.6', 'sudokugen==0.2.1', - 'namesgenerator==0.3' ], entry_points={ 'console_scripts': [ diff --git a/static/gogame.js b/static/gogame.js new file mode 100644 index 0000000..5c0032e --- /dev/null +++ b/static/gogame.js @@ -0,0 +1,14 @@ + +let stones = document.getElementsByClassName('go-stone-empty'); +let inputRow = document.getElementById('go-input-row'); +let inputCol = document.getElementById('go-input-col'); +let inputPlace = document.getElementById('go-input-place'); + +for (let i = 0; i < stones.length; ++i) { + stones[i].onclick = (e) => { + console.log('place'); + inputCol.value = stones[i].attributes['data-col'].value; + inputRow.value = stones[i].attributes['data-row'].value; + inputPlace.click(); + }; +} diff --git a/static/style.css b/static/style.css index 178817c..0f6649b 100644 --- a/static/style.css +++ b/static/style.css @@ -146,3 +146,65 @@ input { .playercardlist .playercard-2 { transform: rotate(10deg) scale(0.5) translate(0px, 10px); } + + +/* + * Go + */ + +.go-background { + fill: #ffdd88; +} + +.go-line { + stroke: black; + stroke-width: 3; + stroke-opacity: 1; + stroke-linecap: round; +} + +.go-hoshi, .go-territory-black { + fill: black; +} + +.go-territory-white { + fill: white; +} + +.go-stone-black { + fill: url(#black-stone); +} + +.go-stone-white { + fill: url(#white-stone); +} + +.go-stone-empty { + cursor: pointer; + opacity: 0; +} +.go-stone-empty:hover, .go-stone-dead { + opacity: 0.5; +} + +input.go-field-input { + display: none; +} + +label.go-field-label { + padding: 0; + margin: 0; +} + +label.go-field-label > svg { + padding: 0; + margin: 0; +} + +div#field { + padding: 0; +} + +input#go-input-place { + display: none; +} diff --git a/templates/cwa/gogame.html.j2 b/templates/cwa/gogame.html.j2 new file mode 100644 index 0000000..4e62c19 --- /dev/null +++ b/templates/cwa/gogame.html.j2 @@ -0,0 +1,125 @@ +{% extends 'cwa/base.html.j2' %} + +{%- block body %} +

Go: {{ game.human_id }}

+ +
+ +
+ + + + + + + + + + + + + + + {% for row in range(boardsize) %} + + {% endfor %} + {% for col in range(boardsize) %} + + {% endfor %} + {% if boardsize == 19 %} + {% for y in [3, 9, 15] %} + {% for x in [3, 9, 15] %} + + {% endfor %} + {% endfor %} + {% elif boardsize == 13 %} + {% for y, x in [[3, 3], [3, 9], [9, 3], [9, 9], [6, 6]] %} + + {% endfor %} + {% elif boardsize == 9 %} + {% for y, x in [[2, 2], [2, 6], [6, 2], [6, 6], [4, 4]] %} + + {% endfor %} + {% endif %} + {% for row in range(boardsize) %} + {% for col in range(boardsize) %} + + {% if field[row][col] == 0 and current_player == colormap[player.uuid] and not abandoned and score is none %} + + {% elif field[row][col] == 1 %} + + {% elif field[row][col] == 2 %} + + {% elif field[row][col] == 5 %} + + {% elif field[row][col] == 6 %} + + {% endif %} + {% endfor %} + {% endfor %} + {% for row in range(boardsize) %} + {% for col in range(boardsize) %} + + {% if territory[row][col] == 3 %} + + {% elif territory[row][col] == 4 %} + + {% endif %} + {% endfor %} + {% endfor %} + +
+ + {% if abandoned %} + Game abandoned + {% elif score is not none %} + + {% if score > 0 %} + Black won with {{ score }} points. + {% else %} + White won with {{ -score }} points. + {% endif %} + + {% elif current_player == colormap[player.uuid] %} + + + + + {% endif %} + +
+ + Refresh + + + + +{%- endblock %} diff --git a/templates/cwa/welcome1.html.j2 b/templates/cwa/welcome1.html.j2 index f8f9908..945065e 100644 --- a/templates/cwa/welcome1.html.j2 +++ b/templates/cwa/welcome1.html.j2 @@ -11,6 +11,7 @@ +
  • Invite other players
  • diff --git a/webgames/game.py b/webgames/game.py index 3052827..a7502d1 100644 --- a/webgames/game.py +++ b/webgames/game.py @@ -9,6 +9,7 @@ from webgames.puzzle import Puzzle from webgames.sudoku_puzzle import Sudoku from webgames.set_puzzle import SetPuzzle from webgames.carcassonne import Carcassonne +from webgames.go import GoPuzzle from webgames.player import Player from webgames.human import HumanID diff --git a/webgames/go.py b/webgames/go.py new file mode 100644 index 0000000..7eab858 --- /dev/null +++ b/webgames/go.py @@ -0,0 +1,244 @@ +from typing import Any, Dict + +import enum +from uuid import UUID +import string +import subprocess +import random +import socket +import re + +from threading import Timer, Lock + +from webgames.puzzle import Puzzle + +class BoardSize(enum.Enum): + SMALL = 9 + MEDIUM = 13 + LARGE = 19 + + +class FieldState(enum.Enum): + EMPTY = 0 + WHITE = 1 + BLACK = 2 + WHITE_TERRITORY = 3 + BLACK_TERRITORY = 4 + WHITE_DEAD = 5 + BLACK_DEAD = 6 + + +ABANDON_TIMEOUT = 3600 + + +class GoPuzzle(Puzzle): + + def __init__(self, pargs = None): + super().__init__('go', self.__class__) + self._players = {} + self.current_player = FieldState.BLACK + self.black_passed = False + self.white_passed = False + self.colormap = {} + self._score = None + self.komi = 6.5 + self.score = None + self.colnames = list(string.ascii_uppercase) + self.colnames.remove('I') + self._field = None + self._territory = None + self._abandoned = False + self._timer = None + self._tlock = Lock() + self._captures = [0, 0] + self._urlbase = '/' + + def _gtp(self, command): + self.gnugo.send(f'{command}\n'.encode()) + ret = self.gnugo.recv(1024) + while not ret.startswith(b'=') and not ret.startswith(b'?'): + ret = self.gnugo.recv(1024) + while not ret.endswith(b'\n\n'): + ret += self.gnugo.recv(1024) + ret = ret.decode() + if ret.startswith('?'): + raise RuntimeError(ret) + return ret + + def _shutdown(self): + with self._tlock: + if self.gnugo is not None: + self.gnugo.close() + self.gnugo = None + self._abandoned = True + + def begin(self, options: Dict[str, Any], urlbase): + with self._tlock: + self._timer = Timer(ABANDON_TIMEOUT, self._shutdown) + self._timer.start() + self.size = BoardSize(int(options.get('go-input-boardsize', BoardSize.MEDIUM.value))) + self.gnugo = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.gnugo.connect('/run/gnugo.sock') + self._gtp(f'boardsize {self.size.value}') + self._gtp('clear_board') + self._gtp(f'komi {self.komi}') + players = list(self._players.keys()) + random.shuffle(players) + self.colormap[players[0]] = FieldState.BLACK + self.colormap[players[1]] = FieldState.WHITE + self._field = [] + self._territory = [] + for i in range(self.size.value): + self._field.append([]) + self._territory.append([]) + for j in range(self.size.value): + self._field[i].append(0) + self._territory[i].append(0) + self._urlbase = urlbase + + def get_extra_options_html(self) -> str: + return ''' + + +''' + + def add_player(self, uuid: UUID) -> None: + if len(self._players) >= 2: + return + self._players[uuid] = uuid + + def _score_game(self): + self._timer.cancel() + # Compute final sore + ret = self._gtp('final_score') + sgf_score = ret.splitlines()[0].split(' ', 1)[1].strip() + winner, score = sgf_score.split('+', 1) + if winner == 'B': + self._score = float(score) + else: + self._score = -float(score) + # Update board with scoring result + for state in ['dead', 'black_territory', 'white_territory']: + ret = self._gtp(f'final_status_list {state}') + vertices = ret.replace('\n', ' ').split(' ') + for vertex in vertices: + if len(vertex.strip()) != 2: + continue + y, x = vertex.strip() + col = int(x) - 1 + row = self.colnames.index(y.upper()) + if state == 'dead': + if self._field[row][col] == FieldState.BLACK.value: + self._field[row][col] = FieldState.BLACK_DEAD.value + elif self._field[row][col] == FieldState.WHITE.value: + self._field[row][col] = FieldState.WHITE_DEAD.value + elif state == 'black_territory': + self._territory[row][col] = FieldState.BLACK_TERRITORY.value + elif state == 'white_territory': + self._territory[row][col] = FieldState.WHITE_TERRITORY.value + self.gnugo.close() + self.gnugo = None + + + def process_action(self, player, action) -> None: + if self.colormap[player] != self.current_player: + return + if 'go-input-submit' not in action: + return + self.current_player = FieldState(3 - self.current_player.value) + with self._tlock: + if self._abandoned: + return + self._timer.cancel() + self._timer = Timer(ABANDON_TIMEOUT, self._shutdown) + self._timer.start() + move = action['go-input-submit'] + if move == 'Pass': + if self.current_player == FieldState.BLACK: + self.black_passed = True + else: + self.white_passed = True + if self.black_passed and self.white_passed: + self._score_game() + elif move == 'Place': + self.black_passed = False + self.white_passed = False + row = int(action['go-input-row']) + col = int(action['go-input-col']) + player = self.current_player.name.lower() + try: + ret = self._gtp(f'play {player} {self.colnames[col]}{row+1}') + except RuntimeError: + self.current_player = FieldState[3 - self.current_player.value] + self.update_field() + + def update_field(self): + ret = self._gtp('showboard') + for line in ret.splitlines(): + + cm = re.match('.*(BLACK|WHITE) ... has captured (\\d+) stones.*', line) + if cm is not None: + color = FieldState[cm[1]] + self._captures[2-color.value] = int(cm[2]) + + line = line.strip() + if len(line) > 0 and line[0] in [str(i) for i in range(10)]: + cols = line.split() + row = int(cols[0]) - 1 + for col in range(self.size.value): + token = cols[col+1] + if token in ['.', '+']: + self._field[row][col] = FieldState.EMPTY.value + elif token == 'X': + self._field[row][col] = FieldState.WHITE.value + elif token == 'O': + self._field[row][col] = FieldState.BLACK.value + + def serialize(self, player): + return { + 'field': self._field, + 'territory': self._territory, + 'captures': self._captures, + 'current_player': self.current_player, + 'colormap': self.colormap + } + + + def render(self, env, game, player, duration): + with self._tlock: + tmpl = env.get_template('cwa/gogame.html.j2') + ser = self.serialize(player) + return tmpl.render(player=player, + players=self._players, + komi=self.komi, + score=self._score, + game=game, + abandoned=self._abandoned, + duration=duration, + field=ser['field'], + territory=ser['territory'], + captures=ser['captures'], + boardsize=self.size.value, + colormap=self.colormap, + current_player=self.current_player, + baseurl=self._urlbase) + + + @property + def human_name(self) -> str: + return 'Go' + + @property + def scores(self): + return {} + + @property + def is_completed(self) -> bool: + return self.black_passed and self.white_passed + + +GoPuzzle()