Comparing changes
Choose two branches to see what changed, then open a pull request to bring the work across.
Able to merge. These branches can be merged automatically.
A pull request is already open: #3 Score open, closed and broken Gomoku threats with exact-five tactics
3 commits
+1 −0 routes/codex-line-threats/.gitignore added View file
| @@ -0,0 +1 @@ | ||
| 1 | +__pycache__/ | |
+73 −0 routes/codex-line-threats/README.md added View file
| @@ -0,0 +1,73 @@ | ||
| 1 | +--- | |
| 2 | +issue: 1 | |
| 3 | +status: done | |
| 4 | +--- | |
| 5 | +# Score exact-five threats and open/closed lines | |
| 6 | + | |
| 7 | +This route answers issue #1 with a deterministic, standard-library Gomocup | |
| 8 | +brain. It starts from `routes/bot.py` at main commit | |
| 9 | +`38fc3717d3d9bf93bb084d1aecbc909e4ef94b90`; the referee and field are unchanged. | |
| 10 | + | |
| 11 | +## What changed | |
| 12 | + | |
| 13 | +Score each empty point within two cells of existing stones in four directions, | |
| 14 | +for both colours. Contiguous open/closed twos, threes and fours get different | |
| 15 | +weights. Five-cell windows also recognize broken fours and count distinct | |
| 16 | +winning replies. Open broken threes and double threats get additional weight. | |
| 17 | +An immediate exact-five win takes priority, then blocking the opponent's | |
| 18 | +immediate win, then the combined attack/defence score. Ties are deterministic. | |
| 19 | + | |
| 20 | +The earlier `threat-weights` route at `dd0cb54` was read before claiming this | |
| 21 | +issue. It clamps overlines to five and uses contiguous patterns only. This | |
| 22 | +route keeps overlines out of its winning test and detects gaps. Its protocol | |
| 23 | +loop replaces BOARD snapshots, resets on RESTART, and treats flag 3 as blocked. | |
| 24 | + | |
| 25 | +## What happened | |
| 26 | + | |
| 27 | +The unchanged starter check scored 0.5 (neighbour 0.5, random 1, threats 0). | |
| 28 | +The first implementation of this route scored **0.861111**, independently | |
| 29 | +repeated through an audit of all 36 season matches: neighbour 1, random 1, | |
| 30 | +threats 0.583333. This exceeds the previously recorded threat-weights score | |
| 31 | +0.833333 on this fixed field; it does not establish general playing strength. | |
| 32 | + | |
| 33 | +Against threats it won 6, drew 2, and lost 4. All 36 games ended by five or a | |
| 34 | +full board; none ended by timeout, startup failure, or an illegal move. | |
| 35 | +Maximum referee-measured local reply latency was 8 ms, under the nominal | |
| 36 | +1 second limit on this machine. Hosted performance remains to be checked. | |
| 37 | + | |
| 38 | +The local tactical/protocol suite passes 11 tests. The site has not posted a | |
| 39 | +check status for the implementation commit f3fcc86. Its compute configuration | |
| 40 | +reports no runner enabled to run checks (the listed runner has runs_checks=false). | |
| 41 | +Local measurements are reported evidence, not hosted verification or acceptance. | |
| 42 | +The route is done as a delivery; external review and CI remain pending. | |
| 43 | + | |
| 44 | +## Reproduce | |
| 45 | + | |
| 46 | +From the repository root, with Python 3 (standard library only): | |
| 47 | + | |
| 48 | +```sh | |
| 49 | +python3 routes/codex-line-threats/test_bot.py | |
| 50 | +python3 -I referee/play.py check routes/codex-line-threats | |
| 51 | +python3 routes/codex-line-threats/audit.py | |
| 52 | +``` | |
| 53 | + | |
| 54 | +The second command is the project's required season check; it creates | |
| 55 | +`metrics.json` in the current directory. The audit uses the same unchanged | |
| 56 | +referee and openings, writes `out/games.json` and `out/season.md` in this route, | |
| 57 | +and records losses and per-game timing. Timings vary across runs. Local runs | |
| 58 | +used Python 3.13. The bot uses syntax and standard-library APIs available in | |
| 59 | +the project's Python 3.12 runner. | |
| 60 | + | |
| 61 | +## Limits | |
| 62 | + | |
| 63 | +This is a one-ply heuristic, not a search engine. It can miss forced sequences | |
| 64 | +and still loses against the threats field bot. The season is a small fixed | |
| 65 | +sample, with deterministic openings and opponents. | |
| 66 | + | |
| 67 | +## Next | |
| 68 | + | |
| 69 | +The maintainer should enable a check-capable runner and review this delivery | |
| 70 | +and its hosted play status. If continuing after review, | |
| 71 | +inspect the four losses against threats recorded in out/games.json and add a | |
| 72 | +bounded tactical search to address the first missed forced sequence. No long | |
| 73 | +computation is running. | |
+48 −0 routes/codex-line-threats/audit.py added View file
| @@ -0,0 +1,48 @@ | ||
| 1 | +"""Run the unchanged season matches; keep game outcomes and timing evidence.""" | |
| 2 | +import json | |
| 3 | +from pathlib import Path | |
| 4 | +import sys | |
| 5 | + | |
| 6 | +ROUTE = Path(__file__).resolve().parent | |
| 7 | +ROOT = ROUTE.parents[1] | |
| 8 | +sys.path.insert(0, str(ROOT / 'referee')) | |
| 9 | +import play | |
| 10 | + | |
| 11 | + | |
| 12 | +def main(): | |
| 13 | + rows = [] | |
| 14 | + for opponent in sorted(p.name for p in (ROOT / 'field').iterdir() if p.is_dir()): | |
| 15 | + for opening_index, opening in enumerate(play.openings(), 1): | |
| 16 | + for seat in (0, 1): | |
| 17 | + brains = [play.brain(str(ROUTE)), play.brain(str(ROOT / 'field' / opponent))] | |
| 18 | + if seat: | |
| 19 | + brains.reverse() | |
| 20 | + result = play.match(brains, opening) | |
| 21 | + our_times = [m[2] for i, m in enumerate(result['moves']) if i >= len(opening) and i % 2 == seat] | |
| 22 | + points = 0.5 if result['winner'] is None else float(result['winner'] == seat) | |
| 23 | + rows.append({'opponent': opponent, 'opening': opening_index, | |
| 24 | + 'seat': 'black' if seat == 0 else 'white', 'points': points, | |
| 25 | + 'reason': result['reason'], 'moves': len(result['moves']), | |
| 26 | + 'max_reply_ms': max(our_times, default=0)}) | |
| 27 | + out = ROUTE / 'out' | |
| 28 | + out.mkdir(exist_ok=True) | |
| 29 | + (out / 'games.json').write_text(json.dumps(rows, indent=2) + '\n') | |
| 30 | + groups = {opp: [r for r in rows if r['opponent'] == opp] for opp in sorted({r['opponent'] for r in rows})} | |
| 31 | + lines = ['# Season check evidence', '', 'Generated by `python3 routes/codex-line-threats/audit.py`.', | |
| 32 | + 'Uses the unchanged project referee, all six openings, and both colours.', '', | |
| 33 | + '| Opponent | Wins | Draws | Losses | Points share |', '|---|---:|---:|---:|---:|'] | |
| 34 | + for opp, games in groups.items(): | |
| 35 | + counts = [sum(r['points'] == value for r in games) for value in (1, 0.5, 0)] | |
| 36 | + lines.append(f'| {opp} | {counts[0]} | {counts[1]} | {counts[2]} | {sum(r["points"] for r in games)/len(games):.6f} |') | |
| 37 | + lines += ['', f'Overall points share: {sum(r["points"] for r in rows)/len(rows):.6f}.', | |
| 38 | + f'Maximum measured reply latency: {max(r["max_reply_ms"] for r in rows)} ms.', | |
| 39 | + 'Reply latency is measured by the project referee on this local machine; it is not a cross-machine runtime guarantee.', | |
| 40 | + f'End reasons: {json.dumps({reason: sum(r["reason"] == reason for r in rows) for reason in sorted({r["reason"] for r in rows})})}.', | |
| 41 | + '', 'Per-game evidence, including losses, is in `games.json`.'] | |
| 42 | + report = '\n'.join(lines) + '\n' | |
| 43 | + (out / 'season.md').write_text(report) | |
| 44 | + print(report) | |
| 45 | + | |
| 46 | + | |
| 47 | +if __name__ == '__main__': | |
| 48 | + main() | |
+144 −0 routes/codex-line-threats/bot.py added View file
| @@ -0,0 +1,144 @@ | ||
| 1 | +"""Deterministic Gomocup brain: exact-five tactics and open/closed line scores. | |
| 2 | + | |
| 3 | +Derived from the repository's starter protocol loop. Each hypothetical move | |
| 4 | +is evaluated for both colours. A five-cell window is useful only when neither | |
| 5 | +adjacent cell extends it into an overline. No opponent files are inspected. | |
| 6 | +""" | |
| 7 | +import sys | |
| 8 | + | |
| 9 | +N = 15 | |
| 10 | +board = {} | |
| 11 | +DIRECTIONS = ((1, 0), (0, 1), (1, 1), (1, -1)) | |
| 12 | + | |
| 13 | + | |
| 14 | +def line_at(x, y, colour, dx, dy): | |
| 15 | + cells = [] | |
| 16 | + for offset in range(-5, 6): | |
| 17 | + p = (x + offset * dx, y + offset * dy) | |
| 18 | + if offset == 0: | |
| 19 | + cells.append('X') | |
| 20 | + elif not (0 <= p[0] < N and 0 <= p[1] < N): | |
| 21 | + cells.append('#') | |
| 22 | + else: | |
| 23 | + cells.append('X' if board.get(p) == colour else '#' if p in board else '.') | |
| 24 | + return ''.join(cells) | |
| 25 | + | |
| 26 | + | |
| 27 | +def line_value(line): | |
| 28 | + """Return (exact five, immediate winning offsets, positional score).""" | |
| 29 | + left = right = 5 | |
| 30 | + while left > 0 and line[left - 1] == 'X': | |
| 31 | + left -= 1 | |
| 32 | + while right < 10 and line[right + 1] == 'X': | |
| 33 | + right += 1 | |
| 34 | + length = right - left + 1 | |
| 35 | + if length == 5: | |
| 36 | + return True, set(), 0 | |
| 37 | + if length > 5: | |
| 38 | + return False, set(), 0 | |
| 39 | + ends = int(left > 0 and line[left - 1] == '.') + int(right < 10 and line[right + 1] == '.') | |
| 40 | + weights = {(4, 2): 100000, (4, 1): 10000, (3, 2): 5000, | |
| 41 | + (3, 1): 400, (2, 2): 300, (2, 1): 40, | |
| 42 | + (1, 2): 10, (1, 1): 2} | |
| 43 | + positional = weights.get((length, ends), 0) | |
| 44 | + wins = set() | |
| 45 | + for start in range(1, 6): | |
| 46 | + window = line[start:start + 5] | |
| 47 | + if '#' in window or line[start - 1] == 'X' or line[start + 5] == 'X': | |
| 48 | + continue | |
| 49 | + count = window.count('X') | |
| 50 | + if count == 4: | |
| 51 | + wins.add(start + window.index('.') - 5) | |
| 52 | + elif count == 3: | |
| 53 | + positional = max(positional, 400) | |
| 54 | + elif count == 2: | |
| 55 | + positional = max(positional, 40) | |
| 56 | + # Open broken threes can become a two-ended four in one move. | |
| 57 | + for pattern in ('.XXX.', '.XX.X.', '.X.XX.'): | |
| 58 | + for start in range(len(line) - len(pattern) + 1): | |
| 59 | + if start <= 5 < start + len(pattern) and line[start:start + len(pattern)] == pattern: | |
| 60 | + positional = max(positional, 5000) | |
| 61 | + return False, wins, positional | |
| 62 | + | |
| 63 | + | |
| 64 | +def evaluate(p, colour): | |
| 65 | + win = False | |
| 66 | + winning_points = set() | |
| 67 | + score = 0 | |
| 68 | + open_threes = 0 | |
| 69 | + for dx, dy in DIRECTIONS: | |
| 70 | + exact, offsets, value = line_value(line_at(*p, colour, dx, dy)) | |
| 71 | + win |= exact | |
| 72 | + winning_points.update((p[0] + i * dx, p[1] + i * dy) for i in offsets) | |
| 73 | + score += value | |
| 74 | + open_threes += value >= 5000 | |
| 75 | + # Two distinct winning squares cannot both be blocked in one reply. | |
| 76 | + if len(winning_points) >= 2: | |
| 77 | + score += 1000000 | |
| 78 | + elif winning_points: | |
| 79 | + score += 60000 | |
| 80 | + elif open_threes >= 2: | |
| 81 | + score += 20000 | |
| 82 | + return win, score | |
| 83 | + | |
| 84 | + | |
| 85 | +def move(): | |
| 86 | + if not board: | |
| 87 | + return N // 2, N // 2 | |
| 88 | + near = {(x + a, y + b) for x, y in board | |
| 89 | + for a in range(-2, 3) for b in range(-2, 3)} | |
| 90 | + candidates = sorted(p for p in near | |
| 91 | + if 0 <= p[0] < N and 0 <= p[1] < N and p not in board) | |
| 92 | + if not candidates: | |
| 93 | + raise ValueError('No legal move') | |
| 94 | + ranked = [] | |
| 95 | + for p in candidates: | |
| 96 | + own_win, attack = evaluate(p, 1) | |
| 97 | + their_win, defence = evaluate(p, 2) | |
| 98 | + centrality = -abs(p[0] - N // 2) - abs(p[1] - N // 2) | |
| 99 | + ranked.append(((own_win, their_win, attack * 11 + defence * 10, centrality), p)) | |
| 100 | + return max(ranked)[1] | |
| 101 | + | |
| 102 | + | |
| 103 | +def play(): | |
| 104 | + x, y = move() | |
| 105 | + board[(x, y)] = 1 | |
| 106 | + print(f'{x},{y}', flush=True) | |
| 107 | + | |
| 108 | + | |
| 109 | +def main(): | |
| 110 | + global N | |
| 111 | + for raw in sys.stdin: | |
| 112 | + cmd, _, arg = raw.strip().partition(' ') | |
| 113 | + cmd = cmd.upper() | |
| 114 | + if cmd == 'START': | |
| 115 | + N = int(arg) | |
| 116 | + board.clear() | |
| 117 | + print('OK', flush=True) | |
| 118 | + elif cmd == 'RESTART': | |
| 119 | + board.clear() | |
| 120 | + print('OK', flush=True) | |
| 121 | + elif cmd == 'BEGIN': | |
| 122 | + play() | |
| 123 | + elif cmd == 'TURN': | |
| 124 | + x, y = map(int, arg.split(',')) | |
| 125 | + board[(x, y)] = 2 | |
| 126 | + play() | |
| 127 | + elif cmd == 'BOARD': | |
| 128 | + board.clear() | |
| 129 | + for line in sys.stdin: | |
| 130 | + if line.strip().upper() == 'DONE': | |
| 131 | + break | |
| 132 | + x, y, colour = map(int, line.split(',')) | |
| 133 | + board[(x, y)] = colour # 3 is a blocked cell, not an opponent stone. | |
| 134 | + play() | |
| 135 | + elif cmd == 'ABOUT': | |
| 136 | + print('name="exact-line-threats", version="1", author="codex-golive"', flush=True) | |
| 137 | + elif cmd == 'END': | |
| 138 | + return | |
| 139 | + elif cmd != 'INFO': | |
| 140 | + print('UNKNOWN', flush=True) | |
| 141 | + | |
| 142 | + | |
| 143 | +if __name__ == '__main__': | |
| 144 | + main() | |
+326 −0 routes/codex-line-threats/out/games.json added View file
| @@ -0,0 +1,326 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "opponent": "neighbour", | |
| 4 | + "opening": 1, | |
| 5 | + "seat": "black", | |
| 6 | + "points": 1.0, | |
| 7 | + "reason": "five", | |
| 8 | + "moves": 9, | |
| 9 | + "max_reply_ms": 3 | |
| 10 | + }, | |
| 11 | + { | |
| 12 | + "opponent": "neighbour", | |
| 13 | + "opening": 1, | |
| 14 | + "seat": "white", | |
| 15 | + "points": 1.0, | |
| 16 | + "reason": "five", | |
| 17 | + "moves": 12, | |
| 18 | + "max_reply_ms": 3 | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + "opponent": "neighbour", | |
| 22 | + "opening": 2, | |
| 23 | + "seat": "black", | |
| 24 | + "points": 1.0, | |
| 25 | + "reason": "five", | |
| 26 | + "moves": 9, | |
| 27 | + "max_reply_ms": 3 | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "opponent": "neighbour", | |
| 31 | + "opening": 2, | |
| 32 | + "seat": "white", | |
| 33 | + "points": 1.0, | |
| 34 | + "reason": "five", | |
| 35 | + "moves": 10, | |
| 36 | + "max_reply_ms": 2 | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + "opponent": "neighbour", | |
| 40 | + "opening": 3, | |
| 41 | + "seat": "black", | |
| 42 | + "points": 1.0, | |
| 43 | + "reason": "five", | |
| 44 | + "moves": 11, | |
| 45 | + "max_reply_ms": 3 | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "opponent": "neighbour", | |
| 49 | + "opening": 3, | |
| 50 | + "seat": "white", | |
| 51 | + "points": 1.0, | |
| 52 | + "reason": "five", | |
| 53 | + "moves": 10, | |
| 54 | + "max_reply_ms": 2 | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "opponent": "neighbour", | |
| 58 | + "opening": 4, | |
| 59 | + "seat": "black", | |
| 60 | + "points": 1.0, | |
| 61 | + "reason": "five", | |
| 62 | + "moves": 13, | |
| 63 | + "max_reply_ms": 3 | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + "opponent": "neighbour", | |
| 67 | + "opening": 4, | |
| 68 | + "seat": "white", | |
| 69 | + "points": 1.0, | |
| 70 | + "reason": "five", | |
| 71 | + "moves": 12, | |
| 72 | + "max_reply_ms": 3 | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "opponent": "neighbour", | |
| 76 | + "opening": 5, | |
| 77 | + "seat": "black", | |
| 78 | + "points": 1.0, | |
| 79 | + "reason": "five", | |
| 80 | + "moves": 9, | |
| 81 | + "max_reply_ms": 2 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "opponent": "neighbour", | |
| 85 | + "opening": 5, | |
| 86 | + "seat": "white", | |
| 87 | + "points": 1.0, | |
| 88 | + "reason": "five", | |
| 89 | + "moves": 16, | |
| 90 | + "max_reply_ms": 3 | |
| 91 | + }, | |
| 92 | + { | |
| 93 | + "opponent": "neighbour", | |
| 94 | + "opening": 6, | |
| 95 | + "seat": "black", | |
| 96 | + "points": 1.0, | |
| 97 | + "reason": "five", | |
| 98 | + "moves": 11, | |
| 99 | + "max_reply_ms": 2 | |
| 100 | + }, | |
| 101 | + { | |
| 102 | + "opponent": "neighbour", | |
| 103 | + "opening": 6, | |
| 104 | + "seat": "white", | |
| 105 | + "points": 1.0, | |
| 106 | + "reason": "five", | |
| 107 | + "moves": 10, | |
| 108 | + "max_reply_ms": 3 | |
| 109 | + }, | |
| 110 | + { | |
| 111 | + "opponent": "random", | |
| 112 | + "opening": 1, | |
| 113 | + "seat": "black", | |
| 114 | + "points": 1.0, | |
| 115 | + "reason": "five", | |
| 116 | + "moves": 9, | |
| 117 | + "max_reply_ms": 2 | |
| 118 | + }, | |
| 119 | + { | |
| 120 | + "opponent": "random", | |
| 121 | + "opening": 1, | |
| 122 | + "seat": "white", | |
| 123 | + "points": 1.0, | |
| 124 | + "reason": "five", | |
| 125 | + "moves": 12, | |
| 126 | + "max_reply_ms": 3 | |
| 127 | + }, | |
| 128 | + { | |
| 129 | + "opponent": "random", | |
| 130 | + "opening": 2, | |
| 131 | + "seat": "black", | |
| 132 | + "points": 1.0, | |
| 133 | + "reason": "five", | |
| 134 | + "moves": 9, | |
| 135 | + "max_reply_ms": 2 | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + "opponent": "random", | |
| 139 | + "opening": 2, | |
| 140 | + "seat": "white", | |
| 141 | + "points": 1.0, | |
| 142 | + "reason": "five", | |
| 143 | + "moves": 10, | |
| 144 | + "max_reply_ms": 2 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "opponent": "random", | |
| 148 | + "opening": 3, | |
| 149 | + "seat": "black", | |
| 150 | + "points": 1.0, | |
| 151 | + "reason": "five", | |
| 152 | + "moves": 11, | |
| 153 | + "max_reply_ms": 2 | |
| 154 | + }, | |
| 155 | + { | |
| 156 | + "opponent": "random", | |
| 157 | + "opening": 3, | |
| 158 | + "seat": "white", | |
| 159 | + "points": 1.0, | |
| 160 | + "reason": "five", | |
| 161 | + "moves": 10, | |
| 162 | + "max_reply_ms": 3 | |
| 163 | + }, | |
| 164 | + { | |
| 165 | + "opponent": "random", | |
| 166 | + "opening": 4, | |
| 167 | + "seat": "black", | |
| 168 | + "points": 1.0, | |
| 169 | + "reason": "five", | |
| 170 | + "moves": 13, | |
| 171 | + "max_reply_ms": 3 | |
| 172 | + }, | |
| 173 | + { | |
| 174 | + "opponent": "random", | |
| 175 | + "opening": 4, | |
| 176 | + "seat": "white", | |
| 177 | + "points": 1.0, | |
| 178 | + "reason": "five", | |
| 179 | + "moves": 12, | |
| 180 | + "max_reply_ms": 3 | |
| 181 | + }, | |
| 182 | + { | |
| 183 | + "opponent": "random", | |
| 184 | + "opening": 5, | |
| 185 | + "seat": "black", | |
| 186 | + "points": 1.0, | |
| 187 | + "reason": "five", | |
| 188 | + "moves": 9, | |
| 189 | + "max_reply_ms": 2 | |
| 190 | + }, | |
| 191 | + { | |
| 192 | + "opponent": "random", | |
| 193 | + "opening": 5, | |
| 194 | + "seat": "white", | |
| 195 | + "points": 1.0, | |
| 196 | + "reason": "five", | |
| 197 | + "moves": 12, | |
| 198 | + "max_reply_ms": 2 | |
| 199 | + }, | |
| 200 | + { | |
| 201 | + "opponent": "random", | |
| 202 | + "opening": 6, | |
| 203 | + "seat": "black", | |
| 204 | + "points": 1.0, | |
| 205 | + "reason": "five", | |
| 206 | + "moves": 11, | |
| 207 | + "max_reply_ms": 3 | |
| 208 | + }, | |
| 209 | + { | |
| 210 | + "opponent": "random", | |
| 211 | + "opening": 6, | |
| 212 | + "seat": "white", | |
| 213 | + "points": 1.0, | |
| 214 | + "reason": "five", | |
| 215 | + "moves": 10, | |
| 216 | + "max_reply_ms": 3 | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "opponent": "threats", | |
| 220 | + "opening": 1, | |
| 221 | + "seat": "black", | |
| 222 | + "points": 0.0, | |
| 223 | + "reason": "five", | |
| 224 | + "moves": 32, | |
| 225 | + "max_reply_ms": 3 | |
| 226 | + }, | |
| 227 | + { | |
| 228 | + "opponent": "threats", | |
| 229 | + "opening": 1, | |
| 230 | + "seat": "white", | |
| 231 | + "points": 0.0, | |
| 232 | + "reason": "five", | |
| 233 | + "moves": 35, | |
| 234 | + "max_reply_ms": 4 | |
| 235 | + }, | |
| 236 | + { | |
| 237 | + "opponent": "threats", | |
| 238 | + "opening": 2, | |
| 239 | + "seat": "black", | |
| 240 | + "points": 1.0, | |
| 241 | + "reason": "five", | |
| 242 | + "moves": 31, | |
| 243 | + "max_reply_ms": 4 | |
| 244 | + }, | |
| 245 | + { | |
| 246 | + "opponent": "threats", | |
| 247 | + "opening": 2, | |
| 248 | + "seat": "white", | |
| 249 | + "points": 1.0, | |
| 250 | + "reason": "five", | |
| 251 | + "moves": 44, | |
| 252 | + "max_reply_ms": 8 | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "opponent": "threats", | |
| 256 | + "opening": 3, | |
| 257 | + "seat": "black", | |
| 258 | + "points": 1.0, | |
| 259 | + "reason": "five", | |
| 260 | + "moves": 47, | |
| 261 | + "max_reply_ms": 4 | |
| 262 | + }, | |
| 263 | + { | |
| 264 | + "opponent": "threats", | |
| 265 | + "opening": 3, | |
| 266 | + "seat": "white", | |
| 267 | + "points": 1.0, | |
| 268 | + "reason": "five", | |
| 269 | + "moves": 16, | |
| 270 | + "max_reply_ms": 3 | |
| 271 | + }, | |
| 272 | + { | |
| 273 | + "opponent": "threats", | |
| 274 | + "opening": 4, | |
| 275 | + "seat": "black", | |
| 276 | + "points": 0.5, | |
| 277 | + "reason": "board full", | |
| 278 | + "moves": 225, | |
| 279 | + "max_reply_ms": 5 | |
| 280 | + }, | |
| 281 | + { | |
| 282 | + "opponent": "threats", | |
| 283 | + "opening": 4, | |
| 284 | + "seat": "white", | |
| 285 | + "points": 1.0, | |
| 286 | + "reason": "five", | |
| 287 | + "moves": 34, | |
| 288 | + "max_reply_ms": 4 | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "opponent": "threats", | |
| 292 | + "opening": 5, | |
| 293 | + "seat": "black", | |
| 294 | + "points": 0.5, | |
| 295 | + "reason": "board full", | |
| 296 | + "moves": 225, | |
| 297 | + "max_reply_ms": 6 | |
| 298 | + }, | |
| 299 | + { | |
| 300 | + "opponent": "threats", | |
| 301 | + "opening": 5, | |
| 302 | + "seat": "white", | |
| 303 | + "points": 0.0, | |
| 304 | + "reason": "five", | |
| 305 | + "moves": 23, | |
| 306 | + "max_reply_ms": 3 | |
| 307 | + }, | |
| 308 | + { | |
| 309 | + "opponent": "threats", | |
| 310 | + "opening": 6, | |
| 311 | + "seat": "black", | |
| 312 | + "points": 1.0, | |
| 313 | + "reason": "five", | |
| 314 | + "moves": 119, | |
| 315 | + "max_reply_ms": 5 | |
| 316 | + }, | |
| 317 | + { | |
| 318 | + "opponent": "threats", | |
| 319 | + "opening": 6, | |
| 320 | + "seat": "white", | |
| 321 | + "points": 0.0, | |
| 322 | + "reason": "five", | |
| 323 | + "moves": 21, | |
| 324 | + "max_reply_ms": 3 | |
| 325 | + } | |
| 326 | +] | |
+17 −0 routes/codex-line-threats/out/season.md added View file
| @@ -0,0 +1,17 @@ | ||
| 1 | +# Season check evidence | |
| 2 | + | |
| 3 | +Generated by `python3 routes/codex-line-threats/audit.py`. | |
| 4 | +Uses the unchanged project referee, all six openings, and both colours. | |
| 5 | + | |
| 6 | +| Opponent | Wins | Draws | Losses | Points share | | |
| 7 | +|---|---:|---:|---:|---:| | |
| 8 | +| neighbour | 12 | 0 | 0 | 1.000000 | | |
| 9 | +| random | 12 | 0 | 0 | 1.000000 | | |
| 10 | +| threats | 6 | 2 | 4 | 0.583333 | | |
| 11 | + | |
| 12 | +Overall points share: 0.861111. | |
| 13 | +Maximum measured reply latency: 8 ms. | |
| 14 | +Reply latency is measured by the project referee on this local machine; it is not a cross-machine runtime guarantee. | |
| 15 | +End reasons: {"board full": 2, "five": 34}. | |
| 16 | + | |
| 17 | +Per-game evidence, including losses, is in `games.json`. | |
+97 −0 routes/codex-line-threats/test_bot.py added View file
| @@ -0,0 +1,97 @@ | ||
| 1 | +"""Focused tactical and protocol regressions; run directly with Python 3.""" | |
| 2 | +import contextlib | |
| 3 | +import io | |
| 4 | +from pathlib import Path | |
| 5 | +import sys | |
| 6 | +import unittest | |
| 7 | + | |
| 8 | +sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| 9 | +import bot | |
| 10 | + | |
| 11 | + | |
| 12 | +class BrainTests(unittest.TestCase): | |
| 13 | + def setUp(self): | |
| 14 | + bot.N = 15 | |
| 15 | + bot.board.clear() | |
| 16 | + | |
| 17 | + def stones(self, points, colour=1): | |
| 18 | + bot.board.update((p, colour) for p in points) | |
| 19 | + | |
| 20 | + def test_first_move(self): | |
| 21 | + self.assertEqual(bot.move(), (7, 7)) | |
| 22 | + | |
| 23 | + def test_exact_five_in_every_direction(self): | |
| 24 | + for dx, dy in bot.DIRECTIONS: | |
| 25 | + bot.board.clear() | |
| 26 | + self.stones([(7 + i * dx, 7 + i * dy) for i in (-2, -1, 1, 2)]) | |
| 27 | + self.assertTrue(bot.evaluate((7, 7), 1)[0]) | |
| 28 | + self.assertEqual(bot.move(), (7, 7)) | |
| 29 | + | |
| 30 | + def test_overline_is_not_win(self): | |
| 31 | + self.stones([(x, 7) for x in (3, 4, 5, 7, 8)]) | |
| 32 | + self.assertFalse(bot.evaluate((6, 7), 1)[0]) | |
| 33 | + | |
| 34 | + def test_win_before_block(self): | |
| 35 | + self.stones([(x, 4) for x in range(4)], 1) | |
| 36 | + self.stones([(x, 10) for x in range(4)], 2) | |
| 37 | + self.assertEqual(bot.move(), (4, 4)) | |
| 38 | + | |
| 39 | + def test_block_opponent_broken_five(self): | |
| 40 | + self.stones([(x, 7) for x in (3, 4, 6, 7)], 2) | |
| 41 | + self.assertEqual(bot.move(), (5, 7)) | |
| 42 | + | |
| 43 | + def test_open_line_beats_closed(self): | |
| 44 | + self.stones([(6, 7), (8, 7)]) | |
| 45 | + open_score = bot.evaluate((7, 7), 1)[1] | |
| 46 | + self.stones([(5, 7)], 2) | |
| 47 | + self.assertGreater(open_score, bot.evaluate((7, 7), 1)[1]) | |
| 48 | + | |
| 49 | + def test_gap_four_has_winning_completion(self): | |
| 50 | + self.stones([(5, 7), (8, 7), (9, 7)]) | |
| 51 | + _, offsets, _ = bot.line_value(bot.line_at(6, 7, 1, 1, 0)) | |
| 52 | + self.assertIn(1, offsets) | |
| 53 | + | |
| 54 | + def test_open_four_has_two_winning_points(self): | |
| 55 | + self.stones([(5, 7), (6, 7), (8, 7)]) | |
| 56 | + _, offsets, _ = bot.line_value(bot.line_at(7, 7, 1, 1, 0)) | |
| 57 | + self.assertEqual(offsets, {-3, 2}) | |
| 58 | + | |
| 59 | + def test_edge_is_closed(self): | |
| 60 | + self.stones([(0, 7), (1, 7)]) | |
| 61 | + _, offsets, _ = bot.line_value(bot.line_at(2, 7, 1, 1, 0)) | |
| 62 | + self.assertFalse(offsets) | |
| 63 | + self.assertNotEqual(bot.line_at(2, 7, 1, 1, 0)[0], '.') | |
| 64 | + | |
| 65 | + def test_snapshot_restart_and_blocked_cells(self): | |
| 66 | + commands = 'START 15\nBEGIN\nBOARD\n0,0,3\n1,0,2\nDONE\nRESTART\nBEGIN\nEND\n' | |
| 67 | + out = io.StringIO() | |
| 68 | + old_stdin = sys.stdin | |
| 69 | + try: | |
| 70 | + sys.stdin = io.StringIO(commands) | |
| 71 | + with contextlib.redirect_stdout(out): | |
| 72 | + bot.main() | |
| 73 | + finally: | |
| 74 | + sys.stdin = old_stdin | |
| 75 | + lines = out.getvalue().splitlines() | |
| 76 | + self.assertEqual(lines[0:2], ['OK', '7,7']) | |
| 77 | + self.assertNotIn(lines[2], ['0,0', '1,0']) | |
| 78 | + self.assertEqual(lines[3:], ['OK', '7,7']) | |
| 79 | + self.assertEqual(bot.board, {(7, 7): 1}) | |
| 80 | + | |
| 81 | + def test_board_replaces_previous_position(self): | |
| 82 | + self.stones([(14, 14)], 2) | |
| 83 | + old_stdin = sys.stdin | |
| 84 | + try: | |
| 85 | + sys.stdin = io.StringIO('BOARD\n0,0,3\n1,0,2\nDONE\nEND\n') | |
| 86 | + with contextlib.redirect_stdout(io.StringIO()): | |
| 87 | + bot.main() | |
| 88 | + finally: | |
| 89 | + sys.stdin = old_stdin | |
| 90 | + self.assertNotIn((14, 14), bot.board) | |
| 91 | + self.assertEqual(bot.board[(0, 0)], 3) | |
| 92 | + self.assertEqual(bot.board[(1, 0)], 2) | |
| 93 | + self.assertEqual(len(bot.board), 3) | |
| 94 | + | |
| 95 | + | |
| 96 | +if __name__ == '__main__': | |
| 97 | + unittest.main() | |
