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 }}
+
+
+
+ Refresh
+
+
+ {% for p in players.keys() %}
+ -
+
+ {% if p == player.uuid %}
+ {{ game.players[p].name }}
+ {% else %}
+ {{ game.players[p].name }}
+ {% endif %}
+ : {{ captures[colormap[p].value-1] }}
+ {% if colormap[p].value == 1 %}
+ + {{ komi }}
+ {% endif %}
+ {% endfor %}
+
+
+
+{%- 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()