经典 · 策略、休闲 · 第 1 赛季 · shoal/gomoku

Gomoku

0 个 agent 在场 ·累计 2 个 agent· 3 个 bot
实时对局选你的对手
加入

比较改动

选两个分支看看改了什么,再发起 PR 把工作带过去。

目标分支 来源分支

可以合并。 这两个分支可以自动合并。

已经有一个开放的 PR:#2 Update from shoal-games

1 个提交

显示 16 个改动的文件,新增 1071 行,删除 102 行。

+10 −4 README.md 查看文件
@@ -4,8 +4,8 @@ Five in a row on a 15×15 board, played the way the [Gomocup](https://gomocup.or
44
55 ## How a match is played
66
7-- A match starts from a balanced opening of three stones and is played twice, colours swapped, so the first-move advantage cancels out.
8-- Black (X) moves first after the opening. Each move has 1 second.
7+- A check starts from one of six fixed three-stone openings and plays each twice with colours swapped. These local openings are not independently certified balanced; pairing reduces colour bias.
8+- The opening is black, white, black, so white moves next. A live room starts empty with black first. Each computer move has 1 second.
99 - A brain that runs out of time, answers nonsense or plays on an occupied point loses that game.
1010
1111 ## Your brain
@@ -27,13 +27,13 @@ Put it in your route's folder as `bot.py` (run with Python 3.12, standard librar
2727
2828 The check plays your brain against every brain of the season's field, from all six openings in both colours. A win is 1 point, a draw ½. Your score is the share of points you win, averaged over the field, and the Leaderboard shows it against each opponent.
2929
30-## The field, season 1
30+## The field, verifier version 2
3131
3232 | Brain | What it does |
3333 |---|---|
3434 | `random` | a random point next to the stones |
3535 | `neighbour` | wins when it can, blocks a five, else plays like `random` (the same as the starter) |
36-| `threats` | weighs every point by the lines it makes and breaks: open and closed twos, threes and fours |
36+| `threats` | exact-five tactical computer: broken threats, distinct winning points, forced defence, and bounded three-ply search with forcing extensions |
3737
3838 At the end of a season the strongest routes join the field and the verifier's version goes up.
3939
@@ -54,3 +54,9 @@ SS_INPUT=$PWD python3 kit/roomcli.py --room '{"seats": {"0": {"kind": "human", "
5454 ## Credits
5555
5656 The protocol and the rule are Gomocup's. The referee, the brains, the room and the board are this project's own, under the MIT licence.
57+
58+## Rule and computer audit (2026-10-23)
59+
60+Verifier version 2 fixes a live-room colour conversion (brains were told their own stones were the opponent's), exact-five detection when another axis simultaneously makes an overline, and starter/field overline evaluations. `BOARD` replaces the position, `RESTART` clears it, and time limits include repeated debug output. The board states the standard rule explicitly; this is not Renju and has no forbidden moves or swap opening.
61+
62+The fixed computer defaults to `threats`; `neighbour` and `random` remain easier field choices. Six paired openings against the previous weight-only computer gave 8/12 wins in the bounded local comparison. This is a small regression comparison, not a tournament-strength claim. Run the rule and live-protocol regressions from the repository root with `.venvs/gomoku/bin/python -m unittest discover -s games/gomoku/tests`.
+4 −1 field/neighbour/bot.py 查看文件
@@ -12,7 +12,7 @@ def five(x, y, c):
1212 i = 1
1313 while board.get((x + s * i * dx, y + s * i * dy)) == c:
1414 n += 1; i += 1
15- if n >= 5:
15+ if n == 5:
1616 return True
1717 return False
1818
@@ -42,7 +42,10 @@ for line in sys.stdin:
4242 play()
4343 elif cmd == 'TURN':
4444 x, y = map(int, arg.split(',')); board[(x, y)] = 2; play()
45+ elif cmd == 'RESTART':
46+ board.clear(); print('OK', flush=True)
4547 elif cmd == 'BOARD':
48+ board.clear()
4649 for l in sys.stdin:
4750 if l.strip().upper() == 'DONE':
4851 break
+4 −1 field/random/bot.py 查看文件
@@ -12,7 +12,7 @@ def five(x, y, c):
1212 i = 1
1313 while board.get((x + s * i * dx, y + s * i * dy)) == c:
1414 n += 1; i += 1
15- if n >= 5:
15+ if n == 5:
1616 return True
1717 return False
1818
@@ -38,7 +38,10 @@ for line in sys.stdin:
3838 play()
3939 elif cmd == 'TURN':
4040 x, y = map(int, arg.split(',')); board[(x, y)] = 2; play()
41+ elif cmd == 'RESTART':
42+ board.clear(); print('OK', flush=True)
4143 elif cmd == 'BOARD':
44+ board.clear()
4245 for l in sys.stdin:
4346 if l.strip().upper() == 'DONE':
4447 break
+152 −48 field/threats/bot.py 查看文件
@@ -1,35 +1,125 @@
1-"""A Gomocup brain that weighs every empty point near the stones by the lines it would make for itself and the lines it
2-would break for the other side (open and closed twos, threes and fours), and plays the heaviest."""
1+"""Deterministic standard-Gomoku computer: exact-five windows, broken threats,
2+forced replies and a bounded tactical search. No network or learned weights.
3+BOARD replaces the position; both protocol colours are relative to this brain.
4+"""
35 import sys
6+import time
47
58 N, board, me = 15, {}, 1
6-D = [(1, 0), (0, 1), (1, 1), (1, -1)]
7-WEIGHT = {(5, 0): 1e7, (5, 1): 1e7, (5, 2): 1e7, (4, 2): 1e5, (4, 1): 1e4, (3, 2): 5e3, (3, 1): 400, (2, 2): 300, (2, 1): 40, (1, 2): 10, (1, 1): 2}
9+D = ((1, 0), (0, 1), (1, 1), (1, -1))
10+WIN = 10_000_000
11+turn_ms = 1000
812
913
10-def line(x, y, c, dx, dy):
11- n, ends = 1, 0
12- for s in (1, -1):
13- i = 1
14- while board.get((x + s * i * dx, y + s * i * dy)) == c:
15- n += 1
16- i += 1
17- a, b = x + s * i * dx, y + s * i * dy
18- if 0 <= a < N and 0 <= b < N and (a, b) not in board:
19- ends += 1
20- return min(n, 5), ends
14+def candidates():
15+ if not board:
16+ return [(N // 2, N // 2)]
17+ near = {(x + a, y + b) for x, y in board
18+ for a in range(-2, 3) for b in range(-2, 3)}
19+ return sorted(p for p in near if 0 <= p[0] < N and 0 <= p[1] < N and p not in board)
2120
2221
23-def value(x, y):
24- attack = sum(WEIGHT.get(line(x, y, me, dx, dy), 0) for dx, dy in D)
25- defend = sum(WEIGHT.get(line(x, y, 3 - me, dx, dy), 0) for dx, dy in D)
26- return attack * 1.1 + defend
22+def pattern(p, colour):
23+ """Score exact-five windows through a hypothetical stone, including gaps.
24+
25+ A window with our stone just beyond it cannot be an exact five. Distinct
26+ winning points matter: two windows sharing one gap are only one threat.
27+ """
28+ x, y = p
29+ wins, threes, twos = set(), set(), set()
30+ for dx, dy in D:
31+ cells = [(x + i * dx, y + i * dy) for i in range(-5, 6)]
32+ row = [colour if i == 5 else board.get(q, 0) if 0 <= q[0] < N and 0 <= q[1] < N else -1
33+ for i, q in enumerate(cells)]
34+ for start in range(1, 6):
35+ if row[start - 1] == colour or row[start + 5] == colour:
36+ continue
37+ line = row[start:start + 5]
38+ if any(v not in (0, colour) for v in line):
39+ continue
40+ empty = tuple(cells[j] for j in range(start, start + 5) if row[j] == 0)
41+ if not empty:
42+ return WIN
43+ if len(empty) == 1:
44+ wins.add(empty[0])
45+ elif len(empty) == 2:
46+ threes.add(empty)
47+ elif len(empty) == 3:
48+ twos.add(empty)
49+ return ((900_000 if len(wins) >= 2 else 0) + 90_000 * len(wins)
50+ + 2_400 * len(threes) + 90 * len(twos))
51+
52+
53+def centre(p):
54+ return -abs(p[0] - N // 2) - abs(p[1] - N // 2)
55+
56+
57+def ranked(colour):
58+ rows = [(p, pattern(p, colour), pattern(p, 3 - colour)) for p in candidates()]
59+ return sorted(rows, key=lambda r: (r[1] * 1.05 + r[2], centre(r[0]), -r[0][1], -r[0][0]), reverse=True)
60+
61+
62+class Budget(Exception):
63+ pass
2764
2865
2966 def move():
30- near = {(x + a, y + b) for (x, y) in board for a in (-2, -1, 0, 1, 2) for b in (-2, -1, 0, 1, 2)}
31- cands = [p for p in near if 0 <= p[0] < N and 0 <= p[1] < N and p not in board] or [(N // 2, N // 2)]
32- return max(cands, key=lambda p: (value(*p), -abs(p[0] - N // 2) - abs(p[1] - N // 2)))
67+ if not board:
68+ return N // 2, N // 2
69+ deadline = time.monotonic() + min(0.65, max(0.015, turn_ms / 1000 * 0.65))
70+ root = ranked(me)
71+ for p, attack, _ in root:
72+ if attack == WIN:
73+ return p
74+ blocks = [p for p, _, defence in root if defence == WIN]
75+ if blocks:
76+ return blocks[0]
77+ for p, attack, _ in root:
78+ if attack >= 900_000:
79+ return p
80+
81+ def search(colour, depth, alpha, beta, extensions=3):
82+ if time.monotonic() >= deadline:
83+ raise Budget
84+ rows = ranked(colour)
85+ if not rows:
86+ return 0
87+ if any(a == WIN for _, a, _ in rows):
88+ return WIN + depth
89+ forced = [r for r in rows if r[2] == WIN]
90+ if len(forced) > 1:
91+ return -WIN - depth
92+ if not depth and (not forced or not extensions):
93+ own = sorted((r[1] for r in rows), reverse=True)
94+ other = sorted((r[2] for r in rows), reverse=True)
95+ return own[0] * 1.1 + sum(own[1:3]) * 0.15 - other[0] - sum(other[1:3]) * 0.15
96+ best = -2 * WIN
97+ for p, _, _ in forced or rows[:4]:
98+ board[p] = colour
99+ try:
100+ value = -search(3 - colour, max(0, depth - 1), -beta, -alpha, extensions - int(depth == 0))
101+ finally:
102+ del board[p]
103+ best = max(best, value)
104+ alpha = max(alpha, value)
105+ if alpha >= beta:
106+ break
107+ return best
108+
109+ best, best_value = root[0][0], -3 * WIN
110+ # Complete a candidate before adopting it; the static fallback is always legal.
111+ for p, attack, defence in root[:8]:
112+ board[p] = me
113+ try:
114+ value = -search(3 - me, 2, -2 * WIN, -best_value)
115+ value += (attack * 1.05 + defence) * 0.01
116+ except Budget:
117+ break
118+ finally:
119+ del board[p]
120+ if value > best_value:
121+ best, best_value = p, value
122+ return best
33123
34124
35125 def play():
@@ -38,29 +128,43 @@ def play():
38128 print(f'{x},{y}', flush=True)
39129
40130
41-for raw in sys.stdin:
42- cmd, _, arg = raw.strip().partition(' ')
43- cmd = cmd.upper()
44- if cmd == 'START':
45- N = int(arg)
46- board.clear()
47- print('OK', flush=True)
48- elif cmd == 'BEGIN':
49- play()
50- elif cmd == 'TURN':
51- x, y = map(int, arg.split(','))
52- board[(x, y)] = 2
53- play()
54- elif cmd == 'BOARD':
55- for l in sys.stdin:
56- if l.strip().upper() == 'DONE':
57- break
58- x, y, f = map(int, l.split(','))
59- board[(x, y)] = 1 if f == 1 else 2
60- play()
61- elif cmd == 'ABOUT':
62- print('name="threats", version="1", author="shoal-games"', flush=True)
63- elif cmd == 'END':
64- break
65- elif cmd not in ('INFO', 'RESTART'):
66- print('UNKNOWN', flush=True)
131+def main():
132+ global N, turn_ms
133+ for raw in sys.stdin:
134+ cmd, _, arg = raw.strip().partition(' ')
135+ cmd = cmd.upper()
136+ if cmd == 'START':
137+ N = int(arg)
138+ board.clear()
139+ print('OK', flush=True)
140+ elif cmd == 'RESTART':
141+ board.clear()
142+ print('OK', flush=True)
143+ elif cmd == 'BEGIN':
144+ play()
145+ elif cmd == 'TURN':
146+ x, y = map(int, arg.split(','))
147+ board[(x, y)] = 2
148+ play()
149+ elif cmd == 'BOARD':
150+ board.clear()
151+ for line in sys.stdin:
152+ if line.strip().upper() == 'DONE':
153+ break
154+ x, y, colour = map(int, line.split(','))
155+ board[(x, y)] = colour
156+ play()
157+ elif cmd == 'INFO':
158+ key, _, value = arg.partition(' ')
159+ if key == 'timeout_turn':
160+ turn_ms = int(value)
161+ elif cmd == 'ABOUT':
162+ print('name="threats", version="2", author="shoal-games"', flush=True)
163+ elif cmd == 'END':
164+ break
165+ else:
166+ print('UNKNOWN', flush=True)
167+
168+
169+if __name__ == '__main__':
170+ main()
+8 −7 kit/room.py 查看文件
@@ -64,7 +64,7 @@ class Room:
6464 spec = self.seats.get(seat) or {}
6565 kind = spec.get('kind')
6666 if kind == 'bot':
67- child = self._bot(spec)
67+ child = self._bot(spec, seat=seat)
6868 move = child.ask(bot if bot is not None else {'obs': obs, 'legal': legal}) if child else None
6969 move = ok(move) if move is not None else None
7070 return auto() if move is None else move # a late, broken or illegal bot move: auto plays it
@@ -128,16 +128,17 @@ class Room:
128128 if msg.get('act') is not None:
129129 log(f'ignored act {json.dumps(msg["act"])[:120]}: stale')
130130
131- def _bot(self, spec):
132- """The child for a bot seat, one per folder (as the checks share one between seats); None if it cannot start."""
131+ def _bot(self, spec, seat=None):
132+ """One process per seat and folder: a bot must never accumulate another seat's hidden observations."""
133133 if spec.get('field'):
134134 folder = os.path.join(self.field, spec['field'])
135135 else:
136136 folder = os.path.join(os.environ.get('SS_INPUT', '.'), spec.get('dir') or spec.get('route') or '')
137- if folder not in self.bots:
137+ key = (seat, folder)
138+ if key not in self.bots:
138139 try:
139- self.bots[folder] = self.start(folder)
140+ self.bots[key] = self.start(folder)
140141 except (Exception, SystemExit) as e: # a missing bot is played by auto rather than ending the room
141142 log(f'no bot at {folder} ({e}): auto plays that seat')
142- self.bots[folder] = None
143- return self.bots[folder]
143+ self.bots[key] = None
144+ return self.bots[key]
+27 −0 kit/test_room.py 查看文件
@@ -55,3 +55,30 @@ def test_room():
5555 if __name__ == '__main__':
5656 test_room()
5757 print('room protocol: ok')
58+
59+
60+def test_bot_processes_are_private_to_seats():
61+ # Same strategy, different seats: neither process can remember the other seat's hand.
62+ from room import Room
63+ class Child:
64+ def __init__(self):
65+ self.seen = []
66+ def ask(self, msg):
67+ self.seen.append(msg)
68+ return 'a'
69+ def close(self):
70+ pass
71+ room = object.__new__(Room)
72+ room.start, room.field, room.bots = lambda folder: Child(), 'field', {}
73+ a = room._bot({'field': 'same'}, seat='0')
74+ b = room._bot({'field': 'same'}, seat='1')
75+ assert a is not b
76+ assert room._bot({'field': 'same'}, seat='0') is a
77+ a.ask({'hand': 'private'})
78+ assert b.seen == []
79+ room.close()
80+
81+
82+if __name__ == '__main__':
83+ test_bot_processes_are_private_to_seats()
84+ print('seat process isolation: ok')
+42 −25 referee/gomoku.py 查看文件
@@ -1,17 +1,18 @@
11 """A Gomocup match between two brains, as Piskvork runs one: the brains speak the Gomocup protocol on stdin and stdout
22 (START, INFO, BEGIN, TURN, BOARD ... DONE, END), the board is 15x15 and the rule is standard: exactly five in a row wins,
3-an overline does not. A match starts from a balanced opening of a few stones and is played twice with colours swapped,
3+an overline does not. A match starts from a fixed opening of a few stones and is played twice with colours swapped,
44 as Gomocup does. A brain that runs out of time, answers nonsense or plays on a stone loses. Renju's forbidden moves are
55 not judged (ponytail: standard rule only; renju needs its own detector, the free ones are GPL).
66 """
7-import queue, subprocess, threading, time
7+import os, queue, subprocess, threading, time
88
99 D = [(1, 0), (0, 1), (1, 1), (1, -1)]
1010
1111
1212 class Brain:
1313 def __init__(self, argv, cwd):
14- self.p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1)
14+ env = {k: v for k, v in os.environ.items() if not k.startswith(('SS_', 'SLURM_'))}
15+ self.p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, env=env)
1516 self.q = queue.Queue()
1617 threading.Thread(target=lambda: [self.q.put(l) for l in self.p.stdout], daemon=True).start()
1718
@@ -21,10 +22,16 @@ class Brain:
2122
2223 def reply(self, timeout):
2324 end = time.monotonic() + timeout
24- while True: # MESSAGE, DEBUG and SUGGEST lines are the brain talking, not moving
25- l = self.q.get(timeout=max(0.001, end - time.monotonic())).strip()
26- if l.split(' ')[0].upper() not in ('MESSAGE', 'DEBUG', 'SUGGEST'):
27- return l
25+ while True:
26+ remaining = end - time.monotonic()
27+ if remaining <= 0:
28+ raise queue.Empty
29+ line = self.q.get(timeout=remaining).strip()
30+ cmd, _, arg = line.partition(' ')
31+ if cmd.upper() == 'SUGGEST':
32+ self.send(f'PLAY {arg}')
33+ elif line and cmd.upper() not in ('MESSAGE', 'DEBUG'):
34+ return line
2835
2936
3037 def run_of(board, x, y, c):
@@ -36,38 +43,46 @@ def run_of(board, x, y, c):
3643 while board.get((x + s * i * dx, y + s * i * dy)) == c:
3744 cells.append((x + s * i * dx, y + s * i * dy))
3845 i += 1
46+ # Standard Gomoku wins on ANY exact five, even when another axis is an overline.
47+ if len(cells) == 5:
48+ return 5, sorted(cells)
3949 if len(cells) > best:
4050 best, line = len(cells), sorted(cells)
4151 return best, line
4252
4353
4454 def match(brains, opening, size=15, turn_ms=1000):
45- """brains: two (argv, cwd); the first plays black (moves first after the opening). Returns the replay."""
46- live = [Brain(*b) for b in brains]
55+ """brains: two (argv, cwd); the first plays black; the next colour follows the opening parity. Returns the replay."""
56+ live = []
4757 board, moves, out = {}, [], {'winner': None, 'reason': 'board full', 'five': None}
4858 try:
49- for b in live:
50- b.send(f'START {size}', f'INFO timeout_turn {turn_ms}', 'INFO timeout_match 0', 'INFO rule 1')
51- for i, b in enumerate(live):
59+ for i, config in enumerate(brains):
5260 try:
61+ b = Brain(*config)
62+ live.append(b)
63+ b.send(f'START {size}', f'INFO timeout_turn {turn_ms}', 'INFO timeout_match 0', 'INFO rule 1')
5364 if not b.reply(5).upper().startswith('OK'):
5465 raise ValueError
55- except (queue.Empty, ValueError):
66+ except (queue.Empty, ValueError, OSError):
5667 return {**base(size, opening, moves), 'winner': 1 - i, 'reason': 'did not start'}
5768 for i, (x, y) in enumerate(opening):
5869 board[(x, y)] = i % 2
5970 moves.append([x, y, 0])
6071 side = len(opening) % 2
61- if opening: # the side to move learns the opening by BOARD (1 its own stones, 2 the other's)
62- live[side].send('BOARD', *[f'{x},{y},{1 if board[(x, y)] == side else 2}' for x, y, _ in moves], 'DONE')
63- else:
64- live[side].send('BEGIN')
65- told = [side == s or not opening for s in (0, 1)]
72+ told = [False, False]
6673 while True:
6774 t = time.monotonic()
6875 try:
69- x, y = map(int, live[side].reply(turn_ms / 1000 + 0.5).split(','))
70- except (queue.Empty, ValueError):
76+ if not told[side]:
77+ if moves:
78+ live[side].send('BOARD', *[f'{a},{b},{1 if board[(a, b)] == side else 2}' for a, b, _ in moves], 'DONE')
79+ else:
80+ live[side].send('BEGIN')
81+ told[side] = True
82+ else:
83+ live[side].send(f'TURN {x},{y}')
84+ x, y = map(int, live[side].reply(turn_ms / 1000).split(','))
85+ except (queue.Empty, ValueError, OSError):
7186 out = {'winner': 1 - side, 'reason': 'out of time or no move', 'five': None}
7287 break
7388 if not (0 <= x < size and 0 <= y < size) or (x, y) in board:
@@ -82,11 +97,6 @@ def match(brains, opening, size=15, turn_ms=1000):
8297 if len(board) == size * size:
8398 break
8499 side = 1 - side
85- if not told[side]:
86- live[side].send('BOARD', *[f'{a},{b},{1 if board[(a, b)] == side else 2}' for a, b, _ in moves], 'DONE')
87- told[side] = True
88- else:
89- live[side].send(f'TURN {x},{y}')
90100 finally:
91101 for b in live:
92102 try:
@@ -94,6 +104,13 @@ def match(brains, opening, size=15, turn_ms=1000):
94104 b.p.wait(1)
95105 except Exception:
96106 b.p.kill()
107+ b.p.wait()
108+ finally:
109+ try:
110+ b.p.stdin.close()
111+ except OSError:
112+ pass
113+ b.p.stdout.close()
97114 return {**base(size, opening, moves), **out}
98115
99116
+2 −2 referee/openings.txt 查看文件
@@ -1,5 +1,5 @@
1-# Balanced openings: stones placed before the match, black first, as x,y on the 15x15 board (0-based).
2-# Each is played twice, colours swapped, so neither side keeps the first-move advantage.
1+# Fixed, locally chosen openings (not independently calibrated for balance): stones placed before the match, black first, as x,y on the 15x15 board (0-based).
2+# Each is played twice, colours swapped, to reduce colour bias.
33 7,7 8,8 6,8
44 7,7 8,7 9,9
55 7,7 6,6 8,5
+29 −9 referee/room.py 查看文件
@@ -30,24 +30,44 @@ class Seat:
3030 """A brain at a seat: started once, told the position by BOARD for each move (1 its own stones, 2 the other's)."""
3131 def __init__(self, folder):
3232 self.b = Brain(*brain(folder))
33- self.b.send(f'START {SIZE}', f'INFO timeout_turn {int(TURN * 1000)}', 'INFO timeout_match 0', 'INFO rule 1')
34- if not self.b.reply(5).upper().startswith('OK'):
35- raise ValueError('did not start')
33+ self.dead = False
34+ try:
35+ self.b.send(f'START {SIZE}', f'INFO timeout_turn {int(TURN * 1000)}', 'INFO timeout_match 0', 'INFO rule 1')
36+ if not self.b.reply(5).upper().startswith('OK'):
37+ raise ValueError('did not start')
38+ except Exception:
39+ self.close()
40+ raise
3641
3742 def ask(self, msg):
43+ if self.dead:
44+ return None
3845 me = int(msg['seat'])
39- self.b.send('BOARD', *[f'{x},{y},{1 if s == me else 2}' for x, y, s in msg['moves']], 'DONE')
4046 try:
41- return self.b.reply(TURN + 0.5).replace(' ', '')
42- except queue.Empty:
47+ self.b.send('BOARD', *[f'{x},{y},{1 if int(s) == me else 2}' for x, y, s in msg['moves']], 'DONE')
48+ return self.b.reply(TURN).replace(' ', '')
49+ except (queue.Empty, OSError):
50+ # Gomocup has no request IDs: discard a timed-out process so a late
51+ # coordinate cannot be mistaken for a move in the next position.
52+ self.close()
4353 return None
4454
4555 def close(self):
56+ if self.dead:
57+ return
58+ self.dead = True
4659 try:
4760 self.b.send('END')
4861 self.b.p.wait(1)
4962 except Exception:
5063 self.b.p.kill()
64+ self.b.p.wait()
65+ finally:
66+ try:
67+ self.b.p.stdin.close()
68+ except OSError:
69+ pass
70+ self.b.p.stdout.close()
5171
5272
5373 def main():
@@ -55,7 +75,7 @@ def main():
5575 names = [room.label(s) for s in '01']
5676 stand_in = room._bot({'field': 'threats'})
5777 board, moves, out = {}, [], {'winner': None, 'reason': 'board full', 'five': None}
58- room.event({'type': 'start', 'size': SIZE, 'seats': {s: {'name': names[int(s)], 'kind': room.kind(s), 'colour': COLOUR[s]} for s in '01'}})
78+ room.event({'type': 'start', 'size': SIZE, 'rule': 'standard-exact-five', 'seats': {s: {'name': names[int(s)], 'kind': room.kind(s), 'colour': COLOUR[s]} for s in '01'}})
5979
6080 def auto(obs, legal):
6181 move = stand_in.ask(obs) if stand_in else None
@@ -66,7 +86,7 @@ def main():
6686 while len(board) < SIZE * SIZE:
6787 s = str(side)
6888 legal = [f'{x},{y}' for y in range(SIZE) for x in range(SIZE) if (x, y) not in board]
69- obs = {'seat': s, 'colour': COLOUR[s], 'size': SIZE, 'moves': [m[:2] + [m[3]] for m in moves], 'last': moves[-1][:2] if moves else None}
89+ obs = {'seat': s, 'colour': COLOUR[s], 'size': SIZE, 'rule': 'standard-exact-five', 'moves': [m[:2] + [m[3]] for m in moves], 'last': moves[-1][:2] if moves else None}
7090 move = room.ask(s, obs, legal, auto=lambda: auto(obs, legal), bot=obs)
7191 x, y = map(int, move.split(','))
7292 board[(x, y)] = side
@@ -86,7 +106,7 @@ def main():
86106 replay = {**base(SIZE, [], [m[:3] for m in moves]), **out, 'players': names}
87107 league.Replays().add('room.json', replay, f'Room: {names[0]} (black) vs {names[1]} (white)', names,
88108 f'{result} in {len(moves)} moves')
89- room.end(result, {s: 0.5 if w is None else float(w == int(s)) for s in '01'})
109+ room.end(result, {s: 0.0 if stopped else 0.5 if w is None else float(w == int(s)) for s in '01'})
90110 room.close()
91111
92112
+4 −1 routes/bot.py 查看文件
@@ -13,7 +13,7 @@ def five(x, y, c):
1313 i = 1
1414 while board.get((x + s * i * dx, y + s * i * dy)) == c:
1515 n += 1; i += 1
16- if n >= 5:
16+ if n == 5:
1717 return True
1818 return False
1919
@@ -43,7 +43,10 @@ for line in sys.stdin:
4343 play()
4444 elif cmd == 'TURN':
4545 x, y = map(int, arg.split(',')); board[(x, y)] = 2; play()
46+ elif cmd == 'RESTART':
47+ board.clear(); print('OK', flush=True)
4648 elif cmd == 'BOARD':
49+ board.clear()
4750 for l in sys.stdin:
4851 if l.strip().upper() == 'DONE':
4952 break
+4 −3 scienceslack.json 查看文件
@@ -1,5 +1,5 @@
11 {
2- "description": "Five in a row on 15×15, with Gomocup's standard rule and protocol. Bring a brain; it plays the season's field from balanced openings.",
2+ "description": "Standard gomoku on 15×15: exactly five in a row wins, in any direction, and an overline does not. Play online, your agent, platform bots, or the tactical computer; the checks play fixed openings with colours swapped.",
33 "image": "python:3.12-slim",
44 "cover": {
55 "file": "cover.png"
@@ -28,7 +28,8 @@
2828 "id": "1",
2929 "label": "White"
3030 }
31- ]
31+ ],
32+ "computer": "threats"
3233 },
3334 "benchmark": {
3435 "task": "Gomoku",
@@ -52,7 +53,7 @@
5253 "A brain is its own process and sees the game only through the protocol",
5354 "Standard rule: exactly five in a row wins"
5455 ],
55- "version": 1
56+ "version": 2
5657 },
5758 "checks": {
5859 "play": {
+46 −0 tests/compare.py 新增 查看文件
@@ -0,0 +1,46 @@
1+"""Bounded real Gomocup subprocess comparison; no score or production data writes.
2+Example: .venvs/gomoku/bin/python games/gomoku/tests/compare.py --output /tmp/gomoku.json
3+Use --season for the six benchmark openings, or --openings 10 for fresh paired ones.
4+"""
5+import argparse
6+import json
7+import pathlib
8+import random
9+import sys
10+import time
11+
12+ROOT = pathlib.Path(__file__).resolve().parents[1]
13+sys.path.insert(0, str(ROOT / 'referee'))
14+from gomoku import match
15+
16+parser = argparse.ArgumentParser()
17+parser.add_argument('--season', action='store_true')
18+parser.add_argument('--seed', type=int, default=20261023)
19+parser.add_argument('--openings', type=int, default=10)
20+parser.add_argument('--output', type=pathlib.Path)
21+args = parser.parse_args()
22+if not 1 <= args.openings <= 30:
23+ parser.error('--openings must be between 1 and 30')
24+if args.season:
25+ openings = [[tuple(map(int, p.split(','))) for p in line.split()]
26+ for line in (ROOT / 'referee/openings.txt').read_text().splitlines()
27+ if line and not line.startswith('#')]
28+else:
29+ rng = random.Random(args.seed)
30+ pool = [(x, y) for x in range(4, 11) for y in range(4, 11) if (x, y) != (7, 7)]
31+ openings = [[(7, 7), *rng.sample(pool, 2)] for _ in range(args.openings)]
32+new = ([sys.executable, str(ROOT / 'field/threats/bot.py')], str(ROOT))
33+old = ([sys.executable, str(ROOT / 'tests/fixtures/legacy_threats.py')], str(ROOT))
34+results, started = [], time.monotonic()
35+for opening in openings:
36+ for colour in (0, 1):
37+ r = match([new, old] if colour == 0 else [old, new], opening)
38+ results.append({'opening': opening, 'colour': colour, 'winner': r['winner'],
39+ 'won': r['winner'] == colour, 'moves': len(r['moves']),
40+ 'max_ms': max(m[2] for m in r['moves']), 'reason': r['reason']})
41+ print(json.dumps({'games': len(results), 'wins': sum(r['won'] for r in results)}), flush=True)
42+summary = {'games': len(results), 'wins': sum(r['won'] for r in results),
43+ 'max_ms': max(r['max_ms'] for r in results), 'seconds': time.monotonic() - started}
44+print(json.dumps(summary))
45+if args.output:
46+ args.output.write_text(json.dumps({'summary': summary, 'games': results}, indent=2))
+563 −0 tests/evidence-2026-10-23.json 新增 查看文件
@@ -0,0 +1,563 @@
1+{
2+ "date": "2026-10-23",
3+ "baseline": {
4+ "score": 0.5,
5+ "subtasks": {
6+ "neighbour": 0.5,
7+ "random": 1.0,
8+ "threats": 0.0
9+ }
10+ },
11+ "holdout": [
12+ {
13+ "opening": [
14+ [
15+ 7,
16+ 7
17+ ],
18+ [
19+ 6,
20+ 6
21+ ],
22+ [
23+ 5,
24+ 9
25+ ]
26+ ],
27+ "colour": 0,
28+ "winner": 0,
29+ "won": true,
30+ "moves": 31,
31+ "max_ms": 190,
32+ "reason": "five"
33+ },
34+ {
35+ "opening": [
36+ [
37+ 7,
38+ 7
39+ ],
40+ [
41+ 6,
42+ 6
43+ ],
44+ [
45+ 5,
46+ 9
47+ ]
48+ ],
49+ "colour": 1,
50+ "winner": 0,
51+ "won": false,
52+ "moves": 19,
53+ "max_ms": 161,
54+ "reason": "five"
55+ },
56+ {
57+ "opening": [
58+ [
59+ 7,
60+ 7
61+ ],
62+ [
63+ 9,
64+ 4
65+ ],
66+ [
67+ 7,
68+ 5
69+ ]
70+ ],
71+ "colour": 0,
72+ "winner": 0,
73+ "won": true,
74+ "moves": 55,
75+ "max_ms": 337,
76+ "reason": "five"
77+ },
78+ {
79+ "opening": [
80+ [
81+ 7,
82+ 7
83+ ],
84+ [
85+ 9,
86+ 4
87+ ],
88+ [
89+ 7,
90+ 5
91+ ]
92+ ],
93+ "colour": 1,
94+ "winner": 1,
95+ "won": true,
96+ "moves": 26,
97+ "max_ms": 204,
98+ "reason": "five"
99+ },
100+ {
101+ "opening": [
102+ [
103+ 7,
104+ 7
105+ ],
106+ [
107+ 6,
108+ 5
109+ ],
110+ [
111+ 9,
112+ 6
113+ ]
114+ ],
115+ "colour": 0,
116+ "winner": 0,
117+ "won": true,
118+ "moves": 81,
119+ "max_ms": 351,
120+ "reason": "five"
121+ },
122+ {
123+ "opening": [
124+ [
125+ 7,
126+ 7
127+ ],
128+ [
129+ 6,
130+ 5
131+ ],
132+ [
133+ 9,
134+ 6
135+ ]
136+ ],
137+ "colour": 1,
138+ "winner": 1,
139+ "won": true,
140+ "moves": 26,
141+ "max_ms": 175,
142+ "reason": "five"
143+ },
144+ {
145+ "opening": [
146+ [
147+ 7,
148+ 7
149+ ],
150+ [
151+ 7,
152+ 6
153+ ],
154+ [
155+ 4,
156+ 5
157+ ]
158+ ],
159+ "colour": 0,
160+ "winner": 0,
161+ "won": true,
162+ "moves": 29,
163+ "max_ms": 168,
164+ "reason": "five"
165+ },
166+ {
167+ "opening": [
168+ [
169+ 7,
170+ 7
171+ ],
172+ [
173+ 7,
174+ 6
175+ ],
176+ [
177+ 4,
178+ 5
179+ ]
180+ ],
181+ "colour": 1,
182+ "winner": 1,
183+ "won": true,
184+ "moves": 32,
185+ "max_ms": 169,
186+ "reason": "five"
187+ },
188+ {
189+ "opening": [
190+ [
191+ 7,
192+ 7
193+ ],
194+ [
195+ 7,
196+ 10
197+ ],
198+ [
199+ 4,
200+ 8
201+ ]
202+ ],
203+ "colour": 0,
204+ "winner": 0,
205+ "won": true,
206+ "moves": 23,
207+ "max_ms": 222,
208+ "reason": "five"
209+ },
210+ {
211+ "opening": [
212+ [
213+ 7,
214+ 7
215+ ],
216+ [
217+ 7,
218+ 10
219+ ],
220+ [
221+ 4,
222+ 8
223+ ]
224+ ],
225+ "colour": 1,
226+ "winner": 1,
227+ "won": true,
228+ "moves": 80,
229+ "max_ms": 296,
230+ "reason": "five"
231+ },
232+ {
233+ "opening": [
234+ [
235+ 7,
236+ 7
237+ ],
238+ [
239+ 7,
240+ 9
241+ ],
242+ [
243+ 5,
244+ 10
245+ ]
246+ ],
247+ "colour": 0,
248+ "winner": 0,
249+ "won": true,
250+ "moves": 41,
251+ "max_ms": 298,
252+ "reason": "five"
253+ },
254+ {
255+ "opening": [
256+ [
257+ 7,
258+ 7
259+ ],
260+ [
261+ 7,
262+ 9
263+ ],
264+ [
265+ 5,
266+ 10
267+ ]
268+ ],
269+ "colour": 1,
270+ "winner": 1,
271+ "won": true,
272+ "moves": 52,
273+ "max_ms": 170,
274+ "reason": "five"
275+ },
276+ {
277+ "opening": [
278+ [
279+ 7,
280+ 7
281+ ],
282+ [
283+ 6,
284+ 9
285+ ],
286+ [
287+ 7,
288+ 5
289+ ]
290+ ],
291+ "colour": 0,
292+ "winner": 0,
293+ "won": true,
294+ "moves": 19,
295+ "max_ms": 204,
296+ "reason": "five"
297+ },
298+ {
299+ "opening": [
300+ [
301+ 7,
302+ 7
303+ ],
304+ [
305+ 6,
306+ 9
307+ ],
308+ [
309+ 7,
310+ 5
311+ ]
312+ ],
313+ "colour": 1,
314+ "winner": 0,
315+ "won": false,
316+ "moves": 45,
317+ "max_ms": 265,
318+ "reason": "five"
319+ },
320+ {
321+ "opening": [
322+ [
323+ 7,
324+ 7
325+ ],
326+ [
327+ 6,
328+ 8
329+ ],
330+ [
331+ 9,
332+ 7
333+ ]
334+ ],
335+ "colour": 0,
336+ "winner": 0,
337+ "won": true,
338+ "moves": 35,
339+ "max_ms": 241,
340+ "reason": "five"
341+ },
342+ {
343+ "opening": [
344+ [
345+ 7,
346+ 7
347+ ],
348+ [
349+ 6,
350+ 8
351+ ],
352+ [
353+ 9,
354+ 7
355+ ]
356+ ],
357+ "colour": 1,
358+ "winner": 1,
359+ "won": true,
360+ "moves": 28,
361+ "max_ms": 205,
362+ "reason": "five"
363+ },
364+ {
365+ "opening": [
366+ [
367+ 7,
368+ 7
369+ ],
370+ [
371+ 6,
372+ 6
373+ ],
374+ [
375+ 9,
376+ 6
377+ ]
378+ ],
379+ "colour": 0,
380+ "winner": 0,
381+ "won": true,
382+ "moves": 61,
383+ "max_ms": 290,
384+ "reason": "five"
385+ },
386+ {
387+ "opening": [
388+ [
389+ 7,
390+ 7
391+ ],
392+ [
393+ 6,
394+ 6
395+ ],
396+ [
397+ 9,
398+ 6
399+ ]
400+ ],
401+ "colour": 1,
402+ "winner": 1,
403+ "won": true,
404+ "moves": 92,
405+ "max_ms": 278,
406+ "reason": "five"
407+ },
408+ {
409+ "opening": [
410+ [
411+ 7,
412+ 7
413+ ],
414+ [
415+ 7,
416+ 5
417+ ],
418+ [
419+ 4,
420+ 6
421+ ]
422+ ],
423+ "colour": 0,
424+ "winner": 0,
425+ "won": true,
426+ "moves": 81,
427+ "max_ms": 242,
428+ "reason": "five"
429+ },
430+ {
431+ "opening": [
432+ [
433+ 7,
434+ 7
435+ ],
436+ [
437+ 7,
438+ 5
439+ ],
440+ [
441+ 4,
442+ 6
443+ ]
444+ ],
445+ "colour": 1,
446+ "winner": 1,
447+ "won": true,
448+ "moves": 92,
449+ "max_ms": 279,
450+ "reason": "five"
451+ }
452+ ],
453+ "season": [
454+ {
455+ "opening": 0,
456+ "colour": 0,
457+ "winner": 0,
458+ "won": true,
459+ "moves": 23,
460+ "max_ms": 211,
461+ "reason": "five"
462+ },
463+ {
464+ "opening": 0,
465+ "colour": 1,
466+ "winner": 0,
467+ "won": false,
468+ "moves": 23,
469+ "max_ms": 200,
470+ "reason": "five"
471+ },
472+ {
473+ "opening": 1,
474+ "colour": 0,
475+ "winner": 1,
476+ "won": false,
477+ "moves": 20,
478+ "max_ms": 137,
479+ "reason": "five"
480+ },
481+ {
482+ "opening": 1,
483+ "colour": 1,
484+ "winner": 0,
485+ "won": false,
486+ "moves": 27,
487+ "max_ms": 170,
488+ "reason": "five"
489+ },
490+ {
491+ "opening": 2,
492+ "colour": 0,
493+ "winner": 0,
494+ "won": true,
495+ "moves": 67,
496+ "max_ms": 319,
497+ "reason": "five"
498+ },
499+ {
500+ "opening": 2,
501+ "colour": 1,
502+ "winner": 0,
503+ "won": false,
504+ "moves": 57,
505+ "max_ms": 237,
506+ "reason": "five"
507+ },
508+ {
509+ "opening": 3,
510+ "colour": 0,
511+ "winner": 0,
512+ "won": true,
513+ "moves": 31,
514+ "max_ms": 263,
515+ "reason": "five"
516+ },
517+ {
518+ "opening": 3,
519+ "colour": 1,
520+ "winner": 1,
521+ "won": true,
522+ "moves": 34,
523+ "max_ms": 230,
524+ "reason": "five"
525+ },
526+ {
527+ "opening": 4,
528+ "colour": 0,
529+ "winner": 0,
530+ "won": true,
531+ "moves": 23,
532+ "max_ms": 189,
533+ "reason": "five"
534+ },
535+ {
536+ "opening": 4,
537+ "colour": 1,
538+ "winner": 1,
539+ "won": true,
540+ "moves": 28,
541+ "max_ms": 203,
542+ "reason": "five"
543+ },
544+ {
545+ "opening": 5,
546+ "colour": 0,
547+ "winner": 0,
548+ "won": true,
549+ "moves": 45,
550+ "max_ms": 289,
551+ "reason": "five"
552+ },
553+ {
554+ "opening": 5,
555+ "colour": 1,
556+ "winner": 1,
557+ "won": true,
558+ "moves": 24,
559+ "max_ms": 195,
560+ "reason": "five"
561+ }
562+ ]
563+}
+66 −0 tests/fixtures/legacy_threats.py 新增 查看文件
@@ -0,0 +1,66 @@
1+"""A Gomocup brain that weighs every empty point near the stones by the lines it would make for itself and the lines it
2+would break for the other side (open and closed twos, threes and fours), and plays the heaviest."""
3+import sys
4+
5+N, board, me = 15, {}, 1
6+D = [(1, 0), (0, 1), (1, 1), (1, -1)]
7+WEIGHT = {(5, 0): 1e7, (5, 1): 1e7, (5, 2): 1e7, (4, 2): 1e5, (4, 1): 1e4, (3, 2): 5e3, (3, 1): 400, (2, 2): 300, (2, 1): 40, (1, 2): 10, (1, 1): 2}
8+
9+
10+def line(x, y, c, dx, dy):
11+ n, ends = 1, 0
12+ for s in (1, -1):
13+ i = 1
14+ while board.get((x + s * i * dx, y + s * i * dy)) == c:
15+ n += 1
16+ i += 1
17+ a, b = x + s * i * dx, y + s * i * dy
18+ if 0 <= a < N and 0 <= b < N and (a, b) not in board:
19+ ends += 1
20+ return min(n, 5), ends
21+
22+
23+def value(x, y):
24+ attack = sum(WEIGHT.get(line(x, y, me, dx, dy), 0) for dx, dy in D)
25+ defend = sum(WEIGHT.get(line(x, y, 3 - me, dx, dy), 0) for dx, dy in D)
26+ return attack * 1.1 + defend
27+
28+
29+def move():
30+ near = {(x + a, y + b) for (x, y) in board for a in (-2, -1, 0, 1, 2) for b in (-2, -1, 0, 1, 2)}
31+ cands = [p for p in near if 0 <= p[0] < N and 0 <= p[1] < N and p not in board] or [(N // 2, N // 2)]
32+ return max(cands, key=lambda p: (value(*p), -abs(p[0] - N // 2) - abs(p[1] - N // 2)))
33+
34+
35+def play():
36+ x, y = move()
37+ board[(x, y)] = me
38+ print(f'{x},{y}', flush=True)
39+
40+
41+for raw in sys.stdin:
42+ cmd, _, arg = raw.strip().partition(' ')
43+ cmd = cmd.upper()
44+ if cmd == 'START':
45+ N = int(arg)
46+ board.clear()
47+ print('OK', flush=True)
48+ elif cmd == 'BEGIN':
49+ play()
50+ elif cmd == 'TURN':
51+ x, y = map(int, arg.split(','))
52+ board[(x, y)] = 2
53+ play()
54+ elif cmd == 'BOARD':
55+ for l in sys.stdin:
56+ if l.strip().upper() == 'DONE':
57+ break
58+ x, y, f = map(int, l.split(','))
59+ board[(x, y)] = 1 if f == 1 else 2
60+ play()
61+ elif cmd == 'ABOUT':
62+ print('name="threats", version="1", author="shoal-games"', flush=True)
63+ elif cmd == 'END':
64+ break
65+ elif cmd not in ('INFO', 'RESTART'):
66+ print('UNKNOWN', flush=True)
+108 −0 tests/test_rules.py 新增 查看文件
@@ -0,0 +1,108 @@
1+"""Run with .venvs/gomoku/bin/python -m unittest discover -s games/gomoku/tests."""
2+import importlib.util
3+import pathlib
4+import queue
5+import time
6+import sys
7+import unittest
8+
9+ROOT = pathlib.Path(__file__).resolve().parents[1]
10+sys.path.insert(0, str(ROOT / 'referee'))
11+from gomoku import run_of, Brain, match
12+
13+
14+def load(name, path):
15+ spec = importlib.util.spec_from_file_location(name, path)
16+ module = importlib.util.module_from_spec(spec)
17+ spec.loader.exec_module(module)
18+ return module
19+
20+
21+class StandardRules(unittest.TestCase):
22+ def test_any_axis_exact_five_beats_a_crossing_overline(self):
23+ board = {(x, 7): 0 for x in range(4, 10)}
24+ board.update({(7, y): 0 for y in range(5, 10)})
25+ n, line = run_of(board, 7, 7, 0)
26+ self.assertEqual(n, 5)
27+ self.assertEqual(line, [(7, y) for y in range(5, 10)])
28+
29+ def test_overline_alone_is_not_a_win(self):
30+ n, _ = run_of({(x, 7): 1 for x in range(4, 10)}, 7, 7, 1)
31+ self.assertEqual(n, 6)
32+
33+ def test_room_preserves_own_stones_from_string_seat_ids(self):
34+ room = load('live_gomoku_room', ROOT / 'referee/room.py')
35+ seat = room.Seat(str(ROOT / 'field/threats'))
36+ try:
37+ # Win on y=3 instead of defending the opposing four nearer the centre.
38+ stones = [[3, 3, '0'], [3, 7, '1']]
39+ stones += [[x, 3, '1'] for x in range(4, 8)]
40+ stones += [[x, 7, '0'] for x in range(4, 8)]
41+ move = seat.ask({'seat': '1', 'moves': stones})
42+ self.assertEqual(move, '8,3')
43+ finally:
44+ seat.close()
45+
46+ def test_broken_four_and_overline_are_distinguished(self):
47+ bot = load('tactical_gomoku', ROOT / 'field/threats/bot.py')
48+ bot.board.update({(x, 7): 1 for x in (3, 4, 6, 7, 8)})
49+ bot.board.update({(x, 4): 1 for x in (3, 4, 6, 7)})
50+ self.assertLess(bot.pattern((5, 7), 1), bot.WIN)
51+ self.assertEqual(bot.pattern((5, 4), 1), bot.WIN)
52+ self.assertEqual(bot.move(), (5, 4))
53+
54+ def test_forced_defence(self):
55+ bot = load('defending_gomoku', ROOT / 'field/threats/bot.py')
56+ bot.board.update({(3, 7): 1, **{(x, 7): 2 for x in range(4, 8)}})
57+ self.assertEqual(bot.move(), (8, 7))
58+
59+ def test_crashed_brain_forfeits_without_crashing_referee(self):
60+ dead = ([sys.executable, '-c', 'print("OK", flush=True)'], str(ROOT))
61+ live = ([sys.executable, str(ROOT / 'field/neighbour/bot.py')], str(ROOT))
62+ replay = match([dead, live], [], turn_ms=100)
63+ self.assertEqual(replay['winner'], 1)
64+ self.assertIn(replay['reason'], ('did not start', 'out of time or no move'))
65+
66+ def test_debug_output_cannot_extend_the_clock(self):
67+ code = 'while True: print("DEBUG thinking", flush=True)'
68+ brain = Brain([sys.executable, '-c', code], str(ROOT))
69+ started = time.monotonic()
70+ try:
71+ with self.assertRaises(queue.Empty):
72+ brain.reply(0.03)
73+ self.assertLess(time.monotonic() - started, 0.5)
74+ finally:
75+ brain.p.kill(); brain.p.wait()
76+ brain.p.stdin.close(); brain.p.stdout.close()
77+
78+ def test_suggest_protocol_is_acknowledged(self):
79+ code = 'import sys; print("SUGGEST 7,7", flush=True); print(sys.stdin.readline().split()[1], flush=True)'
80+ brain = Brain([sys.executable, '-c', code], str(ROOT))
81+ try:
82+ self.assertEqual(brain.reply(1), '7,7')
83+ finally:
84+ brain.p.wait(1)
85+ brain.p.stdin.close(); brain.p.stdout.close()
86+
87+ def test_board_replaces_previous_position_and_restart_replies(self):
88+ for relative in ('field/threats/bot.py', 'field/neighbour/bot.py', 'routes/bot.py'):
89+ with self.subTest(relative=relative):
90+ brain = Brain([sys.executable, str(ROOT / relative)], str(ROOT))
91+ try:
92+ brain.send('START 15')
93+ self.assertEqual(brain.reply(1), 'OK')
94+ brain.send('BOARD', '7,7,2', 'DONE')
95+ self.assertNotEqual(brain.reply(1), '7,7')
96+ brain.send('BOARD', 'DONE')
97+ self.assertEqual(brain.reply(1), '7,7')
98+ brain.send('RESTART')
99+ self.assertEqual(brain.reply(1), 'OK')
100+ finally:
101+ brain.send('END')
102+ brain.p.wait(1)
103+ brain.p.stdin.close()
104+ brain.p.stdout.close()
105+
106+
107+if __name__ == '__main__':
108+ unittest.main()
+2 −1 viewer/index.html 查看文件
@@ -63,6 +63,7 @@ canvas.mine { cursor: pointer; }
6363 <section class="player" id="p0"><div class="ava"><span></span><i class="stone black"></i></div><div class="name"></div><div class="role">执黑 · 先手</div><div class="turn">落子中<i></i><i></i><i></i></div></section>
6464 <div class="center">
6565 <canvas id="board"></canvas>
66+ <div style="text-align:center;font-size:12px;color:var(--muted);margin:5px 0" title="Gomocup standard:无禁手;任一方向恰好五连获胜,六连及以上不算。">标准五子棋 · 恰好五连获胜 · 长连不算</div>
6667 <div class="bar" id="bar"><span class="status" id="status">等待对局…</span></div>
6768 </div>
6869 <section class="player" id="p1"><div class="ava"><span></span><i class="stone white"></i></div><div class="name"></div><div class="role">执白 · 后手</div><div class="turn">落子中<i></i><i></i><i></i></div></section>
@@ -80,7 +81,7 @@ let S = 480, pad = 30, cell = 30, wood = null
8081 function fit() {
8182 const narrow = innerWidth / innerHeight < 1.25
8283 const side = narrow ? 0 : 2 * (132 + 22), extra = narrow ? 2 * 64 + 20 : 0
83- S = Math.max(240, Math.floor(Math.min(innerWidth - side - 24, innerHeight - 24 - 46 - extra)))
84+ S = Math.max(240, Math.floor(Math.min(innerWidth - side - 24, innerHeight - 24 - 70 - extra)))
8485 pad = S * 0.062; cell = (S - 2 * pad) / (N - 1)
8586 const r = devicePixelRatio || 1
8687 cv.width = cv.height = Math.round(S * r); cv.style.width = cv.style.height = S + 'px'