From 99f044db40d6e55a5ad7ad4dd782f7d0eac8c973 Mon Sep 17 00:00:00 2001 From: Malcolm Ocean Date: Fri, 15 May 2026 17:33:55 -0700 Subject: [PATCH 1/5] stag_hunt: add --token and --slot args for tournament support All 5 stag_hunt bots crashed with "Unknown option: --slot" when the tournament runner passed the standard tournament arguments. Thread token and slot through connectUrl so they're sent as query params. Also add a pre-build check to docker_build.nim that verifies bot source files handle --name, --token, and --slot before building images. Co-Authored-By: Claude Opus 4.6 --- stag_hunt/players/coordinator/coordinator.nim | 18 +++++++--- .../players/nearest_hunter/nearest_hunter.nim | 30 ++++++++++------- stag_hunt/players/rabbiteer/rabbiteer.nim | 33 ++++++++++--------- stag_hunt/players/sidekick/sidekick.nim | 18 +++++++--- stag_hunt/players/stag_hunter/stag_hunter.nim | 18 +++++++--- tools/docker_build.nim | 28 ++++++++++++++++ 6 files changed, 106 insertions(+), 39 deletions(-) diff --git a/stag_hunt/players/coordinator/coordinator.nim b/stag_hunt/players/coordinator/coordinator.nim index e703fee3..bef3b386 100644 --- a/stag_hunt/players/coordinator/coordinator.nim +++ b/stag_hunt/players/coordinator/coordinator.nim @@ -771,13 +771,17 @@ proc addQueryParam(url, key, value: string): string = result.add('=') result.add(value.queryEscape()) -proc connectUrl(address, url, name: string, port: int): string = +proc connectUrl(address, url, name, token: string, port, slot: int): string = ## Builds the player websocket URL. if url.len > 0: result = url.withPath(CoordinatorWebSocketPath) else: result = "ws://" & address & ":" & $port & CoordinatorWebSocketPath result = result.addQueryParam("name", name) + if slot >= 0: + result = result.addQueryParam("slot", $slot) + if token.len > 0: + result = result.addQueryParam("token", token) proc initBot(): Bot = result.selfFound = false @@ -826,10 +830,12 @@ proc runBot( address = DefaultHost, port = CoordinatorDefaultPort, url = "", - name = "coordinator" + name = "coordinator", + token = "", + slot = -1 ) = ## Connects coordinator to Stag Hunt and runs the coalition policy. - let endpoint = connectUrl(address, url, name, port) + let endpoint = connectUrl(address, url, name, token, port, slot) while true: try: echo "coordinator connecting to ", endpoint @@ -854,6 +860,8 @@ when isMainModule: port = CoordinatorDefaultPort url = "" name = "coordinator" + token = "" + slot = -1 for kind, key, value in getopt(): case kind @@ -863,6 +871,8 @@ when isMainModule: of "port": port = parseInt(value) of "url": url = value of "name": name = value + of "token": token = value + of "slot": slot = parseInt(value) else: raise newException(ValueError, "Unknown option: --" & key) of cmdArgument, cmdShortOption: @@ -870,4 +880,4 @@ when isMainModule: of cmdEnd: discard - runBot(address, port, url, name) + runBot(address, port, url, name, token, slot) diff --git a/stag_hunt/players/nearest_hunter/nearest_hunter.nim b/stag_hunt/players/nearest_hunter/nearest_hunter.nim index 086408f3..8ff9f74c 100644 --- a/stag_hunt/players/nearest_hunter/nearest_hunter.nim +++ b/stag_hunt/players/nearest_hunter/nearest_hunter.nim @@ -666,13 +666,17 @@ proc addQueryParam(url, key, value: string): string = result.add('=') result.add(value.queryEscape()) -proc connectUrl(address, url, name: string, port: int): string = +proc connectUrl(address, url, name, token: string, port, slot: int): string = ## Builds the player websocket URL. if url.len > 0: result = url.withPath(WebSocketPath) else: result = "ws://" & address & ":" & $port & WebSocketPath result = result.addQueryParam("name", name) + if slot >= 0: + result = result.addQueryParam("slot", $slot) + if token.len > 0: + result = result.addQueryParam("token", token) proc initBot(): Bot = ## Creates a fresh nearest-hunter bot state. @@ -723,10 +727,12 @@ proc runBot( address = DefaultHost, port = DefaultPort, url = "", - name = "nearest_hunter" + name = "nearest_hunter", + token = "", + slot = -1 ) = ## Connects to a stag_hunt server and pursues the closest visible prey. - let endpoint = connectUrl(address, url, name, port) + let endpoint = connectUrl(address, url, name, token, port, slot) while true: try: echo "nearest_hunter connecting to ", endpoint @@ -751,19 +757,19 @@ when isMainModule: port = DefaultPort url = "" name = "nearest_hunter" + token = "" + slot = -1 for kind, key, value in getopt(): case kind of cmdLongOption: case key - of "address": - address = value - of "port": - port = parseInt(value) - of "url": - url = value - of "name": - name = value + of "address": address = value + of "port": port = parseInt(value) + of "url": url = value + of "name": name = value + of "token": token = value + of "slot": slot = parseInt(value) else: raise newException(ValueError, "Unknown option: --" & key) of cmdArgument, cmdShortOption: @@ -771,4 +777,4 @@ when isMainModule: of cmdEnd: discard - runBot(address, port, url, name) + runBot(address, port, url, name, token, slot) diff --git a/stag_hunt/players/rabbiteer/rabbiteer.nim b/stag_hunt/players/rabbiteer/rabbiteer.nim index 8f450bc8..89fc1821 100644 --- a/stag_hunt/players/rabbiteer/rabbiteer.nim +++ b/stag_hunt/players/rabbiteer/rabbiteer.nim @@ -493,16 +493,17 @@ proc addQueryParam(url, key, value: string): string = result.add('=') result.add(value.queryEscape()) -proc connectUrl( - address, url, name: string, - port: int -): string = +proc connectUrl(address, url, name, token: string, port, slot: int): string = ## Builds the player websocket URL for Stag Hunt. if url.len > 0: result = url.withPath(WebSocketPath) else: result = "ws://" & address & ":" & $port & WebSocketPath result = result.addQueryParam("name", name) + if slot >= 0: + result = result.addQueryParam("slot", $slot) + if token.len > 0: + result = result.addQueryParam("token", token) proc initBot(): Bot = ## Creates a fresh rabbiteer bot state. @@ -553,10 +554,12 @@ proc runBot( address = DefaultHost, port = DefaultPort, url = "", - name = "rabbiteer" + name = "rabbiteer", + token = "", + slot = -1 ) = ## Connects rabbiteer to Stag Hunt and chases visible rabbits forever. - let endpoint = connectUrl(address, url, name, port) + let endpoint = connectUrl(address, url, name, token, port, slot) while true: try: echo "rabbiteer connecting to ", endpoint @@ -582,19 +585,19 @@ when isMainModule: port = DefaultPort url = "" name = "rabbiteer" + token = "" + slot = -1 for kind, key, value in getopt(): case kind of cmdLongOption: case key - of "address": - address = value - of "port": - port = parseInt(value) - of "url": - url = value - of "name": - name = value + of "address": address = value + of "port": port = parseInt(value) + of "url": url = value + of "name": name = value + of "token": token = value + of "slot": slot = parseInt(value) else: raise newException(ValueError, "Unknown option: --" & key) of cmdArgument, cmdShortOption: @@ -602,4 +605,4 @@ when isMainModule: of cmdEnd: discard - runBot(address, port, url, name) + runBot(address, port, url, name, token, slot) diff --git a/stag_hunt/players/sidekick/sidekick.nim b/stag_hunt/players/sidekick/sidekick.nim index 5542a1e0..94745b08 100644 --- a/stag_hunt/players/sidekick/sidekick.nim +++ b/stag_hunt/players/sidekick/sidekick.nim @@ -560,12 +560,16 @@ proc addQueryParam(url, key, value: string): string = result.add('=') result.add(value.queryEscape()) -proc connectUrl(address, url, name: string, port: int): string = +proc connectUrl(address, url, name, token: string, port, slot: int): string = if url.len > 0: result = url.withPath(WebSocketPath) else: result = "ws://" & address & ":" & $port & WebSocketPath result = result.addQueryParam("name", name) + if slot >= 0: + result = result.addQueryParam("slot", $slot) + if token.len > 0: + result = result.addQueryParam("token", token) proc initBot(): Bot = result.selfObjectId = -1 @@ -611,9 +615,11 @@ proc runBot( address = DefaultHost, port = DefaultPort, url = "", - name = "sidekick" + name = "sidekick", + token = "", + slot = -1 ) = - let endpoint = connectUrl(address, url, name, port) + let endpoint = connectUrl(address, url, name, token, port, slot) while true: try: echo "sidekick connecting to ", endpoint @@ -637,6 +643,8 @@ when isMainModule: port = DefaultPort url = "" name = "sidekick" + token = "" + slot = -1 for kind, key, value in getopt(): case kind @@ -646,6 +654,8 @@ when isMainModule: of "port": port = parseInt(value) of "url": url = value of "name": name = value + of "token": token = value + of "slot": slot = parseInt(value) else: raise newException(ValueError, "Unknown option: --" & key) of cmdArgument, cmdShortOption: @@ -653,4 +663,4 @@ when isMainModule: of cmdEnd: discard - runBot(address, port, url, name) + runBot(address, port, url, name, token, slot) diff --git a/stag_hunt/players/stag_hunter/stag_hunter.nim b/stag_hunt/players/stag_hunter/stag_hunter.nim index 1e5e8e1e..fdfd9d54 100644 --- a/stag_hunt/players/stag_hunter/stag_hunter.nim +++ b/stag_hunt/players/stag_hunter/stag_hunter.nim @@ -444,12 +444,16 @@ proc addQueryParam(url, key, value: string): string = result.add('=') result.add(value.queryEscape()) -proc connectUrl(address, url, name: string, port: int): string = +proc connectUrl(address, url, name, token: string, port, slot: int): string = if url.len > 0: result = url.withPath(WebSocketPath) else: result = "ws://" & address & ":" & $port & WebSocketPath result = result.addQueryParam("name", name) + if slot >= 0: + result = result.addQueryParam("slot", $slot) + if token.len > 0: + result = result.addQueryParam("token", token) proc initBot(): Bot = result.selfObjectId = -1 @@ -490,9 +494,11 @@ proc runBot( address = DefaultHost, port = DefaultPort, url = "", - name = "stag_hunter" + name = "stag_hunter", + token = "", + slot = -1 ) = - let endpoint = connectUrl(address, url, name, port) + let endpoint = connectUrl(address, url, name, token, port, slot) while true: try: echo "stag_hunter connecting to ", endpoint @@ -517,6 +523,8 @@ when isMainModule: port = DefaultPort url = "" name = "stag_hunter" + token = "" + slot = -1 for kind, key, value in getopt(): case kind @@ -526,6 +534,8 @@ when isMainModule: of "port": port = parseInt(value) of "url": url = value of "name": name = value + of "token": token = value + of "slot": slot = parseInt(value) else: raise newException(ValueError, "Unknown option: --" & key) of cmdArgument, cmdShortOption: @@ -533,4 +543,4 @@ when isMainModule: of cmdEnd: discard - runBot(address, port, url, name) + runBot(address, port, url, name, token, slot) diff --git a/tools/docker_build.nim b/tools/docker_build.nim index fcbdd07f..650e44aa 100644 --- a/tools/docker_build.nim +++ b/tools/docker_build.nim @@ -409,6 +409,32 @@ proc addBotTargets( if target.supportsGame(game.name): result.addUniqueTarget(target) +const + TournamentArgs = ["name", "token", "slot"] + +proc checkTournamentArgs(root: string, targets: openArray[DockerTarget]) = + ## Verifies bot source files accept --name, --token, and --slot. + var failed = false + for target in targets: + if target.isGame: + continue + let nimFile = target.contextDir / target.name & ".nim" + if not fileExists(nimFile): + continue + let source = readFile(nimFile) + if "getopt" notin source: + continue + for arg in TournamentArgs: + if ("of \"" & arg & "\"") notin source: + echo "Error: ", nimFile.relativePath(root), + " does not handle --", arg, + " (required for tournaments)" + failed = true + if failed: + echo "" + echo "Bot players must accept --name, --token, and --slot to work in tournaments." + quit(1) + proc ensureBuildx() = ## Verifies that docker buildx is available. let (output, code) = execCmdEx("docker buildx version") @@ -635,6 +661,8 @@ proc main() = echo " bots: ", includeBots echo " targets: ", targetNames(chosen) + checkTournamentArgs(root, chosen) + ensureBuildx() if push or "," in platforms: ensureBuilder() From d6f6c8413e2f9b3d9faaee2ad706a3b26f272cf1 Mon Sep 17 00:00:00 2001 From: Malcolm Ocean Date: Fri, 15 May 2026 17:38:58 -0700 Subject: [PATCH 2/5] Add CLAUDE.md with tournament args requirement for bot players Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8ea2fb0a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# BitWorld + +## Bot Players + +Bot players must accept these CLI arguments to work in tournaments: + +- `--name` — player display name +- `--token` — authentication token for the game server +- `--slot` — assigned player slot (integer, -1 means unassigned) + +These are passed as query params in the websocket connect URL. See `planet_wars/players/skurge/skurge.nim` for a reference implementation. + +`tools/docker_build.nim` will fail the build if a bot source file parses CLI args but doesn't handle all three. + +## Building and Deploying + +```sh +nim r tools/docker_build.nim --push stag_hunt --bots +``` + +See README.md for full deploy workflow. From bd5376ca012845f31b2c522b48bf62fcaec0dc17 Mon Sep 17 00:00:00 2001 From: Malcolm Ocean Date: Fri, 15 May 2026 17:46:00 -0700 Subject: [PATCH 3/5] Revert "Add CLAUDE.md with tournament args requirement for bot players" This reverts commit d6f6c8413e2f9b3d9faaee2ad706a3b26f272cf1. --- CLAUDE.md | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8ea2fb0a..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,21 +0,0 @@ -# BitWorld - -## Bot Players - -Bot players must accept these CLI arguments to work in tournaments: - -- `--name` — player display name -- `--token` — authentication token for the game server -- `--slot` — assigned player slot (integer, -1 means unassigned) - -These are passed as query params in the websocket connect URL. See `planet_wars/players/skurge/skurge.nim` for a reference implementation. - -`tools/docker_build.nim` will fail the build if a bot source file parses CLI args but doesn't handle all three. - -## Building and Deploying - -```sh -nim r tools/docker_build.nim --push stag_hunt --bots -``` - -See README.md for full deploy workflow. From 800cf0d9069a3a342edb91438775cf0c1aae0134 Mon Sep 17 00:00:00 2001 From: Malcolm Ocean Date: Fri, 15 May 2026 17:54:36 -0700 Subject: [PATCH 4/5] stag_hunt: read COGAMES_ENGINE_WS_URL for tournament env var path The coworld runner passes the full websocket URL via this env var. Also check for it in docker_build's pre-build validation. Co-Authored-By: Claude Opus 4.6 --- stag_hunt/players/coordinator/coordinator.nim | 2 +- stag_hunt/players/nearest_hunter/nearest_hunter.nim | 2 +- stag_hunt/players/rabbiteer/rabbiteer.nim | 2 +- stag_hunt/players/sidekick/sidekick.nim | 2 +- stag_hunt/players/stag_hunter/stag_hunter.nim | 2 +- tools/docker_build.nim | 12 ++++++++++-- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/stag_hunt/players/coordinator/coordinator.nim b/stag_hunt/players/coordinator/coordinator.nim index bef3b386..2f7b5455 100644 --- a/stag_hunt/players/coordinator/coordinator.nim +++ b/stag_hunt/players/coordinator/coordinator.nim @@ -858,7 +858,7 @@ when isMainModule: var address = DefaultHost port = CoordinatorDefaultPort - url = "" + url = getEnv("COGAMES_ENGINE_WS_URL") name = "coordinator" token = "" slot = -1 diff --git a/stag_hunt/players/nearest_hunter/nearest_hunter.nim b/stag_hunt/players/nearest_hunter/nearest_hunter.nim index 8ff9f74c..db68beee 100644 --- a/stag_hunt/players/nearest_hunter/nearest_hunter.nim +++ b/stag_hunt/players/nearest_hunter/nearest_hunter.nim @@ -755,7 +755,7 @@ when isMainModule: var address = DefaultHost port = DefaultPort - url = "" + url = getEnv("COGAMES_ENGINE_WS_URL") name = "nearest_hunter" token = "" slot = -1 diff --git a/stag_hunt/players/rabbiteer/rabbiteer.nim b/stag_hunt/players/rabbiteer/rabbiteer.nim index 89fc1821..6e311051 100644 --- a/stag_hunt/players/rabbiteer/rabbiteer.nim +++ b/stag_hunt/players/rabbiteer/rabbiteer.nim @@ -583,7 +583,7 @@ when isMainModule: var address = DefaultHost port = DefaultPort - url = "" + url = getEnv("COGAMES_ENGINE_WS_URL") name = "rabbiteer" token = "" slot = -1 diff --git a/stag_hunt/players/sidekick/sidekick.nim b/stag_hunt/players/sidekick/sidekick.nim index 94745b08..429a4652 100644 --- a/stag_hunt/players/sidekick/sidekick.nim +++ b/stag_hunt/players/sidekick/sidekick.nim @@ -641,7 +641,7 @@ when isMainModule: var address = DefaultHost port = DefaultPort - url = "" + url = getEnv("COGAMES_ENGINE_WS_URL") name = "sidekick" token = "" slot = -1 diff --git a/stag_hunt/players/stag_hunter/stag_hunter.nim b/stag_hunt/players/stag_hunter/stag_hunter.nim index fdfd9d54..86622637 100644 --- a/stag_hunt/players/stag_hunter/stag_hunter.nim +++ b/stag_hunt/players/stag_hunter/stag_hunter.nim @@ -521,7 +521,7 @@ when isMainModule: var address = DefaultHost port = DefaultPort - url = "" + url = getEnv("COGAMES_ENGINE_WS_URL") name = "stag_hunter" token = "" slot = -1 diff --git a/tools/docker_build.nim b/tools/docker_build.nim index 650e44aa..454dbc1d 100644 --- a/tools/docker_build.nim +++ b/tools/docker_build.nim @@ -411,9 +411,10 @@ proc addBotTargets( const TournamentArgs = ["name", "token", "slot"] + TournamentEnv = "COGAMES_ENGINE_WS_URL" proc checkTournamentArgs(root: string, targets: openArray[DockerTarget]) = - ## Verifies bot source files accept --name, --token, and --slot. + ## Verifies bot source files accept --name, --token, --slot, and the env var. var failed = false for target in targets: if target.isGame: @@ -430,9 +431,16 @@ proc checkTournamentArgs(root: string, targets: openArray[DockerTarget]) = " does not handle --", arg, " (required for tournaments)" failed = true + if TournamentEnv notin source: + echo "Error: ", nimFile.relativePath(root), + " does not read ", TournamentEnv, + " (required for tournaments)" + failed = true if failed: echo "" - echo "Bot players must accept --name, --token, and --slot to work in tournaments." + echo "Bot players must accept --name, --token, --slot and read" + echo TournamentEnv, " to work in tournaments." + echo "See metta/packages/coworld/src/coworld/GAME_RUNTIME_README.md" quit(1) proc ensureBuildx() = From e0f3e8384150be8a1d90f7b863fe88e705d34fb0 Mon Sep 17 00:00:00 2001 From: Malcolm Ocean Date: Fri, 15 May 2026 17:54:56 -0700 Subject: [PATCH 5/5] Add AGENTS.md with tournament compatibility requirements CLAUDE.md symlinks here. Points to the canonical spec in the metta repo so bot authors find the requirements before hitting deploy failures. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 31 +++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 32 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8080b015 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# BitWorld + +## Bot Players — Tournament Compatibility + +Bot players must be compatible with the Coworld tournament runner. The full spec +lives in the metta repo at: + + metta/packages/coworld/src/coworld/GAME_RUNTIME_README.md + +The key requirements for bot containers: + +1. Read `COGAMES_ENGINE_WS_URL` env var — the runner passes the full websocket + URL (including slot and token query params) via this variable. + +2. Accept CLI args `--name`, `--token`, and `--slot` as fallbacks for local + testing and older runner versions. + +3. Connect to the game's `/player` websocket endpoint with `slot`, `token`, and + `name` as query params. + +See `planet_wars/players/skurge/skurge.nim` for a reference implementation. + +`tools/docker_build.nim` validates these requirements before building images. + +## Building and Deploying + +```sh +nim r tools/docker_build.nim --push stag_hunt --bots +``` + +See README.md for full deploy workflow. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file