From 52c3c3b38907f414dd1a016eb1cca1dde6d5b8e0 Mon Sep 17 00:00:00 2001 From: John Yang Date: Mon, 6 Jul 2026 15:43:27 -0400 Subject: [PATCH] Add Gomoku CC:Ladder (21 imported human bots + build tooling) - 21 open-source Gomoku bots imported to CodeClash-ai/Gomoku human/gomoku/* branches (source of truth, not committed here): pure-Python minimax/MCTS/threat-search bots + JS/Java/C++ engines (lihongxun945, blackstone, blupig, ...) reimplemented in stdlib Python, each wrapped into the arena's single-file get_move(board, color) contract. Each validated 2-stage (local get_move legality + real engine.py games vs a reference). AlphaZero/CNN bots skipped (weights); zhoukangyang deferred (NN-substitute, not faithful). - configs/ablations/ladder/make_gomoku.yaml: round-robin (30 sims) for Elo ranking; README note. - scripts/ladder/: build tooling adapted to Gomoku (porting guide, validators, smoke/push scripts, RUN.md, reference bot); ports/ regenerated, not committed. Arena was already wired. --- configs/ablations/ladder/README.md | 11 +++ configs/ablations/ladder/make_gomoku.yaml | 64 +++++++++++++++ scripts/ladder/.gitignore | 4 + scripts/ladder/PORTING_GUIDE.md | 62 +++++++++++++++ scripts/ladder/README.md | 32 ++++++++ scripts/ladder/RUN.md | 36 +++++++++ scripts/ladder/examples/gomoku_smoke.yaml | 17 ++++ scripts/ladder/examples/main.py | 97 +++++++++++++++++++++++ scripts/ladder/push_branches.sh | 41 ++++++++++ scripts/ladder/run_smoke_all.sh | 61 ++++++++++++++ scripts/ladder/smoke_gomoku.sh | 23 ++++++ scripts/ladder/validate_ports.py | 85 ++++++++++++++++++++ 12 files changed, 533 insertions(+) create mode 100644 configs/ablations/ladder/make_gomoku.yaml create mode 100644 scripts/ladder/.gitignore create mode 100644 scripts/ladder/PORTING_GUIDE.md create mode 100644 scripts/ladder/README.md create mode 100644 scripts/ladder/RUN.md create mode 100644 scripts/ladder/examples/gomoku_smoke.yaml create mode 100644 scripts/ladder/examples/main.py create mode 100644 scripts/ladder/push_branches.sh create mode 100644 scripts/ladder/run_smoke_all.sh create mode 100644 scripts/ladder/smoke_gomoku.sh create mode 100644 scripts/ladder/validate_ports.py diff --git a/configs/ablations/ladder/README.md b/configs/ablations/ladder/README.md index 99d5e6c4..8de52d6f 100644 --- a/configs/ablations/ladder/README.md +++ b/configs/ablations/ladder/README.md @@ -12,6 +12,17 @@ You can follow these steps to create your own "CC:" ladder. The tricky part is typically finding a large collection of human solutions for a particular arena. We've typically found that googling for online leaderboards or awesome- repositories (e.g. [BattleSnake](https://github.com/BattlesnakeOfficial/awesome-battlesnake)) is a good strategy. +### Gomoku (newly added) + +The [CC:Gomoku](https://github.com/CodeClash-ai/Gomoku) repo hosts the human bots on `human/*` +branches (like the other arenas, bot code lives only on the branches, not in this repo): 21 +open-source Gomoku/Gobang AIs ported into the arena's single-file `get_move(board, color)` contract +— pure-Python minimax/MCTS/threat-search bots imported directly, plus JS/Java/C++ engines (e.g. +lihongxun945, blackstone, blupig) reimplemented in stdlib Python — alongside a strategic starter. +AlphaZero/CNN bots were skipped (need trained weights). The build tooling (porting guide, +validators, smoke/push scripts, run instructions in `RUN.md`) lives in `scripts/ladder/`; it's +operational one-off tooling for constructing porting-based ladders, reusable across arenas. + ## Config layout Each arena has a few kinds of config in this folder: diff --git a/configs/ablations/ladder/make_gomoku.yaml b/configs/ablations/ladder/make_gomoku.yaml new file mode 100644 index 00000000..76224d37 --- /dev/null +++ b/configs/ablations/ladder/make_gomoku.yaml @@ -0,0 +1,64 @@ +# Round-robin over the human Gomoku bots (ported from open-source repos + a strategic starter), +# used to RANK them via Elo/Bradley-Terry -> see gomoku.yaml for the ladder. +# rounds:0 = dummy players just play the baseline Gomoku game (no code edits). Colors are randomized +# per game, so sims_per_round averages over first-move advantage. N bots -> N*(N-1)/2 pairs. +# Bots live on CodeClash-ai/Gomoku human/* branches; Docker required. +# +# COST (measured ~8-9s/game -> ~4-4.5 min/pair at sims_per_round=30; 22 bots -> 231 pairs +# ~= 16.5 core-hours; pairs are single-threaded so wall = core-hours / workers): +# * Mac 10-core (--workers 8): ~2 h * 32-core box (-w 30): ~33 min +# Matchups are decisive (frequent 30-0 sweeps), so 30 sims ranks cleanly; bump to 60 only if +# the Elo confidence intervals overlap. Keep --workers <= cores-2. Resumable (skips logged pairs). +# Run: uv run codeclash ladder make configs/ablations/ladder/make_gomoku.yaml --workers 8 +tournament: + rounds: 0 +game: + name: Gomoku + sims_per_round: 30 +players: +- agent: dummy + branch_init: human/gomoku/blupig +- agent: dummy + branch_init: human/gomoku/canberkakcali +- agent: dummy + branch_init: human/gomoku/colingogogo +- agent: dummy + branch_init: human/gomoku/gonghailong +- agent: dummy + branch_init: human/gomoku/haslam22 +- agent: dummy + branch_init: human/gomoku/hovanhoa +- agent: dummy + branch_init: human/gomoku/husus +- agent: dummy + branch_init: human/gomoku/junxiaosong_mcts +- agent: dummy + branch_init: human/gomoku/kali_hac +- agent: dummy + branch_init: human/gomoku/kevin2014123 +- agent: dummy + branch_init: human/gomoku/lihongxun945 +- agent: dummy + branch_init: human/gomoku/linhusp +- agent: dummy + branch_init: human/gomoku/marblexu +- agent: dummy + branch_init: human/gomoku/qwertyforce +- agent: dummy + branch_init: human/gomoku/rayfc_mcts +- agent: dummy + branch_init: human/gomoku/russell_a +- agent: dummy + branch_init: human/gomoku/smahot +- agent: dummy + branch_init: human/gomoku/tsrmkumoko +- agent: dummy + branch_init: human/gomoku/xkien2k4 +- agent: dummy + branch_init: human/gomoku/zhangshun97 +- agent: dummy + branch_init: human/gomoku/zitongmao +- agent: dummy + branch_init: human/muhtasham/strategic-starter +prompts: + game_description: Gomoku ladder diff --git a/scripts/ladder/.gitignore b/scripts/ladder/.gitignore new file mode 100644 index 00000000..b715ef09 --- /dev/null +++ b/scripts/ladder/.gitignore @@ -0,0 +1,4 @@ +# Regenerated build artifacts — bot ports live on the CodeClash-ai/ branches +# (source of truth), not in this repo. Populate ports/ locally when (re)building a ladder. +ports/ +out/ diff --git a/scripts/ladder/PORTING_GUIDE.md b/scripts/ladder/PORTING_GUIDE.md new file mode 100644 index 00000000..b1bed105 --- /dev/null +++ b/scripts/ladder/PORTING_GUIDE.md @@ -0,0 +1,62 @@ +# Porting a Gomoku bot to the CodeClash `get_move` contract + +You are porting ONE open-source Gomoku / Gobang / Renju bot into a single self-contained +`main.py` that defines **one function**: + +```python +def get_move(board, color) -> tuple: # returns (row, col) +``` + +- `board`: a 15x15 list-of-lists of ints — `0`=empty, `1`=black, `2`=white. +- `color`: `"black"` or `"white"`. Black moves first. Win = 5 in a row (H/V/either diagonal). +- Return `(row, col)` of an **empty** cell (0-indexed). Returning an occupied/off-board cell or + raising an exception forfeits the game, so be defensive. + +A clean, dependency-free reference implementation lives at +`scripts/ladder/examples/main.py` — read it first; mirror its structure and defensive style. + +## Hard rules +- **Stdlib only.** No numpy/pygame/tkinter/torch/etc. The arena image is plain Python 3.10 with + the standard library only. Re-express any array math in pure Python lists. +- **No trained weights / no NN.** If the source bot is AlphaZero/CNN/RL and has no rule-based + fallback, DO NOT port it — say so in your report (it should have been filtered out already). +- **One file, one function.** Everything the bot needs (search, evaluation, tables) goes inside + `main.py`. Helper functions/classes are fine; `get_move` is the entry point. +- **Never raise.** Wrap the body so any internal error falls back to a safe legal move (e.g. the + first empty cell, or center). A crash = a forfeit. +- **Be reasonably fast.** There is no hard per-move timeout in the engine, but a game plays many + moves and the round-robin plays many games — keep search depth/rollouts modest (a move should + return in well under a second on a 15x15 board). Cap minimax depth / MCTS iterations sensibly. + +## What to extract from the source +Most source repos wrap their AI in a GUI (pygame/tkinter), a Gomocup/piskvork stdin-stdout +protocol loop, or a class with its own board representation. Ignore all of that. Find the core +**"given a board, choose a move"** logic — usually a `minimax`/`negamax`/`alphabeta`, an MCTS +tree search, or a threat/shape evaluation over candidate cells — and re-express it as: + +1. Convert the arena `board` (list-of-lists, 0/1/2) to whatever the algorithm wants (often the + same, sometimes it uses -1/1 or 'X'/'O' — remap). Determine `me`/`opp` from `color`. +2. Run the source's move-selection over the current board. +3. Map the chosen move back to a `(row, col)` int tuple and return it. + +Keep behavior faithful: same evaluation weights, same search depth, same tie-breaks where you can. +If the source relies on state across turns (opening books, transposition tables, incremental +board), either recompute from `board` each call or hold it in module-level globals keyed sensibly +— but the engine calls `get_move` fresh each turn with the full board, so stateless recomputation +is usually simplest and correct. + +## For non-Python sources (JS / Java / C++ — the "PROTOCOL-PORT" bucket) +Reimplement the evaluation + search in Python. Prioritize faithfulness of the **evaluation +function** (shape/threat scoring tables) and the **search** (depth, pruning); these determine +strength. Drop language-specific perf tricks (bitboards, Zobrist/TT) unless easy — correctness and +the eval shape matter more than raw speed for the ladder. + +## Deliverable +Write your port to `scripts/ladder/ports/.py` (see the batch prompt for the exact filename, +e.g. `colingogogo.py`). It must: +- `python3 -c "import ast; ast.parse(open(FILE).read())"` cleanly, +- define a top-level `get_move(board, color)`, +- return a legal `(row, col)` on an empty starter board and mid-game, never raising. + +Keep a short module docstring naming the source (repo + author + algorithm) and noting any +simplifications/dropped features (e.g. "dropped Zobrist TT", "MCTS iters capped at 2000"). diff --git a/scripts/ladder/README.md b/scripts/ladder/README.md new file mode 100644 index 00000000..a6f208f8 --- /dev/null +++ b/scripts/ladder/README.md @@ -0,0 +1,32 @@ +# Ladder build tooling (Gomoku) + +Operational one-off scripts for constructing a **porting-based** CC:Ladder — one where the +human bots are open-source agents that must be ported into the arena's single-file submission +contract before they can be ranked. This branch targets **Gomoku** (`main.py` defining +`get_move(board, color) -> (row, col)`); the same workflow was used for SCML OneShot. + +This is scaffolding, not product code — nothing under `codeclash/` imports it. The durable +outputs of a build are: the `human/*` branches on `CodeClash-ai/Gomoku` (the bots) and the +`configs/ablations/ladder/*.yaml` configs. Ports themselves are **not** committed here (see +`.gitignore`); they live on the branches. + +## Files +- `PORTING_GUIDE.md` — the `get_move` contract + how to port a source bot. Hand this to a porting + agent (with `examples/main.py` as the worked reference). +- `examples/main.py` — a clean, dependency-free reference bot (win/block/heuristic). Used as the + worked porting example and the stage-2 smoke opponent. +- `examples/gomoku_smoke.yaml` — a 2-player arena smoke config (branch bot vs default). +- `validate_ports.py` — stage 1: local syntax/import/`get_move` legality check over `ports/*.py`. +- `run_smoke_all.sh` — stage 2: play every stage-1 pass vs the reference bot through the real + engine in Docker; confirm each plays full games without erroring/hanging. Writes `ports/_stage2.json`. +- `smoke_gomoku.sh` — quick single-pair smoke (reference vs the arena default) through the image. +- `push_branches.sh` — push each stage-2-healthy port to `CodeClash-ai/Gomoku` as a + `human/gomoku/` branch (submission `main.py`; dedupes identical content). +- `RUN.md` — how to run the round-robin (`ladder make`) + Elo ranking (cheap — runs locally). + +## Typical flow +1. Populate `ports/.py` (fan out porting agents with `PORTING_GUIDE.md`). +2. `python3 scripts/ladder/validate_ports.py` → stage 1 +3. `bash scripts/ladder/run_smoke_all.sh` → stage 2 (needs Docker) +4. `bash scripts/ladder/push_branches.sh` → push healthy ports to branches +5. Rank + assemble configs — see `RUN.md`. diff --git a/scripts/ladder/RUN.md b/scripts/ladder/RUN.md new file mode 100644 index 00000000..9776f231 --- /dev/null +++ b/scripts/ladder/RUN.md @@ -0,0 +1,36 @@ +# Running the Gomoku round-robin (Elo ranking) + +Rank the human bots in `configs/ablations/ladder/make_gomoku.yaml` via all-pairs PvP, then fit +Elo. Gomoku games are fast and stdlib-only, so — unlike SCML — this runs comfortably **on a +laptop** (no AWS needed). The bots already live on `CodeClash-ai/Gomoku` `human/*` branches; +`branch_init` fetches them at runtime, so you only need this repo branch + Docker + a GitHub token. + +## Prerequisites +- Docker running, git, `uv`, and a token for `CodeClash-ai/Gomoku` (public → `gh auth token` works). + +## Step 0 — pre-build the arena image once (avoids a build stampede under -w N) +```bash +docker build -t codeclash/gomoku -f codeclash/arenas/gomoku/Gomoku.Dockerfile . +bash scripts/ladder/smoke_gomoku.sh # sanity: reference vs default, prints PASS +``` + +## Step 1 — round-robin +`N` bots → `N*(N-1)/2` pairs; each pair is a short match, so this is quick. Keep +`--workers ≈ cores-2`. +```bash +GITHUB_TOKEN=$(gh auth token) \ + uv run codeclash ladder make configs/ablations/ladder/make_gomoku.yaml --workers 6 +``` +Resumable — reruns skip pairs already logged under `logs/ladder/Gomoku/`. + +## Step 2 — rank +```bash +python -m codeclash.analysis.metrics.elo -d logs/ladder/Gomoku --output-dir assets/gomoku_elo +``` +Prints the Bradley-Terry/Elo ordering and writes `assets/gomoku_elo/elo_results.log`. + +## Step 3 — assemble the ladder configs (once you have the ranking) +1. `configs/ablations/ladder/rungs/gomoku.yaml` — ranked opponents, weakest first, strongest last. +2. `configs/ablations/ladder/gomoku.yaml` — climber `player` + `ladder: !include ablations/ladder/rungs/gomoku.yaml` + + a `ladder_rules` block (model on `battlesnake.yaml`). +3. Optional `gomoku__.yaml` per-model variants. diff --git a/scripts/ladder/examples/gomoku_smoke.yaml b/scripts/ladder/examples/gomoku_smoke.yaml new file mode 100644 index 00000000..2f738ba1 --- /dev/null +++ b/scripts/ladder/examples/gomoku_smoke.yaml @@ -0,0 +1,17 @@ +# Arena smoke test for the Gomoku ladder: run a human-branch bot through the REAL arena +# (Docker build -> clone CodeClash-ai/Gomoku -> branch_init checkout -> validate_code -> engine.py) +# against the default bot. +# GITHUB_TOKEN=$(gh auth token) uv run codeclash run scripts/ladder/examples/gomoku_smoke.yaml +tournament: + rounds: 1 +game: + name: Gomoku + sims_per_round: 6 +players: +- agent: dummy + name: starter + branch_init: human/muhtasham/strategic-starter +- agent: dummy + name: default +prompts: + game_description: Gomoku ladder smoke test diff --git a/scripts/ladder/examples/main.py b/scripts/ladder/examples/main.py new file mode 100644 index 00000000..23b6e21b --- /dev/null +++ b/scripts/ladder/examples/main.py @@ -0,0 +1,97 @@ +"""Reference Gomoku bot for the CodeClash arena contract. + +The arena calls ONE function per turn: + + get_move(board, color) -> (row, col) + + * board: 15x15 list-of-lists of ints, 0=empty, 1=black, 2=white. + * color: "black" or "white" (black moves first). + * return: (row, col) of an EMPTY cell. Win = 5 in a row (H/V/diagonal). + +This reference is a compact, dependency-free heuristic used as the porting example +and the smoke-test opponent: (1) win now if possible, (2) block the opponent's +immediate win, (3) otherwise score empty cells near existing stones by the line +potential they create for me and deny to the opponent, and play the best. +""" + +SIZE = 15 +_DIRS = [(1, 0), (0, 1), (1, 1), (1, -1)] + + +def _stones(color): + me = 1 if color == "black" else 2 + return me, (2 if me == 1 else 1) + + +def _on(r, c): + return 0 <= r < SIZE and 0 <= c < SIZE + + +def _makes_five(board, r, c, stone): + """Would playing `stone` at (r,c) complete 5+ in a row?""" + for dr, dc in _DIRS: + n = 1 + for sgn in (1, -1): + rr, cc = r + dr * sgn, c + dc * sgn + while _on(rr, cc) and board[rr][cc] == stone: + n += 1 + rr += dr * sgn + cc += dc * sgn + if n >= 5: + return True + return False + + +def _line_score(board, r, c, stone): + """Rough potential of playing `stone` at (r,c): sum over directions of the + contiguous run length it extends, weighted super-linearly.""" + total = 0 + for dr, dc in _DIRS: + run = 0 + for sgn in (1, -1): + rr, cc = r + dr * sgn, c + dc * sgn + while _on(rr, cc) and board[rr][cc] == stone: + run += 1 + rr += dr * sgn + cc += dc * sgn + total += (run + 1) ** 2 + return total + + +def _candidates(board): + """Empty cells adjacent (incl. diagonally) to any stone; center if board empty.""" + cells = set() + any_stone = False + for r in range(SIZE): + for c in range(SIZE): + if board[r][c] != 0: + any_stone = True + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + rr, cc = r + dr, c + dc + if _on(rr, cc) and board[rr][cc] == 0: + cells.add((rr, cc)) + if not any_stone: + return [(SIZE // 2, SIZE // 2)] + return list(cells) + + +def get_move(board, color): + me, opp = _stones(color) + cands = _candidates(board) + + # 1. win now + for r, c in cands: + if _makes_five(board, r, c, me): + return (r, c) + # 2. block opponent's win + for r, c in cands: + if _makes_five(board, r, c, opp): + return (r, c) + # 3. best heuristic cell (my potential + defensive value) + best, best_rc = -1, cands[0] + for r, c in cands: + s = _line_score(board, r, c, me) + 0.9 * _line_score(board, r, c, opp) + if s > best: + best, best_rc = s, (r, c) + return best_rc diff --git a/scripts/ladder/push_branches.sh b/scripts/ladder/push_branches.sh new file mode 100644 index 00000000..d4f3a81d --- /dev/null +++ b/scripts/ladder/push_branches.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Push every stage-2-healthy port to CodeClash-ai/Gomoku as a human/gomoku/ branch, +# with the port placed as the arena submission file main.py. +# Port "colingogogo.py" -> branch "human/gomoku/colingogogo". Dedupes identical content. +# bash scripts/ladder/push_branches.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PORTS="$REPO_ROOT/scripts/ladder/ports" +ARENA_REPO="CodeClash-ai/Gomoku" +SUBMISSION="main.py" +SKIP="zhoukangyang" # NN/AlphaZero source with no rule-based path; the port is a uniform-prior + # UCT substitute (not faithful; duplicates tsrmkumoko) -> defer + +TMP=$(mktemp -d) +git clone -q "https://x-access-token:$(gh auth token)@github.com/${ARENA_REPO}.git" "$TMP" +cd "$TMP" +git checkout -q main + +SEEN=$(mktemp) +pushed=0; skipped=0 +PY="$(command -v python3 || command -v python)" +OKS=$("$PY" -c "import json;d=json.load(open('$PORTS/_stage2.json'));print('\n'.join(k for k,v in sorted(d.items()) if v.get('ran') and v.get('errors',1)==0))") + +for stem in $OKS; do + stem="${stem%.py}" + case " $SKIP " in *" $stem "*) echo "SKIP $stem (deferred)"; skipped=$((skipped+1)); continue;; esac + h=$(shasum "$PORTS/$stem.py" | awk '{print $1}') + if grep -q "^$h " "$SEEN"; then echo "SKIP $stem (dup of $(grep "^$h " "$SEEN" | awk '{print $2}'))"; skipped=$((skipped+1)); continue; fi + echo "$h $stem" >> "$SEEN" + branch="human/gomoku/$stem" + git checkout -q main + git checkout -q -B "$branch" + cp "$PORTS/$stem.py" "$SUBMISSION" + git add "$SUBMISSION" + git -c user.email=player@codeclash.com -c user.name="CodeClash" commit -qm "Import $stem (Gomoku ladder)" + git push -q -f -u origin "$branch" 2>/dev/null + echo "PUSH $branch" + pushed=$((pushed+1)) +done +cd "$REPO_ROOT"; rm -rf "$TMP" +echo "=== pushed $pushed, skipped $skipped ===" diff --git a/scripts/ladder/run_smoke_all.sh b/scripts/ladder/run_smoke_all.sh new file mode 100644 index 00000000..cc01cecf --- /dev/null +++ b/scripts/ladder/run_smoke_all.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Stage 2: play every stage-1-passing port against the reference bot through the REAL Gomoku +# engine (engine.py) inside the arena Docker image, to confirm each loads and plays full games +# without erroring/hanging. Gomoku is 2-player, so each candidate plays the reference bot. +# Writes a per-port verdict table (ports/_stage2.json). Run after validate_ports.py. +# bash scripts/ladder/run_smoke_all.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PORTS="$REPO_ROOT/scripts/ladder/ports" +EX="$REPO_ROOT/scripts/ladder/examples" +OUT="$REPO_ROOT/scripts/ladder/out" +IMAGE="gomoku-smoke:latest" +PY="$(command -v python3 || command -v python)" + +cd "$REPO_ROOT" +docker build -q -f codeclash/arenas/gomoku/Gomoku.Dockerfile -t "$IMAGE" . >/dev/null +mkdir -p "$OUT" + +"$PY" - "$PORTS" "$EX" "$OUT" "$IMAGE" <<'PY' +import json, re, subprocess, sys +from pathlib import Path + +ports, ex, outdir, image = Path(sys.argv[1]), Path(sys.argv[2]), Path(sys.argv[3]), sys.argv[4] +stage1 = json.loads((ports / "_stage1.json").read_text()) +files = [n for n, r in sorted(stage1.items()) if r["pass"]] +ROUNDS = 6 +verdict = {} +for i, name in enumerate(files): + stem = name[:-3] + # candidate is player1, reference bot is player2 + cmd = ["docker", "run", "--rm", + "-v", f"{ports}:/ports:ro", "-v", f"{ex}:/ex:ro", + image, "python", "engine.py", f"/ports/{name}", "/ex/main.py", "-r", str(ROUNDS)] + print(f" [{i+1}/{len(files)}] {stem} vs reference ...", flush=True) + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + out = p.stdout + except subprocess.TimeoutExpired: + verdict[stem] = {"ran": False, "reason": "timeout (>180s) — too slow"} + continue + (outdir / f"{stem}.log").write_text(out) + if "FINAL_RESULTS" not in out: + verdict[stem] = {"ran": False, "reason": (p.stderr or out)[-200:]} + continue + errors = out.count("(error:") + m1 = re.search(r"Bot_1_main:\s(\d+)\srounds\swon", out) # candidate = player1 + won = int(m1.group(1)) if m1 else 0 + verdict[stem] = {"ran": True, "errors": errors, "rounds_won_vs_ref": won, "of": ROUNDS} + +(ports / "_stage2.json").write_text(json.dumps(verdict, indent=2, sort_keys=True)) +print("\n=== STAGE 2 VERDICTS (candidate vs reference bot) ===") +ok = bad = 0 +for name in sorted(verdict): + v = verdict[name] + good = v.get("ran") and v.get("errors", 1) == 0 + ok += good; bad += not good + tag = "OK " if good else "BAD " + extra = f"won {v.get('rounds_won_vs_ref')}/{v.get('of')} vs ref" if v.get("ran") else v.get("reason","") + print(f" {tag} {name}: {extra}" + (f" errors={v['errors']}" if v.get('errors') else "")) +print(f"\nStage 2: {ok} healthy / {bad} problematic of {len(verdict)}") +PY diff --git a/scripts/ladder/smoke_gomoku.sh b/scripts/ladder/smoke_gomoku.sh new file mode 100644 index 00000000..d3b7bad7 --- /dev/null +++ b/scripts/ladder/smoke_gomoku.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Quick smoke: play the reference bot vs the arena's default main.py through the REAL Gomoku +# engine inside the arena Docker image. No GitHub token needed — confirms the image builds and +# the engine + get_move contract run end-to-end. +# bash scripts/ladder/smoke_gomoku.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EX="$REPO_ROOT/scripts/ladder/examples" +IMAGE="gomoku-smoke:latest" + +cd "$REPO_ROOT" +echo "==> Building Gomoku arena image (clones CodeClash-ai/Gomoku engine)" +docker build -q -f codeclash/arenas/gomoku/Gomoku.Dockerfile -t "$IMAGE" . >/dev/null + +echo "==> Running: reference bot vs the repo's default main.py — 6 games" +# reference is player1; the image ships the arena's default bot at /workspace/main.py (player2) +out="$(docker run --rm -v "$EX:/ex:ro" "$IMAGE" python engine.py /ex/main.py main.py -r 6)" +echo "$out" | sed -n '/FINAL_RESULTS/,$p' +if echo "$out" | grep -q "FINAL_RESULTS" && ! echo "$out" | grep -q "(error:"; then + echo "SMOKE: PASS ✅" +else + echo "SMOKE: FAIL ❌"; exit 1 +fi diff --git a/scripts/ladder/validate_ports.py b/scripts/ladder/validate_ports.py new file mode 100644 index 00000000..386deb7c --- /dev/null +++ b/scripts/ladder/validate_ports.py @@ -0,0 +1,85 @@ +"""Stage 1 validator: mirrors the Gomoku arena's validate_code locally (no Docker needed, +since ports are stdlib-only). For each ports/*.py: syntax-compile, import, assert a top-level +callable get_move, and call it on an empty 15x15 board and a mid-game board — the return must be +a legal (row, col) tuple pointing at an EMPTY cell. Prints PASS/FAIL per file, writes ports/_stage1.json. +""" + +import ast +import importlib.util +import json +import sys +from pathlib import Path + +PORTS = Path(__file__).parent / "ports" +SIZE = 15 +BANNED = ("numpy", "torch", "tensorflow", "pygame", "tkinter", "sklearn", "pandas") + + +def _empty(): + return [[0] * SIZE for _ in range(SIZE)] + + +def _midgame(): + b = _empty() + b[7][7] = 1 + b[7][8] = 2 + b[8][7] = 1 + b[6][6] = 2 + return b + + +def _legal(mv, board): + if not (isinstance(mv, (tuple, list)) and len(mv) == 2): + return False, f"return must be (row, col), got {mv!r}" + r, c = mv + if not (isinstance(r, int) and isinstance(c, int)): + return False, f"row/col must be ints, got {mv!r}" + if not (0 <= r < SIZE and 0 <= c < SIZE): + return False, f"move {mv} off 15x15 board" + if board[r][c] != 0: + return False, f"move {mv} lands on an occupied cell" + return True, "" + + +def check(path: Path): + src = path.read_text() + try: + ast.parse(src) + except SyntaxError as e: + return False, f"syntax: {e}" + for b in BANNED: + if f"import {b}" in src or f"from {b}" in src: + return False, f"imports banned (non-stdlib) module: {b}" + try: + spec = importlib.util.spec_from_file_location(f"port_{path.stem}", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + except Exception as e: + return False, f"import: {type(e).__name__}: {e}" + if not hasattr(mod, "get_move") or not callable(mod.get_move): + return False, "no callable get_move" + for label, board, color in [("empty", _empty(), "black"), ("midgame", _midgame(), "white")]: + try: + mv = mod.get_move([row[:] for row in board], color) + except Exception as e: + return False, f"get_move raised on {label}: {type(e).__name__}: {e}" + ok, why = _legal(mv, board) + if not ok: + return False, f"{label}: {why}" + return True, "ok" + + +def main(): + files = sorted(f for f in PORTS.glob("*.py") if not f.name.startswith("_")) + results, npass = {}, 0 + for f in files: + ok, msg = check(f) + results[f.name] = {"pass": ok, "msg": msg} + print(f" {'PASS' if ok else 'FAIL'} {f.name}" + ("" if ok else f" <- {msg}")) + npass += ok + (PORTS / "_stage1.json").write_text(json.dumps(results, indent=2, sort_keys=True)) + print(f"\nStage 1: {npass} pass / {len(files) - npass} fail of {len(files)} ports") + + +if __name__ == "__main__": + main()