Update from shoal-games #2
Open
+10 −4 README.md View file
| @@ -4,8 +4,8 @@ Five in a row on a 15×15 board, played the way the [Gomocup](https://gomocup.or | ||
| 4 | 4 | |
| 5 | 5 | ## How a match is played |
| 6 | 6 | |
| 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. | |
| 9 | 9 | - A brain that runs out of time, answers nonsense or plays on an occupied point loses that game. |
| 10 | 10 | |
| 11 | 11 | ## Your brain |
| @@ -27,13 +27,13 @@ Put it in your route's folder as `bot.py` (run with Python 3.12, standard librar | ||
| 27 | 27 | |
| 28 | 28 | 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. |
| 29 | 29 | |
| 30 | -## The field, season 1 | |
| 30 | +## The field, verifier version 2 | |
| 31 | 31 | |
| 32 | 32 | | Brain | What it does | |
| 33 | 33 | |---|---| |
| 34 | 34 | | `random` | a random point next to the stones | |
| 35 | 35 | | `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 | | |
| 37 | 37 | |
| 38 | 38 | At the end of a season the strongest routes join the field and the verifier's version goes up. |
| 39 | 39 | |
| @@ -54,3 +54,9 @@ SS_INPUT=$PWD python3 kit/roomcli.py --room '{"seats": {"0": {"kind": "human", " | ||
| 54 | 54 | ## Credits |
| 55 | 55 | |
| 56 | 56 | 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 View file
| @@ -12,7 +12,7 @@ def five(x, y, c): | ||
| 12 | 12 | i = 1 |
| 13 | 13 | while board.get((x + s * i * dx, y + s * i * dy)) == c: |
| 14 | 14 | n += 1; i += 1 |
| 15 | - if n >= 5: | |
| 15 | + if n == 5: | |
| 16 | 16 | return True |
| 17 | 17 | return False |
| 18 | 18 | |
| @@ -42,7 +42,10 @@ for line in sys.stdin: | ||
| 42 | 42 | play() |
| 43 | 43 | elif cmd == 'TURN': |
| 44 | 44 | x, y = map(int, arg.split(',')); board[(x, y)] = 2; play() |
| 45 | + elif cmd == 'RESTART': | |
| 46 | + board.clear(); print('OK', flush=True) | |
| 45 | 47 | elif cmd == 'BOARD': |
| 48 | + board.clear() | |
| 46 | 49 | for l in sys.stdin: |
| 47 | 50 | if l.strip().upper() == 'DONE': |
| 48 | 51 | break |
+4 −1 field/random/bot.py View file
| @@ -12,7 +12,7 @@ def five(x, y, c): | ||
| 12 | 12 | i = 1 |
| 13 | 13 | while board.get((x + s * i * dx, y + s * i * dy)) == c: |
| 14 | 14 | n += 1; i += 1 |
| 15 | - if n >= 5: | |
| 15 | + if n == 5: | |
| 16 | 16 | return True |
| 17 | 17 | return False |
| 18 | 18 | |
| @@ -38,7 +38,10 @@ for line in sys.stdin: | ||
| 38 | 38 | play() |
| 39 | 39 | elif cmd == 'TURN': |
| 40 | 40 | x, y = map(int, arg.split(',')); board[(x, y)] = 2; play() |
| 41 | + elif cmd == 'RESTART': | |
| 42 | + board.clear(); print('OK', flush=True) | |
| 41 | 43 | elif cmd == 'BOARD': |
| 44 | + board.clear() | |
| 42 | 45 | for l in sys.stdin: |
| 43 | 46 | if l.strip().upper() == 'DONE': |
| 44 | 47 | break |
+152 −48 field/threats/bot.py View file
| @@ -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 | +""" | |
| 3 | 5 | import sys |
| 6 | +import time | |
| 4 | 7 | |
| 5 | 8 | 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 | |
| 8 | 12 | |
| 9 | 13 | |
| 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) | |
| 21 | 20 | |
| 22 | 21 | |
| 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 | |
| 27 | 64 | |
| 28 | 65 | |
| 29 | 66 | 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 | |
| 33 | 123 | |
| 34 | 124 | |
| 35 | 125 | def play(): |
| @@ -38,29 +128,43 @@ def play(): | ||
| 38 | 128 | print(f'{x},{y}', flush=True) |
| 39 | 129 | |
| 40 | 130 | |
| 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 View file
| @@ -64,7 +64,7 @@ class Room: | ||
| 64 | 64 | spec = self.seats.get(seat) or {} |
| 65 | 65 | kind = spec.get('kind') |
| 66 | 66 | if kind == 'bot': |
| 67 | - child = self._bot(spec) | |
| 67 | + child = self._bot(spec, seat=seat) | |
| 68 | 68 | move = child.ask(bot if bot is not None else {'obs': obs, 'legal': legal}) if child else None |
| 69 | 69 | move = ok(move) if move is not None else None |
| 70 | 70 | return auto() if move is None else move # a late, broken or illegal bot move: auto plays it |
| @@ -128,16 +128,17 @@ class Room: | ||
| 128 | 128 | if msg.get('act') is not None: |
| 129 | 129 | log(f'ignored act {json.dumps(msg["act"])[:120]}: stale') |
| 130 | 130 | |
| 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.""" | |
| 133 | 133 | if spec.get('field'): |
| 134 | 134 | folder = os.path.join(self.field, spec['field']) |
| 135 | 135 | else: |
| 136 | 136 | 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: | |
| 138 | 139 | try: |
| 139 | - self.bots[folder] = self.start(folder) | |
| 140 | + self.bots[key] = self.start(folder) | |
| 140 | 141 | except (Exception, SystemExit) as e: # a missing bot is played by auto rather than ending the room |
| 141 | 142 | 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 View file
| @@ -55,3 +55,30 @@ def test_room(): | ||
| 55 | 55 | if __name__ == '__main__': |
| 56 | 56 | test_room() |
| 57 | 57 | 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 View file
| @@ -1,17 +1,18 @@ | ||
| 1 | 1 | """A Gomocup match between two brains, as Piskvork runs one: the brains speak the Gomocup protocol on stdin and stdout |
| 2 | 2 | (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, | |
| 4 | 4 | as Gomocup does. A brain that runs out of time, answers nonsense or plays on a stone loses. Renju's forbidden moves are |
| 5 | 5 | not judged (ponytail: standard rule only; renju needs its own detector, the free ones are GPL). |
| 6 | 6 | """ |
| 7 | -import queue, subprocess, threading, time | |
| 7 | +import os, queue, subprocess, threading, time | |
| 8 | 8 | |
| 9 | 9 | D = [(1, 0), (0, 1), (1, 1), (1, -1)] |
| 10 | 10 | |
| 11 | 11 | |
| 12 | 12 | class Brain: |
| 13 | 13 | 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) | |
| 15 | 16 | self.q = queue.Queue() |
| 16 | 17 | threading.Thread(target=lambda: [self.q.put(l) for l in self.p.stdout], daemon=True).start() |
| 17 | 18 | |
| @@ -21,10 +22,16 @@ class Brain: | ||
| 21 | 22 | |
| 22 | 23 | def reply(self, timeout): |
| 23 | 24 | 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 | |
| 28 | 35 | |
| 29 | 36 | |
| 30 | 37 | def run_of(board, x, y, c): |
| @@ -36,38 +43,46 @@ def run_of(board, x, y, c): | ||
| 36 | 43 | while board.get((x + s * i * dx, y + s * i * dy)) == c: |
| 37 | 44 | cells.append((x + s * i * dx, y + s * i * dy)) |
| 38 | 45 | 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) | |
| 39 | 49 | if len(cells) > best: |
| 40 | 50 | best, line = len(cells), sorted(cells) |
| 41 | 51 | return best, line |
| 42 | 52 | |
| 43 | 53 | |
| 44 | 54 | 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 = [] | |
| 47 | 57 | board, moves, out = {}, [], {'winner': None, 'reason': 'board full', 'five': None} |
| 48 | 58 | 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): | |
| 52 | 60 | 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') | |
| 53 | 64 | if not b.reply(5).upper().startswith('OK'): |
| 54 | 65 | raise ValueError |
| 55 | - except (queue.Empty, ValueError): | |
| 66 | + except (queue.Empty, ValueError, OSError): | |
| 56 | 67 | return {**base(size, opening, moves), 'winner': 1 - i, 'reason': 'did not start'} |
| 57 | 68 | for i, (x, y) in enumerate(opening): |
| 58 | 69 | board[(x, y)] = i % 2 |
| 59 | 70 | moves.append([x, y, 0]) |
| 60 | 71 | 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] | |
| 66 | 73 | while True: |
| 67 | 74 | t = time.monotonic() |
| 68 | 75 | 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): | |
| 71 | 86 | out = {'winner': 1 - side, 'reason': 'out of time or no move', 'five': None} |
| 72 | 87 | break |
| 73 | 88 | 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): | ||
| 82 | 97 | if len(board) == size * size: |
| 83 | 98 | break |
| 84 | 99 | 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}') | |
| 90 | 100 | finally: |
| 91 | 101 | for b in live: |
| 92 | 102 | try: |
| @@ -94,6 +104,13 @@ def match(brains, opening, size=15, turn_ms=1000): | ||
| 94 | 104 | b.p.wait(1) |
| 95 | 105 | except Exception: |
| 96 | 106 | 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() | |
| 97 | 114 | return {**base(size, opening, moves), **out} |
| 98 | 115 | |
| 99 | 116 | |
+2 −2 referee/openings.txt View file
| @@ -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. | |
| 3 | 3 | 7,7 8,8 6,8 |
| 4 | 4 | 7,7 8,7 9,9 |
| 5 | 5 | 7,7 6,6 8,5 |
+29 −9 referee/room.py View file
| @@ -30,24 +30,44 @@ class Seat: | ||
| 30 | 30 | """A brain at a seat: started once, told the position by BOARD for each move (1 its own stones, 2 the other's).""" |
| 31 | 31 | def __init__(self, folder): |
| 32 | 32 | 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 | |
| 36 | 41 | |
| 37 | 42 | def ask(self, msg): |
| 43 | + if self.dead: | |
| 44 | + return None | |
| 38 | 45 | 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') | |
| 40 | 46 | 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() | |
| 43 | 53 | return None |
| 44 | 54 | |
| 45 | 55 | def close(self): |
| 56 | + if self.dead: | |
| 57 | + return | |
| 58 | + self.dead = True | |
| 46 | 59 | try: |
| 47 | 60 | self.b.send('END') |
| 48 | 61 | self.b.p.wait(1) |
| 49 | 62 | except Exception: |
| 50 | 63 | 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() | |
| 51 | 71 | |
| 52 | 72 | |
| 53 | 73 | def main(): |
| @@ -55,7 +75,7 @@ def main(): | ||
| 55 | 75 | names = [room.label(s) for s in '01'] |
| 56 | 76 | stand_in = room._bot({'field': 'threats'}) |
| 57 | 77 | 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'}}) | |
| 59 | 79 | |
| 60 | 80 | def auto(obs, legal): |
| 61 | 81 | move = stand_in.ask(obs) if stand_in else None |
| @@ -66,7 +86,7 @@ def main(): | ||
| 66 | 86 | while len(board) < SIZE * SIZE: |
| 67 | 87 | s = str(side) |
| 68 | 88 | 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} | |
| 70 | 90 | move = room.ask(s, obs, legal, auto=lambda: auto(obs, legal), bot=obs) |
| 71 | 91 | x, y = map(int, move.split(',')) |
| 72 | 92 | board[(x, y)] = side |
| @@ -86,7 +106,7 @@ def main(): | ||
| 86 | 106 | replay = {**base(SIZE, [], [m[:3] for m in moves]), **out, 'players': names} |
| 87 | 107 | league.Replays().add('room.json', replay, f'Room: {names[0]} (black) vs {names[1]} (white)', names, |
| 88 | 108 | 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'}) | |
| 90 | 110 | room.close() |
| 91 | 111 | |
| 92 | 112 | |
+4 −1 routes/bot.py View file
| @@ -13,7 +13,7 @@ def five(x, y, c): | ||
| 13 | 13 | i = 1 |
| 14 | 14 | while board.get((x + s * i * dx, y + s * i * dy)) == c: |
| 15 | 15 | n += 1; i += 1 |
| 16 | - if n >= 5: | |
| 16 | + if n == 5: | |
| 17 | 17 | return True |
| 18 | 18 | return False |
| 19 | 19 | |
| @@ -43,7 +43,10 @@ for line in sys.stdin: | ||
| 43 | 43 | play() |
| 44 | 44 | elif cmd == 'TURN': |
| 45 | 45 | x, y = map(int, arg.split(',')); board[(x, y)] = 2; play() |
| 46 | + elif cmd == 'RESTART': | |
| 47 | + board.clear(); print('OK', flush=True) | |
| 46 | 48 | elif cmd == 'BOARD': |
| 49 | + board.clear() | |
| 47 | 50 | for l in sys.stdin: |
| 48 | 51 | if l.strip().upper() == 'DONE': |
| 49 | 52 | break |
+4 −3 scienceslack.json View file
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 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.", | |
| 3 | 3 | "image": "python:3.12-slim", |
| 4 | 4 | "cover": { |
| 5 | 5 | "file": "cover.png" |
| @@ -28,7 +28,8 @@ | ||
| 28 | 28 | "id": "1", |
| 29 | 29 | "label": "White" |
| 30 | 30 | } |
| 31 | - ] | |
| 31 | + ], | |
| 32 | + "computer": "threats" | |
| 32 | 33 | }, |
| 33 | 34 | "benchmark": { |
| 34 | 35 | "task": "Gomoku", |
| @@ -52,7 +53,7 @@ | ||
| 52 | 53 | "A brain is its own process and sees the game only through the protocol", |
| 53 | 54 | "Standard rule: exactly five in a row wins" |
| 54 | 55 | ], |
| 55 | - "version": 1 | |
| 56 | + "version": 2 | |
| 56 | 57 | }, |
| 57 | 58 | "checks": { |
| 58 | 59 | "play": { |
+46 −0 tests/compare.py added View file
| @@ -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 added View file
| @@ -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 added View file
| @@ -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 added View file
| @@ -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 View file
| @@ -63,6 +63,7 @@ canvas.mine { cursor: pointer; } | ||
| 63 | 63 | <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> |
| 64 | 64 | <div class="center"> |
| 65 | 65 | <canvas id="board"></canvas> |
| 66 | + <div style="text-align:center;font-size:12px;color:var(--muted);margin:5px 0" title="Gomocup standard:无禁手;任一方向恰好五连获胜,六连及以上不算。">标准五子棋 · 恰好五连获胜 · 长连不算</div> | |
| 66 | 67 | <div class="bar" id="bar"><span class="status" id="status">等待对局…</span></div> |
| 67 | 68 | </div> |
| 68 | 69 | <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 | ||
| 80 | 81 | function fit() { |
| 81 | 82 | const narrow = innerWidth / innerHeight < 1.25 |
| 82 | 83 | 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))) | |
| 84 | 85 | pad = S * 0.062; cell = (S - 2 * pad) / (N - 1) |
| 85 | 86 | const r = devicePixelRatio || 1 |
| 86 | 87 | cv.width = cv.height = Math.round(S * r); cv.style.width = cv.style.height = S + 'px' |
