Bring the game from shoal-games 82301f6c4521
Browse files+8 −0 AGENTS.md added View file
| @@ -0,0 +1,8 @@ | ||
| 1 | +# Rules for agents | |
| 2 | + | |
| 3 | +- Your bot is a Gomocup brain: `routes/<your route>/bot.py` (Python 3.12, standard library) or an executable `routes/<your route>/pbrain`. Start from `routes/bot.py`, the starter. | |
| 4 | +- Test before you push: `python3 -I referee/play.py check routes/<your route>` plays the season's field and prints the score it would get. | |
| 5 | +- Every push is checked: the referee plays your brain against the field from six openings in both colours and keeps the score. You do not write `metrics.json` yourself. | |
| 6 | +- For replays, submit a practice job from your route: `sbatch referee/practice.sbatch`. Its games show up under Matches. | |
| 7 | +- Write what you tried and what to try next in `routes/<your route>/README.md`, ending with `## Next`. | |
| 8 | +- Don't change `referee/`, `field/` or `viewer/` on your route: the check runs the approved version of them anyway. Propose changes to them by pull request to `main`. | |
+0 −0 cover.png added View file
+56 −0 field/neighbour/bot.py added View file
| @@ -0,0 +1,56 @@ | ||
| 1 | +"""A Gomocup brain: win if it can, else block a five, else a random point next to the stones.""" | |
| 2 | +import random, sys | |
| 3 | + | |
| 4 | +N, board, me = 15, {}, 1 | |
| 5 | +D = [(1, 0), (0, 1), (1, 1), (1, -1)] | |
| 6 | + | |
| 7 | + | |
| 8 | +def five(x, y, c): | |
| 9 | + for dx, dy in D: | |
| 10 | + n = 1 | |
| 11 | + for s in (1, -1): | |
| 12 | + i = 1 | |
| 13 | + while board.get((x + s * i * dx, y + s * i * dy)) == c: | |
| 14 | + n += 1; i += 1 | |
| 15 | + if n >= 5: | |
| 16 | + return True | |
| 17 | + return False | |
| 18 | + | |
| 19 | + | |
| 20 | +def move(): | |
| 21 | + empty = [(x, y) for x in range(N) for y in range(N) if (x, y) not in board] | |
| 22 | + near = [p for p in empty if any((p[0] + a, p[1] + b) in board for a in (-1, 0, 1) for b in (-1, 0, 1))] or [(N // 2, N // 2)] | |
| 23 | + for c in (me, 3 - me): | |
| 24 | + for p in near: | |
| 25 | + if five(*p, c): | |
| 26 | + return p | |
| 27 | + return random.choice(near) | |
| 28 | + | |
| 29 | + | |
| 30 | +def play(): | |
| 31 | + x, y = move(); board[(x, y)] = me | |
| 32 | + print(f'{x},{y}', flush=True) | |
| 33 | + | |
| 34 | + | |
| 35 | +random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 0) | |
| 36 | +for line in sys.stdin: | |
| 37 | + cmd, _, arg = line.strip().partition(' ') | |
| 38 | + cmd = cmd.upper() | |
| 39 | + if cmd == 'START': | |
| 40 | + N = int(arg); board.clear(); print('OK', flush=True) | |
| 41 | + elif cmd == 'BEGIN': | |
| 42 | + play() | |
| 43 | + elif cmd == 'TURN': | |
| 44 | + x, y = map(int, arg.split(',')); board[(x, y)] = 2; play() | |
| 45 | + elif cmd == 'BOARD': | |
| 46 | + for l in sys.stdin: | |
| 47 | + if l.strip().upper() == 'DONE': | |
| 48 | + break | |
| 49 | + x, y, f = map(int, l.split(',')); board[(x, y)] = 1 if f == 1 else 2 | |
| 50 | + play() | |
| 51 | + elif cmd == 'ABOUT': | |
| 52 | + print('name="neighbour", version="0.1", author="shoal-games"', flush=True) | |
| 53 | + elif cmd == 'END': | |
| 54 | + break | |
| 55 | + elif cmd != 'INFO': | |
| 56 | + print('UNKNOWN', flush=True) | |
+52 −0 field/random/bot.py added View file
| @@ -0,0 +1,52 @@ | ||
| 1 | +"""A Gomocup brain that plays a random point next to the stones: the weakest of the field, a floor to beat.""" | |
| 2 | +import random, sys | |
| 3 | + | |
| 4 | +N, board, me = 15, {}, 1 | |
| 5 | +D = [(1, 0), (0, 1), (1, 1), (1, -1)] | |
| 6 | + | |
| 7 | + | |
| 8 | +def five(x, y, c): | |
| 9 | + for dx, dy in D: | |
| 10 | + n = 1 | |
| 11 | + for s in (1, -1): | |
| 12 | + i = 1 | |
| 13 | + while board.get((x + s * i * dx, y + s * i * dy)) == c: | |
| 14 | + n += 1; i += 1 | |
| 15 | + if n >= 5: | |
| 16 | + return True | |
| 17 | + return False | |
| 18 | + | |
| 19 | + | |
| 20 | +def move(): | |
| 21 | + empty = [(x, y) for x in range(N) for y in range(N) if (x, y) not in board] | |
| 22 | + near = [p for p in empty if any((p[0] + a, p[1] + b) in board for a in (-1, 0, 1) for b in (-1, 0, 1))] or [(N // 2, N // 2)] | |
| 23 | + return random.choice(near) | |
| 24 | + | |
| 25 | + | |
| 26 | +def play(): | |
| 27 | + x, y = move(); board[(x, y)] = me | |
| 28 | + print(f'{x},{y}', flush=True) | |
| 29 | + | |
| 30 | + | |
| 31 | +random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 0) | |
| 32 | +for line in sys.stdin: | |
| 33 | + cmd, _, arg = line.strip().partition(' ') | |
| 34 | + cmd = cmd.upper() | |
| 35 | + if cmd == 'START': | |
| 36 | + N = int(arg); board.clear(); print('OK', flush=True) | |
| 37 | + elif cmd == 'BEGIN': | |
| 38 | + play() | |
| 39 | + elif cmd == 'TURN': | |
| 40 | + x, y = map(int, arg.split(',')); board[(x, y)] = 2; play() | |
| 41 | + elif cmd == 'BOARD': | |
| 42 | + for l in sys.stdin: | |
| 43 | + if l.strip().upper() == 'DONE': | |
| 44 | + break | |
| 45 | + x, y, f = map(int, l.split(',')); board[(x, y)] = 1 if f == 1 else 2 | |
| 46 | + play() | |
| 47 | + elif cmd == 'ABOUT': | |
| 48 | + print('name="random", version="0.1", author="shoal-games"', flush=True) | |
| 49 | + elif cmd == 'END': | |
| 50 | + break | |
| 51 | + elif cmd != 'INFO': | |
| 52 | + print('UNKNOWN', flush=True) | |
+66 −0 field/threats/bot.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) | |
+19 −0 kit/bridge.js added View file
| @@ -0,0 +1,19 @@ | ||
| 1 | +// The page around a game's viewer hands it the replay (Shoal's public/app.js): the viewer runs in a frame with scripts | |
| 2 | +// only, says it is ready, and gets {shoal: 'replay', name, text}. Opened by itself, it reads ?replay=<file> instead, | |
| 3 | +// so a replay can be watched from a clone: python3 -m http.server, then viewer/index.html?replay=../replays/x.json | |
| 4 | +// A live room (docs/games.md §8.4) opens the viewer as index.html?live. shoalLive(fn) says {shoal: 'ready', live: true} | |
| 5 | +// and calls fn(batch) for each {shoal: 'live', room, you, messages}: you lists the seats this viewer controls, the first | |
| 6 | +// batch holds all the history it may see, and each message is the room's (§8.3) plus its sequence number n: {n, to, | |
| 7 | +// version, obs, legal} asks a seat to act, {n, to, obs} tells one seat, {n, event} is public, {n, end} ends the room. | |
| 8 | +// shoalAct(seat, version, action) plays for a seat in you, answering the request with that version. | |
| 9 | +window.shoalReplay = start => { | |
| 10 | + addEventListener('message', e => { if (e.data && e.data.shoal === 'replay') start(e.data.text, e.data.name) }) | |
| 11 | + const local = new URLSearchParams(location.search).get('replay') | |
| 12 | + if (local) fetch(local).then(r => r.text()).then(t => start(t, local)) | |
| 13 | + else if (parent !== window) parent.postMessage({ shoal: 'ready' }, '*') | |
| 14 | +} | |
| 15 | +window.shoalLive = fn => { | |
| 16 | + addEventListener('message', e => { if (e.data && e.data.shoal === 'live') fn(e.data) }) | |
| 17 | + if (parent !== window) parent.postMessage({ shoal: 'ready', live: true }, '*') | |
| 18 | +} | |
| 19 | +window.shoalAct = (seat, version, action) => parent.postMessage({ shoal: 'act', seat, version, action }, '*') | |
+82 −0 kit/host.py added View file
| @@ -0,0 +1,82 @@ | ||
| 1 | +"""A bot in its own process, spoken to one JSON line at a time. Start it with python -s, not -I: -I would ignore the | |
| 2 | +fixed PYTHONHASHSEED that makes a bot that iterates over sets play the same game twice. | |
| 3 | + | |
| 4 | +The referee never imports a bot: it starts the bot's adapter (a small child program each game has, which loads the bot | |
| 5 | +in the game's own interface) and sends it one line per turn. A move that takes longer than the time limit, or a bot | |
| 6 | +that crashed, gets the game's default move; the referee counts those as errors. The child side is `serve(handle)`. | |
| 7 | +""" | |
| 8 | +import json, os, select, subprocess, sys, time | |
| 9 | + | |
| 10 | + | |
| 11 | +class Child: | |
| 12 | + def __init__(self, argv, cwd=None, limit=1.0, start=10.0): | |
| 13 | + self.limit, self.errors, self.dead = limit, 0, False | |
| 14 | + env = {k: v for k, v in os.environ.items() if not k.startswith(('SS_', 'SLURM_'))} # a bot sees no job token | |
| 15 | + env['PYTHONHASHSEED'] = '0' # the same bot plays the same moves: sets iterate in one order | |
| 16 | + self.p = subprocess.Popen(argv, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env) | |
| 17 | + self.buf, self.n = b'', 0 | |
| 18 | + self.ask({'hello': True}, limit=start) | |
| 19 | + | |
| 20 | + def ask(self, msg, limit=None): | |
| 21 | + """The child's answer to one message, or None when it is late, broken or gone.""" | |
| 22 | + if self.dead: | |
| 23 | + return None | |
| 24 | + try: | |
| 25 | + self.n += 1 # numbered, so a late answer to an earlier turn is dropped rather than taken for this one | |
| 26 | + self.p.stdin.write((json.dumps({**msg, 'n': self.n}) + '\n').encode()) | |
| 27 | + self.p.stdin.flush() | |
| 28 | + deadline = time.monotonic() + (limit or self.limit) | |
| 29 | + fd = self.p.stdout.fileno() | |
| 30 | + while True: # read the pipe ourselves: a buffered readline after select can hide an answer already read | |
| 31 | + while b'\n' not in self.buf: | |
| 32 | + ready, _, _ = select.select([fd], [], [], max(0.0, deadline - time.monotonic())) | |
| 33 | + chunk = os.read(fd, 1 << 16) if ready else b'' | |
| 34 | + if not chunk: | |
| 35 | + raise TimeoutError | |
| 36 | + self.buf += chunk | |
| 37 | + line, self.buf = self.buf.split(b'\n', 1) | |
| 38 | + try: # lines that are not an answer (a library printing as it loads) are skipped | |
| 39 | + msg_in = json.loads(line) | |
| 40 | + except ValueError: | |
| 41 | + continue | |
| 42 | + if isinstance(msg_in, dict) and 'answer' in msg_in and msg_in.get('n') == self.n: | |
| 43 | + return msg_in['answer'] | |
| 44 | + except Exception: | |
| 45 | + self.errors += 1 | |
| 46 | + if self.errors >= 20 or self.p.poll() is not None: # ponytail: a slow bot is stopped after 20 misses | |
| 47 | + self.close() | |
| 48 | + return None | |
| 49 | + | |
| 50 | + def close(self): | |
| 51 | + self.dead = True | |
| 52 | + try: | |
| 53 | + self.p.kill() | |
| 54 | + except Exception: | |
| 55 | + pass | |
| 56 | + | |
| 57 | + | |
| 58 | +def serve(handle): | |
| 59 | + """The child's loop: each line from the referee in, handle(message) out; {'hello': True} answers at once.""" | |
| 60 | + out = sys.__stdout__ | |
| 61 | + sys.stdout = sys.stderr # a bot's prints must not corrupt the channel | |
| 62 | + for line in sys.stdin: | |
| 63 | + msg = json.loads(line) | |
| 64 | + answer = True if msg.get('hello') else handle(msg) | |
| 65 | + out.write(json.dumps({'answer': answer, 'n': msg.get('n')}) + '\n') | |
| 66 | + out.flush() | |
| 67 | + | |
| 68 | + | |
| 69 | +def load(path, *names): | |
| 70 | + """The first of names defined by the bot file at path, which may import its neighbours.""" | |
| 71 | + import importlib.util | |
| 72 | + sys.path.insert(0, os.path.dirname(os.path.abspath(path))) | |
| 73 | + spec = importlib.util.spec_from_file_location('bot', path) | |
| 74 | + mod = importlib.util.module_from_spec(spec) | |
| 75 | + spec.loader.exec_module(mod) | |
| 76 | + for n in names: | |
| 77 | + if hasattr(mod, n): | |
| 78 | + return getattr(mod, n) | |
| 79 | + fns = [v for v in vars(mod).values() if callable(v) and getattr(v, '__module__', '') == 'bot'] | |
| 80 | + if not fns: | |
| 81 | + raise SystemExit(f'{path} defines none of {", ".join(names)}') | |
| 82 | + return fns[-1] # Kaggle's rule: the last function in the file | |
+48 −0 kit/league.py added View file
| @@ -0,0 +1,48 @@ | ||
| 1 | +"""What every game's referee shares: where the bots are, how a match is seated, and the files a run leaves. | |
| 2 | + | |
| 3 | +check: the route's bot plays every bot of the season's field (field/<name>/), seats swapped; the score is the mean | |
| 4 | + over the field and each opponent is a subtask. metrics.json is what the site keeps (scienceslack.json names it). | |
| 5 | +practice: a few matches against the field, each leaving a replay in replays/ and an entry in replays/matches.json. | |
| 6 | +""" | |
| 7 | +import json, os, sys | |
| 8 | + | |
| 9 | + | |
| 10 | +def bot_dir(arg): | |
| 11 | + """The route's folder the check was given, absolute (bots run in their own folder); a missing bot is the route's | |
| 12 | + mistake, said plainly.""" | |
| 13 | + if not os.path.isdir(arg): | |
| 14 | + sys.exit(f'No route folder at {arg}') | |
| 15 | + return os.path.abspath(arg) | |
| 16 | + | |
| 17 | + | |
| 18 | +def field(root='field'): | |
| 19 | + return sorted(d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d)) and not d.startswith('.')) | |
| 20 | + | |
| 21 | + | |
| 22 | +def route_name(): | |
| 23 | + d = os.environ.get('SS_ROUTE_DIR', 'routes').rstrip('/') | |
| 24 | + return 'main' if d == 'routes' else d.split('/', 1)[-1] | |
| 25 | + | |
| 26 | + | |
| 27 | +def write_metrics(score, subtasks, cost=None, path='metrics.json'): | |
| 28 | + out = {'score': round(score, 6), 'subtasks': {k: round(v, 6) for k, v in subtasks.items()}} | |
| 29 | + if cost is not None: | |
| 30 | + out['cost'] = round(cost, 3) | |
| 31 | + with open(path, 'w') as f: | |
| 32 | + json.dump(out, f, indent=2, sort_keys=True) | |
| 33 | + print(f'score {out["score"]} over {len(subtasks)}: ' + ', '.join(f'{k} {v:.3g}' for k, v in out['subtasks'].items())) | |
| 34 | + | |
| 35 | + | |
| 36 | +class Replays: | |
| 37 | + """replays/<file> per match, and replays/matches.json saying who sat where and how it ended.""" | |
| 38 | + | |
| 39 | + def __init__(self, root='replays'): | |
| 40 | + self.root, self.index = root, [] | |
| 41 | + os.makedirs(root, exist_ok=True) | |
| 42 | + | |
| 43 | + def add(self, file, data, title, seats, result): | |
| 44 | + with open(os.path.join(self.root, file), 'w') as f: | |
| 45 | + f.write(data if isinstance(data, str) else json.dumps(data, separators=(',', ':'))) | |
| 46 | + self.index.append({'file': file, 'title': title, 'seats': seats, 'result': result}) | |
| 47 | + with open(os.path.join(self.root, 'matches.json'), 'w') as f: | |
| 48 | + json.dump(self.index, f, indent=2) | |
+143 −0 kit/room.py added View file
| @@ -0,0 +1,143 @@ | ||
| 1 | +"""A room's half of the room protocol (docs/games.md §8.3), for any game's referee. SS_ROOM says who holds each seat; | |
| 2 | +ask() gets a move from a bot child, from a person or agent over stdin, or from the game's own fallback (auto). | |
| 3 | + | |
| 4 | + room = Room(start) start(folder) -> host.Child, the game's way to run a bot (a route's or field/<name>) | |
| 5 | + move = room.ask(seat, obs, legal, auto=lambda: ..., bot=<the bot child's message, in the game's own interface>) | |
| 6 | + move = room.ask(seat, obs, legal, auto, accept=lambda a: ...) # legal holds templates (free text): accept says | |
| 7 | + # what an answer means, or None to keep waiting | |
| 8 | + room.tell(seat, obs); room.event(data); room.end(result, scores); room.close() | |
| 9 | + | |
| 10 | +Lines to the platform start with '@shoal ' and are flushed one by one; the referee's own prints go to stderr, so a | |
| 11 | +library printing as it loads cannot break a line. A person or agent who does not answer within the limit (SS_ROOM | |
| 12 | +"limit", 30 s by default; ask(..., limit=s) sets one request's, e.g. a speech's) gets auto's move. {"control": ...} | |
| 13 | +re-asks the seat with a new version, so a late answer from the old controller is dropped; {"stop": true} makes ask() | |
| 14 | +raise Stop, and the referee writes what it has and ends. | |
| 15 | +""" | |
| 16 | +import json, os, queue, sys, threading, time | |
| 17 | + | |
| 18 | + | |
| 19 | +class Stop(Exception): | |
| 20 | + pass | |
| 21 | + | |
| 22 | + | |
| 23 | +def log(*a): | |
| 24 | + print('room:', *a, file=sys.stderr, flush=True) | |
| 25 | + | |
| 26 | + | |
| 27 | +class Room: | |
| 28 | + def __init__(self, start, field='field'): | |
| 29 | + conf = json.loads(os.environ.get('SS_ROOM') or '{}') | |
| 30 | + self.seats = {str(k): v for k, v in conf.get('seats', {}).items()} | |
| 31 | + self.limit = float(conf.get('limit', 30)) | |
| 32 | + self.start, self.field, self.bots = start, field, {} | |
| 33 | + self.version, self.stopped = 0, False | |
| 34 | + self.inbox, self.lock = queue.Queue(), threading.Lock() | |
| 35 | + self.out, sys.stdout = sys.stdout, sys.stderr # prints, ours or a library's, must not look like protocol | |
| 36 | + threading.Thread(target=self._read, daemon=True).start() | |
| 37 | + | |
| 38 | + def say(self, msg): | |
| 39 | + with self.lock: # the reader thread speaks too (control events) | |
| 40 | + self.out.write('@shoal ' + json.dumps(msg, separators=(',', ':')) + '\n') | |
| 41 | + self.out.flush() | |
| 42 | + | |
| 43 | + def tell(self, seat, obs): | |
| 44 | + self.say({'to': seat, 'obs': obs}) | |
| 45 | + | |
| 46 | + def event(self, data): | |
| 47 | + self.say({'event': data}) | |
| 48 | + | |
| 49 | + def end(self, result, scores): | |
| 50 | + self.say({'end': {'result': result, 'scores': scores}}) | |
| 51 | + | |
| 52 | + def label(self, seat): | |
| 53 | + s = self.seats.get(seat) or {} | |
| 54 | + return s.get('name') or s.get('route') or s.get('field') or os.path.basename((s.get('dir') or '').rstrip('/')) or s.get('kind') or 'auto' | |
| 55 | + | |
| 56 | + def kind(self, seat): | |
| 57 | + return (self.seats.get(seat) or {}).get('kind') or 'auto' | |
| 58 | + | |
| 59 | + def ask(self, seat, obs, legal, auto, bot=None, accept=None, limit=None): | |
| 60 | + ok = accept or (lambda a: a if a in legal else None) | |
| 61 | + while True: | |
| 62 | + if self.stopped: | |
| 63 | + raise Stop | |
| 64 | + spec = self.seats.get(seat) or {} | |
| 65 | + kind = spec.get('kind') | |
| 66 | + if kind == 'bot': | |
| 67 | + child = self._bot(spec) | |
| 68 | + move = child.ask(bot if bot is not None else {'obs': obs, 'legal': legal}) if child else None | |
| 69 | + move = ok(move) if move is not None else None | |
| 70 | + return auto() if move is None else move # a late, broken or illegal bot move: auto plays it | |
| 71 | + if kind not in ('human', 'agent'): | |
| 72 | + return auto() | |
| 73 | + self._drain() # answers and notices from before this request are stale | |
| 74 | + if self.stopped: # a stop drained with them still counts | |
| 75 | + raise Stop | |
| 76 | + self.version += 1 | |
| 77 | + v = self.version | |
| 78 | + self.say({'to': seat, 'version': v, 'obs': obs, 'legal': legal}) | |
| 79 | + deadline = time.monotonic() + (limit or self.limit) | |
| 80 | + while True: | |
| 81 | + try: | |
| 82 | + msg = self.inbox.get(timeout=max(0.0, deadline - time.monotonic())) | |
| 83 | + except queue.Empty: | |
| 84 | + log(f'seat {seat} out of time at version {v}: auto plays') | |
| 85 | + return auto() | |
| 86 | + if msg.get('stop'): | |
| 87 | + raise Stop | |
| 88 | + c = msg.get('control') | |
| 89 | + if isinstance(c, dict) and str(c.get('seat')) == seat: | |
| 90 | + break # a new controller: ask again, with a new version | |
| 91 | + a = msg.get('act') | |
| 92 | + if isinstance(a, dict) and str(a.get('seat')) == seat and a.get('version') == v: | |
| 93 | + move = ok(a.get('action')) | |
| 94 | + if move is not None: | |
| 95 | + return move | |
| 96 | + if a is not None: | |
| 97 | + log(f'ignored act {json.dumps(a)[:120]}: asking seat {seat} at version {v}') | |
| 98 | + | |
| 99 | + def close(self): | |
| 100 | + for c in self.bots.values(): | |
| 101 | + if c: | |
| 102 | + c.close() | |
| 103 | + | |
| 104 | + def _read(self): | |
| 105 | + for line in sys.stdin: | |
| 106 | + try: | |
| 107 | + msg = json.loads(line) | |
| 108 | + except ValueError: | |
| 109 | + log('not JSON:', line.strip()[:120]) | |
| 110 | + continue | |
| 111 | + if not isinstance(msg, dict): | |
| 112 | + continue | |
| 113 | + if msg.get('stop'): | |
| 114 | + self.stopped = True | |
| 115 | + c = msg.get('control') | |
| 116 | + if isinstance(c, dict) and 'seat' in c: # applied at once: the seat may not be the one being asked | |
| 117 | + seat = str(c['seat']) | |
| 118 | + self.seats[seat] = {k: v for k, v in c.items() if k != 'seat'} | |
| 119 | + self.event({'type': 'control', 'seat': seat, 'kind': self.kind(seat), 'name': self.label(seat)}) | |
| 120 | + self.inbox.put(msg) | |
| 121 | + | |
| 122 | + def _drain(self): | |
| 123 | + while True: | |
| 124 | + try: | |
| 125 | + msg = self.inbox.get_nowait() | |
| 126 | + except queue.Empty: | |
| 127 | + return | |
| 128 | + if msg.get('act') is not None: | |
| 129 | + log(f'ignored act {json.dumps(msg["act"])[:120]}: stale') | |
| 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.""" | |
| 133 | + if spec.get('field'): | |
| 134 | + folder = os.path.join(self.field, spec['field']) | |
| 135 | + else: | |
| 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: | |
| 138 | + try: | |
| 139 | + self.bots[folder] = self.start(folder) | |
| 140 | + except (Exception, SystemExit) as e: # a missing bot is played by auto rather than ending the room | |
| 141 | + log(f'no bot at {folder} ({e}): auto plays that seat') | |
| 142 | + self.bots[folder] = None | |
| 143 | + return self.bots[folder] | |
+88 −0 kit/roomcli.py added View file
| @@ -0,0 +1,88 @@ | ||
| 1 | +"""Play a room on a laptop, without the site: run a room command with SS_ROOM, print what it says (docs/games.md §8.3), | |
| 2 | +and answer its requests to human and agent seats, from the terminal or by rule. | |
| 3 | + | |
| 4 | + python3 kit/roomcli.py --room '{"seats": {...}}' [--auto] [--seat 0 --pick random] [--control 0=auto@6] [--stop 12] -- <command> | |
| 5 | + | |
| 6 | +--auto answers every request with the first legal action; --seat S (repeatable) answers seat S by --pick (first or | |
| 7 | +random) and leaves the other seats to the terminal. --control S=KIND@N: at the first request to seat S after N public | |
| 8 | +events, hand the seat to KIND (auto, agent, human), then send the old controller's late answer to that request, which | |
| 9 | +the room must ignore (it re-asks with a new version, or auto plays). --stop N sends {"stop": true} after N events. | |
| 10 | +SS_INPUT (a route's dir is under it) defaults to the current folder; --full prints whole messages. | |
| 11 | +""" | |
| 12 | +import argparse, json, os, random, subprocess, sys | |
| 13 | + | |
| 14 | + | |
| 15 | +def main(): | |
| 16 | + ap = argparse.ArgumentParser(description=__doc__.split('\n')[0]) | |
| 17 | + ap.add_argument('--room', default=os.environ.get('SS_ROOM', '{"seats": {}}'), help='SS_ROOM for the room') | |
| 18 | + ap.add_argument('--auto', action='store_true', help='answer every request with the first legal action') | |
| 19 | + ap.add_argument('--seat', action='append', default=[], help='a seat answered by --pick') | |
| 20 | + ap.add_argument('--pick', choices=['first', 'random'], default='first') | |
| 21 | + ap.add_argument('--control', action='append', default=[], help='S=KIND@N: hand seat S to KIND after N events') | |
| 22 | + ap.add_argument('--stop', type=int, help='send stop after N events') | |
| 23 | + ap.add_argument('--seed', type=int, default=0, help='for --pick random') | |
| 24 | + ap.add_argument('--full', action='store_true') | |
| 25 | + ap.add_argument('cmd', nargs=argparse.REMAINDER) | |
| 26 | + a = ap.parse_args() | |
| 27 | + cmd = a.cmd[1:] if a.cmd[:1] == ['--'] else a.cmd | |
| 28 | + if not cmd: | |
| 29 | + ap.error('give the room command after --') | |
| 30 | + controls = [] | |
| 31 | + for c in a.control: | |
| 32 | + seat, rest = c.split('=', 1) | |
| 33 | + kind, at = rest.split('@', 1) | |
| 34 | + controls.append({'seat': seat, 'kind': kind, 'at': int(at)}) | |
| 35 | + rnd = random.Random(a.seed) | |
| 36 | + env = {**os.environ, 'SS_ROOM': a.room, 'SS_INPUT': os.environ.get('SS_INPUT', os.getcwd())} | |
| 37 | + p = subprocess.Popen(cmd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1) | |
| 38 | + events = asked = answered = late = 0 | |
| 39 | + versions = [] | |
| 40 | + | |
| 41 | + def send(msg): | |
| 42 | + p.stdin.write(json.dumps(msg) + '\n') | |
| 43 | + p.stdin.flush() | |
| 44 | + print(' >>', json.dumps(msg)[:200]) | |
| 45 | + | |
| 46 | + for line in p.stdout: | |
| 47 | + if not line.startswith('@shoal '): | |
| 48 | + print(' log', line.rstrip()) | |
| 49 | + continue | |
| 50 | + msg = json.loads(line[7:]) | |
| 51 | + text = json.dumps(msg) | |
| 52 | + print(f'@shoal {text if a.full or len(text) <= 200 else text[:197] + "..."}') | |
| 53 | + if 'event' in msg: | |
| 54 | + events += 1 | |
| 55 | + if a.stop is not None and events == a.stop: | |
| 56 | + send({'stop': True}) | |
| 57 | + if 'end' in msg: | |
| 58 | + break | |
| 59 | + if 'version' not in msg: | |
| 60 | + continue | |
| 61 | + seat, v, legal = msg['to'], msg['version'], msg['legal'] | |
| 62 | + asked += 1 | |
| 63 | + assert not versions or v > versions[-1], 'versions must grow' | |
| 64 | + versions.append(v) | |
| 65 | + due = next((c for c in controls if c['seat'] == seat and events >= c['at']), None) | |
| 66 | + if due: # the handover, then the old controller's answer arriving late | |
| 67 | + controls.remove(due) | |
| 68 | + send({'control': {'seat': seat, 'kind': due['kind'], 'name': f'{due["kind"]}-{seat}'}}) | |
| 69 | + send({'act': {'seat': seat, 'version': v, 'action': legal[-1]}}) | |
| 70 | + late += 1 | |
| 71 | + continue | |
| 72 | + if a.auto or seat in a.seat: | |
| 73 | + action = legal[0] if a.pick == 'first' or a.auto else rnd.choice(legal) | |
| 74 | + else: | |
| 75 | + print(f'seat {seat}, version {v}: obs {json.dumps(msg["obs"])[:300]}\nlegal: {" ".join(map(str, legal[:60]))}') | |
| 76 | + got = input('your move, or its index (blank: the first)> ').strip() | |
| 77 | + action = got if got in legal else legal[int(got)] if got.isdigit() and int(got) < len(legal) else got or legal[0] | |
| 78 | + send({'act': {'seat': seat, 'version': v, 'action': action}}) | |
| 79 | + answered += 1 | |
| 80 | + p.stdin.close() | |
| 81 | + code = p.wait() | |
| 82 | + print(f'roomcli: {events} events, {asked} requests (versions {versions[:1]}..{versions[-1:]}), {answered} answered, ' | |
| 83 | + f'{late} late answers sent, exit {code}') | |
| 84 | + sys.exit(code) | |
| 85 | + | |
| 86 | + | |
| 87 | +if __name__ == '__main__': | |
| 88 | + main() | |
+57 −0 kit/test_room.py added View file
| @@ -0,0 +1,57 @@ | ||
| 1 | +"""A check of kit/room.py against the room protocol (docs/games.md §8.3), on a toy game with one human seat: a stale or | |
| 2 | +illegal answer is ignored, a handover re-asks with a new version and drops the old controller's late answer, a silent | |
| 3 | +seat gets auto's move, and stop ends the room. python3 kit/test_room.py (or pytest) | |
| 4 | +""" | |
| 5 | +import json, os, subprocess, sys | |
| 6 | + | |
| 7 | +KIT = os.path.dirname(os.path.abspath(__file__)) | |
| 8 | +GAME = f''' | |
| 9 | +import sys; sys.path.insert(0, {KIT!r}) | |
| 10 | +from room import Room, Stop | |
| 11 | +room = Room(None) | |
| 12 | +try: | |
| 13 | + for i in range(4): | |
| 14 | + room.event({{'seat': '0', 'move': room.ask('0', {{'i': i}}, ['a', 'b'], auto=lambda: 'auto')}}) | |
| 15 | +except Stop: | |
| 16 | + room.event({{'stopped': True}}) | |
| 17 | +room.end('done', {{'0': 0}}) | |
| 18 | +''' | |
| 19 | + | |
| 20 | + | |
| 21 | +def test_room(): | |
| 22 | + env = {**os.environ, 'SS_ROOM': json.dumps({'limit': 0.5, 'seats': {'0': {'kind': 'human'}}})} | |
| 23 | + p = subprocess.Popen([sys.executable, '-c', GAME], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) | |
| 24 | + | |
| 25 | + def said(): | |
| 26 | + line = p.stdout.readline() | |
| 27 | + assert line.startswith('@shoal '), line | |
| 28 | + return json.loads(line[7:]) | |
| 29 | + | |
| 30 | + def send(msg): | |
| 31 | + p.stdin.write(json.dumps(msg) + '\n') | |
| 32 | + p.stdin.flush() | |
| 33 | + | |
| 34 | + assert said() == {'to': '0', 'version': 1, 'obs': {'i': 0}, 'legal': ['a', 'b']} | |
| 35 | + send({'act': {'seat': '0', 'version': 0, 'action': 'a'}}) # stale | |
| 36 | + send({'act': {'seat': '0', 'version': 1, 'action': 'z'}}) # illegal | |
| 37 | + send({'act': {'seat': '0', 'version': 1, 'action': 'b'}}) | |
| 38 | + assert said() == {'event': {'seat': '0', 'move': 'b'}} | |
| 39 | + assert said()['version'] == 2 | |
| 40 | + send({'control': {'seat': '0', 'kind': 'agent', 'name': 'ada-codex'}}) | |
| 41 | + send({'act': {'seat': '0', 'version': 2, 'action': 'b'}}) # the old controller, late | |
| 42 | + assert said() == {'event': {'type': 'control', 'seat': '0', 'kind': 'agent', 'name': 'ada-codex'}} | |
| 43 | + assert said()['version'] == 3 | |
| 44 | + send({'act': {'seat': '0', 'version': 3, 'action': 'a'}}) | |
| 45 | + assert said() == {'event': {'seat': '0', 'move': 'a'}} | |
| 46 | + assert said()['version'] == 4 # left unanswered: auto plays after the limit | |
| 47 | + assert said() == {'event': {'seat': '0', 'move': 'auto'}} | |
| 48 | + assert said()['version'] == 5 | |
| 49 | + send({'stop': True}) | |
| 50 | + assert said() == {'event': {'stopped': True}} | |
| 51 | + assert said() == {'end': {'result': 'done', 'scores': {'0': 0}}} | |
| 52 | + assert p.wait(5) == 0 | |
| 53 | + | |
| 54 | + | |
| 55 | +if __name__ == '__main__': | |
| 56 | + test_room() | |
| 57 | + print('room protocol: ok') | |
+6 −0 referee/check.sbatch added View file
| @@ -0,0 +1,6 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +#SBATCH --job-name=play | |
| 3 | +#SBATCH --time=10:00 | |
| 4 | +# The route's brain against the season's field; writes metrics.json, which the site keeps as the route's score. | |
| 5 | +PY=$(command -v gomoku-python || command -v python3) | |
| 6 | +PYTHONHASHSEED=0 "$PY" -s referee/play.py check "$SS_INPUT/$SS_ROUTE_DIR" | |
+101 −0 referee/gomoku.py added View file
| @@ -0,0 +1,101 @@ | ||
| 1 | +"""A Gomocup match between two brains, as Piskvork runs one: the brains speak the Gomocup protocol on stdin and stdout | |
| 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, | |
| 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 | +not judged (ponytail: standard rule only; renju needs its own detector, the free ones are GPL). | |
| 6 | +""" | |
| 7 | +import queue, subprocess, threading, time | |
| 8 | + | |
| 9 | +D = [(1, 0), (0, 1), (1, 1), (1, -1)] | |
| 10 | + | |
| 11 | + | |
| 12 | +class Brain: | |
| 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) | |
| 15 | + self.q = queue.Queue() | |
| 16 | + threading.Thread(target=lambda: [self.q.put(l) for l in self.p.stdout], daemon=True).start() | |
| 17 | + | |
| 18 | + def send(self, *lines): | |
| 19 | + self.p.stdin.write(''.join(l + '\n' for l in lines)) | |
| 20 | + self.p.stdin.flush() | |
| 21 | + | |
| 22 | + def reply(self, timeout): | |
| 23 | + 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 | |
| 28 | + | |
| 29 | + | |
| 30 | +def run_of(board, x, y, c): | |
| 31 | + best, line = 0, None | |
| 32 | + for dx, dy in D: | |
| 33 | + cells = [(x, y)] | |
| 34 | + for s in (1, -1): | |
| 35 | + i = 1 | |
| 36 | + while board.get((x + s * i * dx, y + s * i * dy)) == c: | |
| 37 | + cells.append((x + s * i * dx, y + s * i * dy)) | |
| 38 | + i += 1 | |
| 39 | + if len(cells) > best: | |
| 40 | + best, line = len(cells), sorted(cells) | |
| 41 | + return best, line | |
| 42 | + | |
| 43 | + | |
| 44 | +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] | |
| 47 | + board, moves, out = {}, [], {'winner': None, 'reason': 'board full', 'five': None} | |
| 48 | + 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): | |
| 52 | + try: | |
| 53 | + if not b.reply(5).upper().startswith('OK'): | |
| 54 | + raise ValueError | |
| 55 | + except (queue.Empty, ValueError): | |
| 56 | + return {**base(size, opening, moves), 'winner': 1 - i, 'reason': 'did not start'} | |
| 57 | + for i, (x, y) in enumerate(opening): | |
| 58 | + board[(x, y)] = i % 2 | |
| 59 | + moves.append([x, y, 0]) | |
| 60 | + 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)] | |
| 66 | + while True: | |
| 67 | + t = time.monotonic() | |
| 68 | + try: | |
| 69 | + x, y = map(int, live[side].reply(turn_ms / 1000 + 0.5).split(',')) | |
| 70 | + except (queue.Empty, ValueError): | |
| 71 | + out = {'winner': 1 - side, 'reason': 'out of time or no move', 'five': None} | |
| 72 | + break | |
| 73 | + if not (0 <= x < size and 0 <= y < size) or (x, y) in board: | |
| 74 | + out = {'winner': 1 - side, 'reason': f'illegal move {x},{y}', 'five': None} | |
| 75 | + break | |
| 76 | + board[(x, y)] = side | |
| 77 | + moves.append([x, y, int((time.monotonic() - t) * 1000)]) | |
| 78 | + n, line = run_of(board, x, y, side) | |
| 79 | + if n == 5: | |
| 80 | + out = {'winner': side, 'reason': 'five', 'five': line} | |
| 81 | + break | |
| 82 | + if len(board) == size * size: | |
| 83 | + break | |
| 84 | + 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 | + finally: | |
| 91 | + for b in live: | |
| 92 | + try: | |
| 93 | + b.send('END') | |
| 94 | + b.p.wait(1) | |
| 95 | + except Exception: | |
| 96 | + b.p.kill() | |
| 97 | + return {**base(size, opening, moves), **out} | |
| 98 | + | |
| 99 | + | |
| 100 | +def base(size, opening, moves): | |
| 101 | + return {'game': 'gomoku', 'rule': 'standard', 'size': size, 'opening': len(opening), 'moves': moves} | |
+8 −0 referee/openings.txt added View file
| @@ -0,0 +1,8 @@ | ||
| 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. | |
| 3 | +7,7 8,8 6,8 | |
| 4 | +7,7 8,7 9,9 | |
| 5 | +7,7 6,6 8,5 | |
| 6 | +7,7 8,6 6,9 | |
| 7 | +7,7 9,7 7,9 | |
| 8 | +7,7 8,9 9,6 | |
+57 −0 referee/play.py added View file
| @@ -0,0 +1,57 @@ | ||
| 1 | +"""The project's referee. check: the route's brain against the season's field, every opening, both colours; the score | |
| 2 | +is the share of points won (a win 1, a draw 0.5), each field brain a subtask. practice: one opening each, both colours, | |
| 3 | +a replay of every game in replays/. | |
| 4 | + python3 -I referee/play.py check|practice <route folder> | |
| 5 | +""" | |
| 6 | +import json, os, sys | |
| 7 | + | |
| 8 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 9 | +sys.path.insert(0, os.path.join(ROOT, 'kit')) | |
| 10 | +sys.path.insert(0, os.path.join(ROOT, 'referee')) | |
| 11 | +import league # noqa: E402 | |
| 12 | +from gomoku import match # noqa: E402 | |
| 13 | + | |
| 14 | +PY = [sys.executable, '-s'] | |
| 15 | + | |
| 16 | + | |
| 17 | +def brain(folder): | |
| 18 | + """A folder's brain: an executable named pbrain (any language, as Gomocup brains are), else bot.py.""" | |
| 19 | + exe = os.path.join(folder, 'pbrain') | |
| 20 | + if os.path.isfile(exe) and os.access(exe, os.X_OK): | |
| 21 | + return [exe], folder | |
| 22 | + if os.path.isfile(os.path.join(folder, 'bot.py')): | |
| 23 | + return [*PY, 'bot.py'], folder | |
| 24 | + sys.exit(f'No brain in {folder}: add bot.py (a Gomocup brain in Python) or an executable pbrain') | |
| 25 | + | |
| 26 | + | |
| 27 | +def openings(): | |
| 28 | + with open(os.path.join(ROOT, 'referee', 'openings.txt')) as f: | |
| 29 | + return [[tuple(map(int, p.split(','))) for p in l.split()] for l in f if l.strip() and not l.startswith('#')] | |
| 30 | + | |
| 31 | + | |
| 32 | +def main(mode, route): | |
| 33 | + me = brain(league.bot_dir(route)) | |
| 34 | + name = league.route_name() | |
| 35 | + ops = openings() if mode == 'check' else openings()[:1] | |
| 36 | + reps = league.Replays() if mode == 'practice' else None | |
| 37 | + subtasks = {} | |
| 38 | + for opp in league.field(os.path.join(ROOT, 'field')): | |
| 39 | + them = brain(os.path.join(ROOT, 'field', opp)) | |
| 40 | + points = 0.0 | |
| 41 | + for k, op in enumerate(ops): | |
| 42 | + for black in (0, 1): # 0: the route plays black | |
| 43 | + seats = [me, them] if black == 0 else [them, me] | |
| 44 | + r = match(seats, op) | |
| 45 | + mine = black # the route's colour index: 0 black, 1 white | |
| 46 | + points += 1.0 if r['winner'] == mine else 0.5 if r['winner'] is None else 0.0 | |
| 47 | + if reps: | |
| 48 | + who = [name, opp] if black == 0 else [opp, name] | |
| 49 | + r['players'] = who | |
| 50 | + end = 'a draw' if r['winner'] is None else f'{who[r["winner"]]} wins ({r["reason"]})' | |
| 51 | + reps.add(f'{opp}-{k}-{"black" if black == 0 else "white"}.json', r, f'{who[0]} (black) vs {who[1]} (white), opening {k + 1}', who, f'{end} in {len(r["moves"]) - r["opening"]} moves') | |
| 52 | + subtasks[opp] = points / (2 * len(ops)) | |
| 53 | + league.write_metrics(sum(subtasks.values()) / len(subtasks), subtasks) | |
| 54 | + | |
| 55 | + | |
| 56 | +if __name__ == '__main__': | |
| 57 | + main(sys.argv[1], sys.argv[2]) | |
+6 −0 referee/practice.sbatch added View file
| @@ -0,0 +1,6 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +#SBATCH --job-name=practice | |
| 3 | +#SBATCH --time=10:00 | |
| 4 | +# A practice run from your route: a game against each field brain in both colours, replays in replays/ (see Matches). | |
| 5 | +PY=$(command -v gomoku-python || command -v python3) | |
| 6 | +PYTHONHASHSEED=0 "$PY" -s referee/play.py practice "$SS_INPUT/$SS_ROUTE_DIR" | |
+1 −0 referee/requirements.txt added View file
| @@ -0,0 +1 @@ | ||
| 1 | +# Python 3.12. Nothing beyond the standard library. | |
+94 −0 referee/room.py added View file
| @@ -0,0 +1,94 @@ | ||
| 1 | +"""A live room (docs/games.md §8): one game of Gomoku on an empty 15x15 board, standard rule, black first, with people, | |
| 2 | +agents and brains at the two seats, speaking the room protocol through kit/room.py. A brain (a route's or a field | |
| 3 | +folder's, as in the checks) is told the whole position by BOARD before each move, 1 s a move; a seat on auto, a person | |
| 4 | +out of time and a brain that fails are played by the field's threats brain. At the end the game is left in replays/ in | |
| 5 | +the practice replays' format with a matches.json entry, so it lists as a match. | |
| 6 | + | |
| 7 | + SS_ROOM='{"seats": {"0": {"kind": "human", "name": "ada"}, "1": {"kind": "bot", "field": "threats"}}}' \ | |
| 8 | + python3 -I referee/room.py | |
| 9 | + | |
| 10 | +Messages (a point is "x,y", 0-based, x across): | |
| 11 | + event {type: start, size, seats} | |
| 12 | + request {to, version, obs: {seat, colour, size, moves: [[x, y, seat]], last}, legal: every empty point} | |
| 13 | + event {type: move, seat, x, y} | |
| 14 | + event {type: over, winner, reason, five}, then end {result, scores} | |
| 15 | +""" | |
| 16 | +import os, queue, sys | |
| 17 | + | |
| 18 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 19 | +sys.path[:0] = [os.path.join(ROOT, 'kit'), os.path.join(ROOT, 'referee')] # kit's room.py, not this file | |
| 20 | +from room import Room, Stop # noqa: E402 (first: play.py puts referee/ ahead of kit/ as it loads) | |
| 21 | +import league # noqa: E402 | |
| 22 | +from gomoku import Brain, base, run_of # noqa: E402 | |
| 23 | +from play import brain # noqa: E402 | |
| 24 | + | |
| 25 | +SIZE, TURN = 15, 1.0 | |
| 26 | +COLOUR = {'0': 'black', '1': 'white'} | |
| 27 | + | |
| 28 | + | |
| 29 | +class Seat: | |
| 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 | + def __init__(self, folder): | |
| 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') | |
| 36 | + | |
| 37 | + def ask(self, msg): | |
| 38 | + 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 | + try: | |
| 41 | + return self.b.reply(TURN + 0.5).replace(' ', '') | |
| 42 | + except queue.Empty: | |
| 43 | + return None | |
| 44 | + | |
| 45 | + def close(self): | |
| 46 | + try: | |
| 47 | + self.b.send('END') | |
| 48 | + self.b.p.wait(1) | |
| 49 | + except Exception: | |
| 50 | + self.b.p.kill() | |
| 51 | + | |
| 52 | + | |
| 53 | +def main(): | |
| 54 | + room = Room(Seat, field=os.path.join(ROOT, 'field')) | |
| 55 | + names = [room.label(s) for s in '01'] | |
| 56 | + stand_in = room._bot({'field': 'threats'}) | |
| 57 | + 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'}}) | |
| 59 | + | |
| 60 | + def auto(obs, legal): | |
| 61 | + move = stand_in.ask(obs) if stand_in else None | |
| 62 | + return move if move in legal else min(legal, key=lambda p: abs(int(p.split(',')[0]) - 7) + abs(int(p.split(',')[1]) - 7)) | |
| 63 | + | |
| 64 | + side, stopped = 0, False | |
| 65 | + try: | |
| 66 | + while len(board) < SIZE * SIZE: | |
| 67 | + s = str(side) | |
| 68 | + 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} | |
| 70 | + move = room.ask(s, obs, legal, auto=lambda: auto(obs, legal), bot=obs) | |
| 71 | + x, y = map(int, move.split(',')) | |
| 72 | + board[(x, y)] = side | |
| 73 | + moves.append([x, y, 0, s]) | |
| 74 | + room.event({'type': 'move', 'seat': s, 'x': x, 'y': y}) | |
| 75 | + n, line = run_of(board, x, y, side) | |
| 76 | + if n == 5: | |
| 77 | + out = {'winner': side, 'reason': 'five', 'five': line} | |
| 78 | + break | |
| 79 | + side = 1 - side | |
| 80 | + except Stop: | |
| 81 | + stopped, out = True, {'winner': None, 'reason': 'stopped', 'five': None} | |
| 82 | + | |
| 83 | + w = out['winner'] | |
| 84 | + result = 'stopped' if stopped else 'a draw' if w is None else f'{names[w]} ({COLOUR[str(w)]}) wins' | |
| 85 | + room.event({'type': 'over', 'winner': None if w is None else str(w), 'reason': out['reason'], 'five': out['five']}) | |
| 86 | + replay = {**base(SIZE, [], [m[:3] for m in moves]), **out, 'players': names} | |
| 87 | + league.Replays().add('room.json', replay, f'Room: {names[0]} (black) vs {names[1]} (white)', names, | |
| 88 | + 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'}) | |
| 90 | + room.close() | |
| 91 | + | |
| 92 | + | |
| 93 | +if __name__ == '__main__': | |
| 94 | + main() | |
+7 −0 referee/room.sbatch added View file
| @@ -0,0 +1,7 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +#SBATCH --job-name=room | |
| 3 | +#SBATCH --time=60:00 | |
| 4 | +# A live room (docs/games.md §8): one game, seats as SS_ROOM says; speaks the room protocol on stdin and stdout, and | |
| 5 | +# leaves the game in replays/ so the room lists as a match. | |
| 6 | +PY=$(command -v gomoku-python || command -v python3) | |
| 7 | +exec "$PY" -s referee/room.py | |
+57 −0 routes/bot.py added View file
| @@ -0,0 +1,57 @@ | ||
| 1 | +"""The starter brain, in the Gomocup protocol (START, BEGIN, TURN, BOARD, END on stdin; "x,y" on stdout): win if it can, | |
| 2 | +else block a five, else a random point next to the stones. Copy it into routes/<your route>/bot.py and make it better.""" | |
| 3 | +import random, sys | |
| 4 | + | |
| 5 | +N, board, me = 15, {}, 1 | |
| 6 | +D = [(1, 0), (0, 1), (1, 1), (1, -1)] | |
| 7 | + | |
| 8 | + | |
| 9 | +def five(x, y, c): | |
| 10 | + for dx, dy in D: | |
| 11 | + n = 1 | |
| 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; i += 1 | |
| 16 | + if n >= 5: | |
| 17 | + return True | |
| 18 | + return False | |
| 19 | + | |
| 20 | + | |
| 21 | +def move(): | |
| 22 | + empty = [(x, y) for x in range(N) for y in range(N) if (x, y) not in board] | |
| 23 | + near = [p for p in empty if any((p[0] + a, p[1] + b) in board for a in (-1, 0, 1) for b in (-1, 0, 1))] or [(N // 2, N // 2)] | |
| 24 | + for c in (me, 3 - me): | |
| 25 | + for p in near: | |
| 26 | + if five(*p, c): | |
| 27 | + return p | |
| 28 | + return random.choice(near) | |
| 29 | + | |
| 30 | + | |
| 31 | +def play(): | |
| 32 | + x, y = move(); board[(x, y)] = me | |
| 33 | + print(f'{x},{y}', flush=True) | |
| 34 | + | |
| 35 | + | |
| 36 | +random.seed(int(sys.argv[1]) if len(sys.argv) > 1 else 0) | |
| 37 | +for line in sys.stdin: | |
| 38 | + cmd, _, arg = line.strip().partition(' ') | |
| 39 | + cmd = cmd.upper() | |
| 40 | + if cmd == 'START': | |
| 41 | + N = int(arg); board.clear(); print('OK', flush=True) | |
| 42 | + elif cmd == 'BEGIN': | |
| 43 | + play() | |
| 44 | + elif cmd == 'TURN': | |
| 45 | + x, y = map(int, arg.split(',')); board[(x, y)] = 2; play() | |
| 46 | + elif cmd == 'BOARD': | |
| 47 | + for l in sys.stdin: | |
| 48 | + if l.strip().upper() == 'DONE': | |
| 49 | + break | |
| 50 | + x, y, f = map(int, l.split(',')); board[(x, y)] = 1 if f == 1 else 2 | |
| 51 | + play() | |
| 52 | + elif cmd == 'ABOUT': | |
| 53 | + print('name="starter", version="0.1", author="shoal-games"', flush=True) | |
| 54 | + elif cmd == 'END': | |
| 55 | + break | |
| 56 | + elif cmd != 'INFO': | |
| 57 | + print('UNKNOWN', flush=True) | |
+19 −0 viewer/bridge.js added View file
| @@ -0,0 +1,19 @@ | ||
| 1 | +// The page around a game's viewer hands it the replay (Shoal's public/app.js): the viewer runs in a frame with scripts | |
| 2 | +// only, says it is ready, and gets {shoal: 'replay', name, text}. Opened by itself, it reads ?replay=<file> instead, | |
| 3 | +// so a replay can be watched from a clone: python3 -m http.server, then viewer/index.html?replay=../replays/x.json | |
| 4 | +// A live room (docs/games.md §8.4) opens the viewer as index.html?live. shoalLive(fn) says {shoal: 'ready', live: true} | |
| 5 | +// and calls fn(batch) for each {shoal: 'live', room, you, messages}: you lists the seats this viewer controls, the first | |
| 6 | +// batch holds all the history it may see, and each message is the room's (§8.3) plus its sequence number n: {n, to, | |
| 7 | +// version, obs, legal} asks a seat to act, {n, to, obs} tells one seat, {n, event} is public, {n, end} ends the room. | |
| 8 | +// shoalAct(seat, version, action) plays for a seat in you, answering the request with that version. | |
| 9 | +window.shoalReplay = start => { | |
| 10 | + addEventListener('message', e => { if (e.data && e.data.shoal === 'replay') start(e.data.text, e.data.name) }) | |
| 11 | + const local = new URLSearchParams(location.search).get('replay') | |
| 12 | + if (local) fetch(local).then(r => r.text()).then(t => start(t, local)) | |
| 13 | + else if (parent !== window) parent.postMessage({ shoal: 'ready' }, '*') | |
| 14 | +} | |
| 15 | +window.shoalLive = fn => { | |
| 16 | + addEventListener('message', e => { if (e.data && e.data.shoal === 'live') fn(e.data) }) | |
| 17 | + if (parent !== window) parent.postMessage({ shoal: 'ready', live: true }, '*') | |
| 18 | +} | |
| 19 | +window.shoalAct = (seat, version, action) => parent.postMessage({ shoal: 'act', seat, version, action }, '*') | |
+271 −0 viewer/index.html added View file
| @@ -0,0 +1,271 @@ | ||
| 1 | + | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | +<meta charset="utf-8"> | |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | +<title>五子棋</title> | |
| 7 | +<style> | |
| 8 | +/* The 五子棋 everyone has played in an app: a wooden board with its star points and A–O / 1–15 along the edge, glossy | |
| 9 | + black and white stones dropping onto it, the last move dotted red, the winning five lit, a player card on each side | |
| 10 | + (the one to move glows), and a banner at the end. Replays get a play bar; a live table takes clicks (?live). */ | |
| 11 | +:root { --ink: #f4e8d2; --muted: #c9b48f; --gold: #f2c14e; --card: rgba(255, 244, 222, .07); --line: rgba(255, 244, 222, .12); } | |
| 12 | +* { box-sizing: border-box; } | |
| 13 | +html, body { margin: 0; height: 100%; overflow: hidden; } | |
| 14 | +body { background: radial-gradient(ellipse at 50% 35%, #5a4029 0%, #3a281a 55%, #1f150d 100%); color: var(--ink); | |
| 15 | + font: 14px/1.4 "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif; user-select: none; } | |
| 16 | +#app { display: flex; align-items: center; justify-content: center; gap: 22px; height: 100%; padding: 12px; } | |
| 17 | +.center { display: flex; flex-direction: column; align-items: center; gap: 10px; position: relative; } | |
| 18 | +canvas { display: block; border-radius: 6px; box-shadow: 0 2px 0 #8a5a24, 0 22px 50px rgba(0, 0, 0, .55); touch-action: manipulation; } | |
| 19 | +canvas.mine { cursor: pointer; } | |
| 20 | +.player { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 132px; padding: 16px 10px 14px; border: 1px solid var(--line); | |
| 21 | + border-radius: 18px; background: var(--card); transition: box-shadow .25s, border-color .25s, transform .25s; } | |
| 22 | +.player.active { border-color: rgba(242, 193, 78, .7); box-shadow: 0 0 0 1px rgba(242, 193, 78, .5), 0 0 28px rgba(242, 193, 78, .28); transform: translateY(-2px); } | |
| 23 | +.ava { position: relative; display: grid; place-items: center; width: 58px; height: 58px; border-radius: 50%; font-size: 20px; font-weight: 600; | |
| 24 | + color: #fff; background: linear-gradient(145deg, var(--a, #7a8ca8), var(--b, #4c5d78)); box-shadow: inset 0 -3px 6px rgba(0, 0, 0, .25), 0 3px 10px rgba(0, 0, 0, .3); } | |
| 25 | +.ava .stone { position: absolute; right: -4px; bottom: -4px; width: 22px; height: 22px; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, .45); } | |
| 26 | +.stone.black { background: radial-gradient(circle at 34% 30%, #777 0%, #222 45%, #000 100%); } | |
| 27 | +.stone.white { background: radial-gradient(circle at 34% 30%, #fff 0%, #eee 50%, #b9b9b9 100%); } | |
| 28 | +.name { max-width: 100%; overflow: hidden; font-size: 15px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } | |
| 29 | +.role { color: var(--muted); font-size: 12px; } | |
| 30 | +.you { margin-left: 4px; padding: 0 6px; border-radius: 6px; background: var(--gold); color: #3a281a; font-size: 11px; font-weight: 700; vertical-align: 2px; } | |
| 31 | +.turn { height: 20px; padding: 0 10px; border-radius: 10px; background: rgba(242, 193, 78, .16); color: var(--gold); font-size: 12px; line-height: 20px; visibility: hidden; } | |
| 32 | +.player.active .turn { visibility: visible; } | |
| 33 | +.turn i { display: inline-block; width: 4px; height: 4px; margin-left: 3px; border-radius: 50%; background: currentColor; vertical-align: 2px; animation: blink 1.2s infinite; } | |
| 34 | +.turn i:nth-child(2) { animation-delay: .2s; } .turn i:nth-child(3) { animation-delay: .4s; } | |
| 35 | +@keyframes blink { 0%, 100% { opacity: .2; } 40% { opacity: 1; } } | |
| 36 | +.bar { display: flex; align-items: center; gap: 6px; height: 36px; padding: 0 8px; border-radius: 18px; background: rgba(0, 0, 0, .28); } | |
| 37 | +.bar button { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ink); font-size: 15px; line-height: 1; cursor: pointer; } | |
| 38 | +.bar button:hover { background: rgba(255, 255, 255, .12); } | |
| 39 | +.bar input { width: clamp(60px, calc(100vw - 300px), 260px); accent-color: var(--gold); } | |
| 40 | +.bar .at { min-width: 64px; color: var(--muted); font-size: 12px; text-align: right; } | |
| 41 | +.status { padding: 0 8px; color: var(--ink); font-size: 14px; } | |
| 42 | +.status b { color: var(--gold); } | |
| 43 | +.banner { position: absolute; left: 50%; top: 42%; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 18px 40px 16px; | |
| 44 | + border: 1px solid rgba(242, 193, 78, .55); border-radius: 20px; background: rgba(31, 21, 13, .86); box-shadow: 0 20px 50px rgba(0, 0, 0, .5); | |
| 45 | + transform: translate(-50%, -50%); animation: pop .35s cubic-bezier(.2, .9, .3, 1.3); pointer-events: none; white-space: nowrap; } | |
| 46 | +.banner b { color: var(--gold); font-size: 34px; letter-spacing: .12em; } | |
| 47 | +.banner small { color: var(--muted); font-size: 13px; } | |
| 48 | +@keyframes pop { from { opacity: 0; transform: translate(-50%, -40%) scale(.85); } } | |
| 49 | +.banner.docked { top: 10px; flex-direction: row; gap: 10px; padding: 6px 18px; border-radius: 14px; transform: translate(-50%, 0); animation: none; | |
| 50 | + transition: top .4s cubic-bezier(.2, .8, .2, 1), transform .4s cubic-bezier(.2, .8, .2, 1), padding .4s; } | |
| 51 | +.banner.docked b { font-size: 18px; letter-spacing: .06em; } | |
| 52 | +@media (max-aspect-ratio: 5/4) { | |
| 53 | + #app { flex-direction: column; gap: 10px; } | |
| 54 | + .player { flex-direction: row; width: auto; min-width: 0; padding: 8px 14px; border-radius: 14px; } | |
| 55 | + .ava { width: 40px; height: 40px; font-size: 15px; } | |
| 56 | + .ava .stone { width: 16px; height: 16px; } | |
| 57 | + #p0 { order: 2; } .center { order: 1; } #p1 { order: 0; } | |
| 58 | +} | |
| 59 | +</style> | |
| 60 | +</head> | |
| 61 | +<body> | |
| 62 | +<div id="app"> | |
| 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 | + <div class="center"> | |
| 65 | + <canvas id="board"></canvas> | |
| 66 | + <div class="bar" id="bar"><span class="status" id="status">等待对局…</span></div> | |
| 67 | + </div> | |
| 68 | + <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> | |
| 69 | +</div> | |
| 70 | +<script src="bridge.js"></script> | |
| 71 | +<script> | |
| 72 | +const N = 15, STARS = [[3, 3], [11, 3], [7, 7], [3, 11], [11, 11]], LETTERS = 'ABCDEFGHIJKLMNO' | |
| 73 | +const live = new URLSearchParams(location.search).has('live') | |
| 74 | +const cv = document.getElementById('board'), g = cv.getContext('2d'), bar = document.getElementById('bar') | |
| 75 | +// the game as drawn: stones in order (black first), the players, who moves, the five and how it ended | |
| 76 | +const G = { moves: [], shown: 0, names: ['', ''], kinds: ['', ''], five: null, over: null, you: [], ask: null, hover: null, drop: 1 } | |
| 77 | +let S = 480, pad = 30, cell = 30, wood = null | |
| 78 | + | |
| 79 | +// ---- drawing ------------------------------------------------------------------------------------------------------ | |
| 80 | +function fit() { | |
| 81 | + const narrow = innerWidth / innerHeight < 1.25 | |
| 82 | + 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 | + pad = S * 0.062; cell = (S - 2 * pad) / (N - 1) | |
| 85 | + const r = devicePixelRatio || 1 | |
| 86 | + cv.width = cv.height = Math.round(S * r); cv.style.width = cv.style.height = S + 'px' | |
| 87 | + g.setTransform(r, 0, 0, r, 0, 0) | |
| 88 | + wood = grain(S) | |
| 89 | + draw() | |
| 90 | +} | |
| 91 | +// the board's wood, once per size: a warm gradient and a few dozen wavy grain lines from a fixed seed | |
| 92 | +function grain(s) { | |
| 93 | + const c = document.createElement('canvas'), x = c.getContext('2d'), r = devicePixelRatio || 1 | |
| 94 | + c.width = c.height = Math.round(s * r); x.scale(r, r) | |
| 95 | + const lg = x.createLinearGradient(0, 0, s, s); lg.addColorStop(0, '#ecc87f'); lg.addColorStop(.55, '#e1b467'); lg.addColorStop(1, '#d2a055') | |
| 96 | + x.fillStyle = lg; x.fillRect(0, 0, s, s) | |
| 97 | + let seed = 7; const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647) | |
| 98 | + for (let i = 0; i < 70; i++) { | |
| 99 | + const y0 = rnd() * s, amp = 2 + rnd() * 6, k = 0.004 + rnd() * 0.01, ph = rnd() * 6 | |
| 100 | + x.strokeStyle = `rgba(${120 + rnd() * 30 | 0}, ${70 + rnd() * 20 | 0}, 25, ${0.04 + rnd() * 0.08})`; x.lineWidth = 0.6 + rnd() * 2.2 | |
| 101 | + x.beginPath(); for (let px = 0; px <= s; px += 6) x.lineTo(px, y0 + Math.sin(px * k + ph) * amp + Math.sin(px * k * 3.1) * amp * 0.3); x.stroke() | |
| 102 | + } | |
| 103 | + return c | |
| 104 | +} | |
| 105 | +const P = i => pad + i * cell | |
| 106 | +function stone(x, y, black, alpha = 1, scale = 1) { | |
| 107 | + const cx = P(x), cy = P(y), r = cell * 0.46 * scale | |
| 108 | + g.save(); g.globalAlpha = alpha | |
| 109 | + g.shadowColor = 'rgba(40, 20, 0, .45)'; g.shadowBlur = cell * 0.14; g.shadowOffsetX = cell * 0.05; g.shadowOffsetY = cell * 0.08 | |
| 110 | + const rg = g.createRadialGradient(cx - r * 0.35, cy - r * 0.4, r * 0.08, cx, cy, r) | |
| 111 | + if (black) { rg.addColorStop(0, '#7d7d7d'); rg.addColorStop(0.35, '#2a2a2a'); rg.addColorStop(1, '#050505') } | |
| 112 | + else { rg.addColorStop(0, '#ffffff'); rg.addColorStop(0.55, '#ececec'); rg.addColorStop(1, '#b5b2ab') } | |
| 113 | + g.fillStyle = rg; g.beginPath(); g.arc(cx, cy, r, 0, 7); g.fill(); g.restore() | |
| 114 | +} | |
| 115 | +function draw() { | |
| 116 | + if (!wood) return | |
| 117 | + g.clearRect(0, 0, S, S) | |
| 118 | + g.drawImage(wood, 0, 0, S, S) | |
| 119 | + g.strokeStyle = 'rgba(60, 38, 14, .9)'; g.lineWidth = Math.max(1, S / 560) | |
| 120 | + g.beginPath() | |
| 121 | + for (let i = 0; i < N; i++) { g.moveTo(P(0), P(i)); g.lineTo(P(N - 1), P(i)); g.moveTo(P(i), P(0)); g.lineTo(P(i), P(N - 1)) } | |
| 122 | + g.stroke() | |
| 123 | + g.lineWidth = Math.max(2, S / 260); g.strokeRect(P(0), P(0), cell * (N - 1), cell * (N - 1)) | |
| 124 | + g.fillStyle = 'rgba(60, 38, 14, .95)' | |
| 125 | + for (const [x, y] of STARS) { g.beginPath(); g.arc(P(x), P(y), Math.max(2.5, cell * 0.12), 0, 7); g.fill() } | |
| 126 | + g.fillStyle = 'rgba(92, 58, 22, .8)'; g.font = `${Math.max(9, cell * 0.36)}px "PingFang SC", system-ui, sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle' | |
| 127 | + for (let i = 0; i < N; i++) { g.fillText(LETTERS[i], P(i), S - pad * 0.45); g.fillText(String(N - i), pad * 0.42, P(i)) } | |
| 128 | + const shown = G.moves.slice(0, G.shown) | |
| 129 | + shown.forEach(([x, y], i) => { | |
| 130 | + const last = i === shown.length - 1, t = last ? G.drop : 1 | |
| 131 | + stone(x, y, i % 2 === 0, t, last ? 1.18 - 0.18 * t : 1) | |
| 132 | + }) | |
| 133 | + const last = shown.at(-1) | |
| 134 | + if (last && G.drop >= 1) { g.fillStyle = '#e8383d'; g.beginPath(); g.arc(P(last[0]), P(last[1]), Math.max(2.5, cell * 0.12), 0, 7); g.fill() } | |
| 135 | + if (G.five && G.shown === G.moves.length) { | |
| 136 | + const [a, b] = [G.five[0], G.five.at(-1)] | |
| 137 | + g.save(); g.strokeStyle = 'rgba(242, 193, 78, .95)'; g.lineWidth = Math.max(3, cell * 0.14); g.lineCap = 'round' | |
| 138 | + g.shadowColor = 'rgba(242, 193, 78, .9)'; g.shadowBlur = cell * 0.5 | |
| 139 | + g.beginPath(); g.moveTo(P(a[0]), P(a[1])); g.lineTo(P(b[0]), P(b[1])); g.stroke(); g.restore() | |
| 140 | + } | |
| 141 | + if (G.ask && G.hover && !taken(...G.hover)) stone(G.hover[0], G.hover[1], G.ask.obs.colour === 'black', 0.45) | |
| 142 | +} | |
| 143 | +let anim = 0 | |
| 144 | +function dropIn() { | |
| 145 | + cancelAnimationFrame(anim); G.drop = 0 | |
| 146 | + const t0 = performance.now(), step = t => { G.drop = Math.min(1, (t - t0) / 170); draw(); if (G.drop < 1) anim = requestAnimationFrame(step) } | |
| 147 | + anim = requestAnimationFrame(step) | |
| 148 | +} | |
| 149 | +const taken = (x, y) => G.moves.some(m => m[0] === x && m[1] === y) | |
| 150 | + | |
| 151 | +// ---- the players ---------------------------------------------------------------------------------------------------- | |
| 152 | +const TINTS = [['#7a8ca8', '#4c5d78'], ['#c9875b', '#8e5634'], ['#6fa58a', '#3f6e57'], ['#a07ab8', '#6c4b85']] | |
| 153 | +function cards() { | |
| 154 | + const toMove = G.over || (!live && G.shown >= G.moves.length) ? -1 : G.shown % 2 | |
| 155 | + for (const i of [0, 1]) { | |
| 156 | + const el = document.getElementById('p' + i), name = G.names[i] || (i ? '白方' : '黑方') | |
| 157 | + const tint = TINTS[[...name].reduce((s, ch) => s + ch.charCodeAt(0), 0) % TINTS.length] | |
| 158 | + el.querySelector('.ava span').textContent = [...name][0].toUpperCase() | |
| 159 | + el.querySelector('.ava').style.cssText = `--a:${tint[0]};--b:${tint[1]}` | |
| 160 | + const n = el.querySelector('.name') | |
| 161 | + n.replaceChildren(name) | |
| 162 | + if (G.you.includes(String(i))) n.insertAdjacentHTML('beforeend', '<span class="you">你</span>') | |
| 163 | + el.querySelector('.role').textContent = (i ? '执白 · 后手' : '执黑 · 先手') + (G.kinds[i] === 'auto' ? ' · 托管' : '') | |
| 164 | + el.classList.toggle('active', toMove === i) | |
| 165 | + } | |
| 166 | +} | |
| 167 | +function banner(text, sub) { | |
| 168 | + document.querySelector('.banner')?.remove() | |
| 169 | + if (!text) return | |
| 170 | + const b = document.createElement('div'); b.className = 'banner' | |
| 171 | + b.innerHTML = '<b></b><small></small>'; b.querySelector('b').textContent = text; b.querySelector('small').textContent = sub | |
| 172 | + document.querySelector('.center').append(b) | |
| 173 | + setTimeout(() => b.classList.add('docked'), 2200) // off the five, to the board's top edge | |
| 174 | +} | |
| 175 | +const REASON = { five: '五连', 'board full': '棋盘下满', stopped: '对局中止', 'out of time or no move': '超时', 'did not start': '未能开局' } | |
| 176 | +const reason = r => REASON[r] ?? (r?.startsWith('illegal') ? '落子违规' : r ?? '') | |
| 177 | + | |
| 178 | +// ---- a replay ------------------------------------------------------------------------------------------------------- | |
| 179 | +let R = null, timer = 0 | |
| 180 | +function replayBar() { | |
| 181 | + bar.innerHTML = '<button data-go="first" title="开局">⏮</button><button data-go="back" title="上一手">‹</button><button data-go="play" title="播放">▶</button><button data-go="next" title="下一手">›</button><button data-go="last" title="终局">⏭</button><input type="range" min="0" value="0" aria-label="手数"><span class="at"></span>' | |
| 182 | + bar.addEventListener('click', e => { | |
| 183 | + const k = e.target.closest('button')?.dataset.go | |
| 184 | + if (!k) return | |
| 185 | + if (k === 'play') return timer ? pause() : play() | |
| 186 | + pause() | |
| 187 | + go({ first: R.opening, back: G.shown - 1, next: G.shown + 1, last: G.moves.length }[k]) | |
| 188 | + }) | |
| 189 | + bar.querySelector('input').oninput = e => (pause(), go(+e.target.value)) | |
| 190 | +} | |
| 191 | +function go(n) { | |
| 192 | + const was = G.shown | |
| 193 | + G.shown = Math.max(0, Math.min(G.moves.length, n)) | |
| 194 | + if (G.shown === was + 1) dropIn(); else { G.drop = 1; draw() } | |
| 195 | + const at = bar.querySelector('input'); at.max = G.moves.length; at.value = G.shown | |
| 196 | + bar.querySelector('.at').textContent = `第 ${Math.max(0, G.shown - R.opening)} / ${G.moves.length - R.opening} 手` | |
| 197 | + cards() | |
| 198 | + const end = G.shown === G.moves.length | |
| 199 | + banner(end ? (R.winner == null ? '和棋' : `${R.winner ? '白方' : '黑方'}胜`) : '', end ? `${R.winner == null ? '' : `${G.names[R.winner]} · `}${reason(R.reason)}` : '') | |
| 200 | +} | |
| 201 | +function play() { | |
| 202 | + if (G.shown >= G.moves.length) go(R.opening) | |
| 203 | + bar.querySelector('[data-go=play]').textContent = '❚❚' | |
| 204 | + timer = setInterval(() => (G.shown >= G.moves.length ? pause() : go(G.shown + 1)), 420) | |
| 205 | +} | |
| 206 | +function pause() { clearInterval(timer); timer = 0; bar.querySelector('[data-go=play]').textContent = '▶' } | |
| 207 | + | |
| 208 | +// ---- a live table --------------------------------------------------------------------------------------------------- | |
| 209 | +let audio = null | |
| 210 | +function tock() { | |
| 211 | + try { | |
| 212 | + audio ??= new AudioContext() | |
| 213 | + const o = audio.createOscillator(), v = audio.createGain(), t = audio.currentTime | |
| 214 | + o.frequency.setValueAtTime(900, t); o.frequency.exponentialRampToValueAtTime(260, t + 0.06) | |
| 215 | + v.gain.setValueAtTime(0.25, t); v.gain.exponentialRampToValueAtTime(0.001, t + 0.09) | |
| 216 | + o.connect(v).connect(audio.destination); o.start(t); o.stop(t + 0.1) | |
| 217 | + } catch {} | |
| 218 | +} | |
| 219 | +function status() { | |
| 220 | + const s = document.getElementById('status') | |
| 221 | + if (G.over) s.textContent = '对局结束' | |
| 222 | + else if (!G.names.some(Boolean)) s.textContent = '等待开局…' | |
| 223 | + else if (G.ask) s.innerHTML = '<b>轮到你落子</b>' | |
| 224 | + else s.textContent = G.you.length ? '对方思考中…' : `${G.shown % 2 ? '白方' : '黑方'}思考中…` | |
| 225 | + cv.classList.toggle('mine', !!G.ask) | |
| 226 | +} | |
| 227 | +function take(batch) { | |
| 228 | + G.you = batch.you || [] | |
| 229 | + for (const m of batch.messages) { | |
| 230 | + const e = m.event | |
| 231 | + if (e?.type === 'start') { for (const [s, x] of Object.entries(e.seats)) { G.names[+s] = x.name; G.kinds[+s] = x.kind } } | |
| 232 | + else if (e?.type === 'control') { G.names[+e.seat] = e.name; G.kinds[+e.seat] = e.kind } | |
| 233 | + else if (e?.type === 'move') { | |
| 234 | + G.moves.push([e.x, e.y]); G.shown = G.moves.length | |
| 235 | + if (G.ask && m.n > G.ask.n) G.ask = null | |
| 236 | + if (batch.messages.length < 4) { dropIn(); tock() } else G.drop = 1 | |
| 237 | + } else if (e?.type === 'over') { G.over = e; G.five = e.five; G.ask = null } | |
| 238 | + else if (m.legal && G.you.includes(m.to)) G.ask = m | |
| 239 | + else if (m.end) G.ask = null | |
| 240 | + } | |
| 241 | + if (G.over) { | |
| 242 | + const w = G.over.winner, mine = w != null && G.you.includes(w), lost = w != null && G.you.length && !mine | |
| 243 | + banner(w == null ? (G.over.reason === 'stopped' ? '对局中止' : '和棋') : mine ? '你赢了' : lost ? '你输了' : `${w === '1' ? '白方' : '黑方'}胜`, | |
| 244 | + `${w == null ? '' : `${G.names[+w]} · `}${reason(G.over.reason)}`) | |
| 245 | + } | |
| 246 | + cards(); status(); draw() | |
| 247 | +} | |
| 248 | +const point = e => { | |
| 249 | + const r = cv.getBoundingClientRect(), x = Math.round((e.clientX - r.left - pad) / cell), y = Math.round((e.clientY - r.top - pad) / cell) | |
| 250 | + return x >= 0 && y >= 0 && x < N && y < N ? [x, y] : null | |
| 251 | +} | |
| 252 | +cv.addEventListener('pointermove', e => { if (!G.ask) return; const p = point(e); if (String(p) !== String(G.hover)) { G.hover = p; draw() } }) | |
| 253 | +cv.addEventListener('pointerleave', () => { G.hover = null; draw() }) | |
| 254 | +cv.addEventListener('click', e => { | |
| 255 | + const p = point(e), a = G.ask | |
| 256 | + if (!a || !p || taken(...p) || !a.legal.includes(`${p[0]},${p[1]}`)) return | |
| 257 | + shoalAct(a.to, a.version, `${p[0]},${p[1]}`) | |
| 258 | + G.ask = null; G.hover = null; status(); draw() | |
| 259 | +}) | |
| 260 | + | |
| 261 | +addEventListener('resize', fit) | |
| 262 | +fit(); cards() | |
| 263 | +if (live) shoalLive(take) | |
| 264 | +else shoalReplay(text => { | |
| 265 | + R = JSON.parse(text) | |
| 266 | + G.moves = R.moves.map(m => [m[0], m[1]]); G.five = R.five; G.names = R.players || ['黑方', '白方'] | |
| 267 | + replayBar(); go(R.opening); play() | |
| 268 | +}) | |
| 269 | +</script> | |
| 270 | +</body> | |
| 271 | +</html> | |
