From 62b3363362b753951077e269541e57d70428468d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:19:44 +0000 Subject: [PATCH 01/12] go.mod: module is github.com/shaumik/PokeArena, and a LICENSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module was named "pokearena", which is not a resolvable import path. That made "go install github.com/shaumik/PokeArena/cmd/...@latest" fail and kept the repo off pkg.go.dev entirely — the most natural way to obtain the MCP server and the bench harness did not work at all. Rename the module and rewrite the 402 import lines that referenced it. The root package keeps its name (pokearena), so cmd/pokearena-agent aliases the import rather than relying on a basename that no longer matches. The repo also had no LICENSE, which means all rights reserved: unusable in published research, unusable commercially, and rejected on sight by the registries and awesome-lists we want to be listed in. MIT, with the trademark position the README already states kept alongside it. --- LICENSE | 28 +++++++++++++++++++ cmd/ai-service/main.go | 12 ++++---- cmd/battle-session/main.go | 16 +++++------ cmd/battle-worker/main.go | 14 +++++----- cmd/bench-history/main.go | 2 +- cmd/bench-report/main.go | 4 +-- cmd/bench/main.go | 14 +++++----- cmd/bench/spec_test.go | 4 +-- cmd/data-sync/main.go | 2 +- cmd/data-sync/transform.go | 4 +-- cmd/data-sync/validate.go | 2 +- cmd/data-validate/main.go | 4 +-- cmd/db-replay/main.go | 2 +- cmd/gateway/main.go | 14 +++++----- cmd/leaderboard-worker/main.go | 10 +++---- cmd/mcp-smoke/main.go | 2 +- cmd/pokearena-agent/main.go | 8 +++--- cmd/pokearena-mcp/main.go | 2 +- cmd/royale/main.go | 6 ++-- cmd/royale/render.go | 6 ++-- cmd/royale/royale_test.go | 4 +-- cmd/royale/store.go | 2 +- cmd/spread-impact/main.go | 8 +++--- cmd/team-validate/main.go | 8 +++--- go.mod | 2 +- internal/agentloop/agent.go | 8 +++--- internal/agentloop/agent_test.go | 8 +++--- internal/agentloop/client.go | 2 +- internal/agentloop/loop.go | 10 +++---- internal/agentloop/prompt.go | 6 ++-- internal/agentloop/prompt_test.go | 8 +++--- internal/ai/agent.go | 4 +-- internal/ai/agent_test.go | 4 +-- internal/ai/expectimax.go | 4 +-- internal/ai/expectimax_test.go | 2 +- internal/ai/harness.go | 4 +-- internal/ai/harness_test.go | 2 +- internal/ai/heuristic.go | 4 +-- internal/ai/itemfog_test.go | 4 +-- internal/ai/main_test.go | 2 +- internal/ai/random.go | 2 +- internal/ai/teams.go | 4 +-- internal/ai/teams_test.go | 2 +- internal/cache/cache.go | 2 +- internal/domain/domain.go | 2 +- internal/domain/nature_test.go | 4 +-- internal/engine/abilities.go | 2 +- internal/engine/abilities_behavior_test.go | 2 +- internal/engine/abilities_test.go | 2 +- internal/engine/abilitysetting.go | 2 +- .../engine/abilitysetting_behavior_test.go | 2 +- internal/engine/aim.go | 4 +-- internal/engine/battle.go | 2 +- internal/engine/behavior_helpers_test.go | 2 +- internal/engine/buffs.go | 2 +- internal/engine/callbackmoves.go | 2 +- .../engine/callbackmoves_behavior_test.go | 2 +- internal/engine/callbackmoves_test.go | 2 +- internal/engine/calledmoves.go | 2 +- internal/engine/calledmoves_behavior_test.go | 2 +- internal/engine/clauses.go | 2 +- internal/engine/clauses_test.go | 2 +- internal/engine/coverage.go | 2 +- internal/engine/coverage_test.go | 2 +- internal/engine/damage.go | 2 +- internal/engine/drainvolatiles.go | 4 +-- internal/engine/effects.go | 2 +- internal/engine/engine_test.go | 2 +- internal/engine/fullgame_integration_test.go | 2 +- internal/engine/futuresight_behavior_test.go | 2 +- internal/engine/gender_test.go | 2 +- internal/engine/gimmicks.go | 4 +-- internal/engine/guards.go | 2 +- internal/engine/hazards.go | 4 +-- internal/engine/hazards_behavior_test.go | 2 +- internal/engine/healblock_test.go | 2 +- internal/engine/historypower_test.go | 2 +- internal/engine/itemcoverage.go | 2 +- internal/engine/items.go | 2 +- internal/engine/items_berries.go | 2 +- internal/engine/items_berries_test.go | 2 +- internal/engine/items_core.go | 2 +- internal/engine/items_field.go | 2 +- internal/engine/items_fling.go | 2 +- internal/engine/items_integration_test.go | 2 +- internal/engine/items_modifiers.go | 2 +- internal/engine/items_modifiers_test.go | 2 +- internal/engine/items_moves.go | 2 +- internal/engine/items_moves_behavior_test.go | 2 +- internal/engine/items_moves_fling_test.go | 2 +- internal/engine/items_moves_test.go | 2 +- internal/engine/items_reactive.go | 2 +- internal/engine/items_test.go | 2 +- internal/engine/lockedmove.go | 2 +- internal/engine/lockon_behavior_test.go | 2 +- internal/engine/lockrestrict.go | 4 +-- internal/engine/mechanics_test.go | 2 +- internal/engine/move_inert_test.go | 2 +- internal/engine/movegaps_behavior_test.go | 2 +- internal/engine/powder_test.go | 2 +- internal/engine/protect.go | 4 +-- internal/engine/pseudoweather.go | 4 +-- internal/engine/pursuit_pivot_test.go | 2 +- internal/engine/roost.go | 2 +- internal/engine/screens.go | 4 +-- internal/engine/selfsecondary_test.go | 2 +- internal/engine/selfswitch_target_test.go | 2 +- .../engine/showdown/harness_selftest_test.go | 4 +-- internal/engine/showdown/harness_test.go | 4 +-- internal/engine/showdown/misc_prng_test.go | 2 +- internal/engine/showdown/names_test.go | 2 +- internal/engine/skydrop.go | 2 +- internal/engine/slotconditions.go | 4 +-- internal/engine/spread_test.go | 2 +- internal/engine/state.go | 2 +- .../engine/statswapmoves_behavior_test.go | 2 +- internal/engine/statusvols.go | 4 +-- internal/engine/statusvols_behavior_test.go | 2 +- internal/engine/substitute.go | 4 +-- internal/engine/substitute_behavior_test.go | 2 +- internal/engine/switching.go | 2 +- internal/engine/team_validation.go | 2 +- internal/engine/team_validation_test.go | 2 +- internal/engine/terrain.go | 4 +-- internal/engine/trapping_release_test.go | 2 +- internal/engine/turn.go | 2 +- internal/engine/twoturn_skip_test.go | 2 +- internal/engine/typechange.go | 2 +- internal/engine/volatiles.go | 4 +-- internal/engine/weather.go | 4 +-- internal/engine/willcrit_test.go | 2 +- internal/eval/game.go | 8 +++--- internal/eval/game_test.go | 6 ++-- internal/eval/items_integration_test.go | 6 ++-- internal/eval/library.go | 4 +-- internal/eval/library_test.go | 4 +-- internal/eval/main_test.go | 2 +- internal/eval/match.go | 6 ++-- internal/eval/match_test.go | 2 +- internal/eval/provenance.go | 4 +-- internal/eval/provenance_test.go | 4 +-- internal/eval/replay.go | 6 ++-- internal/eval/replay_reconstruct.go | 2 +- internal/eval/replay_reconstruct_test.go | 2 +- internal/eval/replay_test.go | 4 +-- internal/eval/report_test.go | 2 +- internal/eval/results.go | 2 +- internal/eval/results_test.go | 2 +- internal/eval/team.go | 4 +-- internal/eval/tournament.go | 2 +- internal/eval/tournament_test.go | 2 +- internal/eval/vsreference.go | 4 +-- internal/gwclient/gwclient.go | 2 +- internal/gwclient/gwclient_test.go | 2 +- internal/httpapi/hub.go | 4 +-- internal/httpapi/hub_test.go | 2 +- internal/httpapi/items_test.go | 4 +-- internal/httpapi/live.go | 2 +- internal/httpapi/server.go | 18 ++++++------ internal/httpapi/sse.go | 2 +- internal/httpapi/ws.go | 8 +++--- internal/livebattle/bridge.go | 2 +- internal/livebattle/bridge_test.go | 8 +++--- internal/livebattle/cancel_test.go | 2 +- internal/livebattle/coordinator.go | 8 +++--- internal/livebattle/coordinator_test.go | 10 +++---- internal/livebattle/livebattle.go | 6 ++-- internal/livebattle/main_test.go | 2 +- internal/livebattle/resume_test.go | 4 +-- internal/llm/anthropic.go | 2 +- internal/llm/anthropic_test.go | 2 +- internal/llm/client.go | 2 +- internal/llm/gemini.go | 2 +- internal/llm/gemini_test.go | 2 +- internal/llm/ollama.go | 2 +- internal/llm/ollama_test.go | 2 +- internal/llm/openai.go | 2 +- internal/llm/openai_test.go | 2 +- internal/mcpserver/session.go | 8 +++--- internal/mcpserver/session_test.go | 6 ++-- internal/mcpserver/tools.go | 2 +- internal/mcpserver/viewwire_test.go | 8 +++--- internal/messages/messages.go | 2 +- internal/mq/live.go | 4 +-- internal/mq/mq.go | 2 +- internal/protocol/pvp.go | 4 +-- internal/session/abandon_test.go | 14 +++++----- internal/session/concurrent_test.go | 16 +++++------ internal/session/distribution_test.go | 20 ++++++------- internal/session/failover_test.go | 12 ++++---- internal/session/latejoin_test.go | 12 ++++---- internal/session/liveai_test.go | 12 ++++---- internal/session/pickerleave_test.go | 14 +++++----- internal/session/reconnect_test.go | 14 +++++----- internal/session/session.go | 16 +++++------ internal/session/spectator_test.go | 14 +++++----- internal/specs/specs.go | 2 +- 197 files changed, 431 insertions(+), 403 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3774f927 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 Shaumik Ashraf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Pokémon and Pokémon character names are trademarks of Nintendo, Creatures Inc., +and GAME FREAK Inc. This project is an unofficial, non-commercial fan work and +is not affiliated with or endorsed by them. The license above covers the +original code and documentation in this repository only. diff --git a/cmd/ai-service/main.go b/cmd/ai-service/main.go index 0aa8a5e6..89959f6d 100644 --- a/cmd/ai-service/main.go +++ b/cmd/ai-service/main.go @@ -11,12 +11,12 @@ import ( "os/signal" "syscall" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/config" - "pokearena/internal/domain" - "pokearena/internal/messages" - "pokearena/internal/mq" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/config" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" ) type aiService struct { diff --git a/cmd/battle-session/main.go b/cmd/battle-session/main.go index c24acd45..59dce300 100644 --- a/cmd/battle-session/main.go +++ b/cmd/battle-session/main.go @@ -11,14 +11,14 @@ import ( "os/signal" "syscall" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/config" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/mq" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/config" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" "github.com/google/uuid" ) diff --git a/cmd/battle-worker/main.go b/cmd/battle-worker/main.go index c02dc251..cdfa7b4e 100644 --- a/cmd/battle-worker/main.go +++ b/cmd/battle-worker/main.go @@ -14,13 +14,13 @@ import ( "syscall" "time" - "pokearena/internal/ai" - "pokearena/internal/config" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/config" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/store" ) type worker struct { diff --git a/cmd/bench-history/main.go b/cmd/bench-history/main.go index 3ef0e022..8f1efa85 100644 --- a/cmd/bench-history/main.go +++ b/cmd/bench-history/main.go @@ -21,7 +21,7 @@ import ( "sort" "strings" - "pokearena/internal/eval" + "github.com/shaumik/PokeArena/internal/eval" ) func main() { diff --git a/cmd/bench-report/main.go b/cmd/bench-report/main.go index a7d9250c..2aba6c8b 100644 --- a/cmd/bench-report/main.go +++ b/cmd/bench-report/main.go @@ -21,8 +21,8 @@ import ( "os" "path/filepath" - "pokearena/internal/domain" - "pokearena/internal/eval" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/eval" ) func main() { diff --git a/cmd/bench/main.go b/cmd/bench/main.go index 718a57fa..c2decc12 100644 --- a/cmd/bench/main.go +++ b/cmd/bench/main.go @@ -24,13 +24,13 @@ import ( "strings" "time" - "pokearena/internal/agentloop" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/eval" - "pokearena/internal/llm" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/agentloop" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" + "github.com/shaumik/PokeArena/internal/llm" + "github.com/shaumik/PokeArena/internal/usage" ) func main() { diff --git a/cmd/bench/spec_test.go b/cmd/bench/spec_test.go index b348c931..3208f1c2 100644 --- a/cmd/bench/spec_test.go +++ b/cmd/bench/spec_test.go @@ -3,8 +3,8 @@ package main import ( "testing" - "pokearena/internal/ai" - "pokearena/internal/eval" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/eval" ) // firstDuplicateName must catch a collision (e.g. -agents random,random or a diff --git a/cmd/data-sync/main.go b/cmd/data-sync/main.go index 51c37037..330d848a 100644 --- a/cmd/data-sync/main.go +++ b/cmd/data-sync/main.go @@ -27,7 +27,7 @@ import ( // Blank import: pull engine's package init so internal/specs sees // every supported volatile / side-condition / weather / terrain slug // before transform.go filters upstream against them. - _ "pokearena/internal/engine" + _ "github.com/shaumik/PokeArena/internal/engine" ) func main() { diff --git a/cmd/data-sync/transform.go b/cmd/data-sync/transform.go index 8d97bbc7..488bd726 100644 --- a/cmd/data-sync/transform.go +++ b/cmd/data-sync/transform.go @@ -7,8 +7,8 @@ import ( "sort" "strings" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // silentDropVolatiles are upstream volatile names we drop without warning diff --git a/cmd/data-sync/validate.go b/cmd/data-sync/validate.go index c47cc962..60e9fd77 100644 --- a/cmd/data-sync/validate.go +++ b/cmd/data-sync/validate.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // validate loads the staged dataset through the live domain validator. Any diff --git a/cmd/data-validate/main.go b/cmd/data-validate/main.go index 10645846..c7f0a04f 100644 --- a/cmd/data-validate/main.go +++ b/cmd/data-validate/main.go @@ -16,13 +16,13 @@ import ( "log" "os" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" // Blank import: engine's init() populates internal/specs with the // vocabularies the domain validator checks against. Skipping it // would make every move's volatile / side-condition slug look // unknown. - _ "pokearena/internal/engine" + _ "github.com/shaumik/PokeArena/internal/engine" ) func main() { diff --git a/cmd/db-replay/main.go b/cmd/db-replay/main.go index 95a651f7..edcff012 100644 --- a/cmd/db-replay/main.go +++ b/cmd/db-replay/main.go @@ -20,7 +20,7 @@ import ( "log" "os" - "pokearena/internal/eval" + "github.com/shaumik/PokeArena/internal/eval" ) // export is the psql json_build_object shape for one battle. diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go index 6c71fa3a..428cf910 100644 --- a/cmd/gateway/main.go +++ b/cmd/gateway/main.go @@ -11,13 +11,13 @@ import ( "syscall" "time" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/config" - "pokearena/internal/domain" - "pokearena/internal/httpapi" - "pokearena/internal/mq" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/config" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/httpapi" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/store" ) func main() { diff --git a/cmd/leaderboard-worker/main.go b/cmd/leaderboard-worker/main.go index 666c48a4..39ecd65e 100644 --- a/cmd/leaderboard-worker/main.go +++ b/cmd/leaderboard-worker/main.go @@ -11,11 +11,11 @@ import ( "os/signal" "syscall" - "pokearena/internal/cache" - "pokearena/internal/config" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/config" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/store" "github.com/jackc/pgx/v5" ) diff --git a/cmd/mcp-smoke/main.go b/cmd/mcp-smoke/main.go index f28ac04c..3414e9f5 100644 --- a/cmd/mcp-smoke/main.go +++ b/cmd/mcp-smoke/main.go @@ -25,7 +25,7 @@ import ( "os/exec" "time" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/protocol" "github.com/gorilla/websocket" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/cmd/pokearena-agent/main.go b/cmd/pokearena-agent/main.go index 5cef5359..723766e2 100644 --- a/cmd/pokearena-agent/main.go +++ b/cmd/pokearena-agent/main.go @@ -25,10 +25,10 @@ import ( "syscall" "time" - "pokearena" - "pokearena/internal/agentloop" - "pokearena/internal/domain" - "pokearena/internal/llm" + pokearena "github.com/shaumik/PokeArena" + "github.com/shaumik/PokeArena/internal/agentloop" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/llm" ) func main() { diff --git a/cmd/pokearena-mcp/main.go b/cmd/pokearena-mcp/main.go index 6d5f351d..c4f19362 100644 --- a/cmd/pokearena-mcp/main.go +++ b/cmd/pokearena-mcp/main.go @@ -31,7 +31,7 @@ import ( "strings" "syscall" - "pokearena/internal/mcpserver" + "github.com/shaumik/PokeArena/internal/mcpserver" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/cmd/royale/main.go b/cmd/royale/main.go index e6ed2713..adc6ba59 100644 --- a/cmd/royale/main.go +++ b/cmd/royale/main.go @@ -32,9 +32,9 @@ import ( "strings" "time" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) func main() { diff --git a/cmd/royale/render.go b/cmd/royale/render.go index 0b987529..ba56fc48 100644 --- a/cmd/royale/render.go +++ b/cmd/royale/render.go @@ -5,9 +5,9 @@ import ( "sort" "strings" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) func pct(hp, max int) int { diff --git a/cmd/royale/royale_test.go b/cmd/royale/royale_test.go index 16618a31..bcd09478 100644 --- a/cmd/royale/royale_test.go +++ b/cmd/royale/royale_test.go @@ -21,8 +21,8 @@ import ( "strings" "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) const ( diff --git a/cmd/royale/store.go b/cmd/royale/store.go index 3aebf15d..4f7c309e 100644 --- a/cmd/royale/store.go +++ b/cmd/royale/store.go @@ -8,7 +8,7 @@ import ( "path/filepath" "time" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // Trainer is one seat in a royale match: a named agent with a theme and the diff --git a/cmd/spread-impact/main.go b/cmd/spread-impact/main.go index 8eeabe37..f67149ac 100644 --- a/cmd/spread-impact/main.go +++ b/cmd/spread-impact/main.go @@ -23,10 +23,10 @@ import ( "log" "os" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/eval" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" ) func main() { diff --git a/cmd/team-validate/main.go b/cmd/team-validate/main.go index 44645497..607038e1 100644 --- a/cmd/team-validate/main.go +++ b/cmd/team-validate/main.go @@ -19,10 +19,10 @@ import ( "log" "os" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/eval" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" ) func main() { diff --git a/go.mod b/go.mod index dc8836f6..70f2bd45 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module pokearena +module github.com/shaumik/PokeArena go 1.26 diff --git a/internal/agentloop/agent.go b/internal/agentloop/agent.go index a82893fd..2df0bf74 100644 --- a/internal/agentloop/agent.go +++ b/internal/agentloop/agent.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/usage" ) // Agent adapts an LLMClient to the ai.Agent interface, so a language model can diff --git a/internal/agentloop/agent_test.go b/internal/agentloop/agent_test.go index 55605df0..22b4f33d 100644 --- a/internal/agentloop/agent_test.go +++ b/internal/agentloop/agent_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/usage" ) func loadDex(t *testing.T) *domain.Dex { diff --git a/internal/agentloop/client.go b/internal/agentloop/client.go index 35563a74..cefd8aa7 100644 --- a/internal/agentloop/client.go +++ b/internal/agentloop/client.go @@ -12,7 +12,7 @@ package agentloop import ( "context" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // LLMClient is the provider-agnostic boundary between the agent loop and diff --git a/internal/agentloop/loop.go b/internal/agentloop/loop.go index 37156ece..b11c91c7 100644 --- a/internal/agentloop/loop.go +++ b/internal/agentloop/loop.go @@ -7,11 +7,11 @@ import ( "log" "time" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/gwclient" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/gwclient" + "github.com/shaumik/PokeArena/internal/protocol" ) // Config wires Run together. Every field except Logger is required. diff --git a/internal/agentloop/prompt.go b/internal/agentloop/prompt.go index bc091d85..1ad342f3 100644 --- a/internal/agentloop/prompt.go +++ b/internal/agentloop/prompt.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // SystemPrompt is the static instructions block sent every turn. Adapters diff --git a/internal/agentloop/prompt_test.go b/internal/agentloop/prompt_test.go index d6795560..21c2ff2d 100644 --- a/internal/agentloop/prompt_test.go +++ b/internal/agentloop/prompt_test.go @@ -5,10 +5,10 @@ import ( "strings" "testing" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/protocol" ) // stubDex builds a minimal Dex with only the moves the prompt renderer diff --git a/internal/ai/agent.go b/internal/ai/agent.go index 1f9d4554..3ce19dd0 100644 --- a/internal/ai/agent.go +++ b/internal/ai/agent.go @@ -10,8 +10,8 @@ import ( "context" "encoding/json" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // View is everything one side may legitimately see — exactly what the human diff --git a/internal/ai/agent_test.go b/internal/ai/agent_test.go index 23b6d490..0b846899 100644 --- a/internal/ai/agent_test.go +++ b/internal/ai/agent_test.go @@ -6,8 +6,8 @@ import ( "testing" "time" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) func loadDex(t *testing.T) *domain.Dex { diff --git a/internal/ai/expectimax.go b/internal/ai/expectimax.go index 4d944697..2ed578eb 100644 --- a/internal/ai/expectimax.go +++ b/internal/ai/expectimax.go @@ -5,8 +5,8 @@ import ( "math" "time" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // ExpectimaxAgent is the "Hard" strategy: a depth-limited search over what is diff --git a/internal/ai/expectimax_test.go b/internal/ai/expectimax_test.go index 4987968d..8382ac3f 100644 --- a/internal/ai/expectimax_test.go +++ b/internal/ai/expectimax_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // TestExpectimaxFixed_Deterministic: the fixed-depth agent must return the diff --git a/internal/ai/harness.go b/internal/ai/harness.go index 24ff78d6..18bb9708 100644 --- a/internal/ai/harness.go +++ b/internal/ai/harness.go @@ -6,8 +6,8 @@ import ( "log" "time" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // Harness wraps a primary Agent with a time budget. Panics and budget diff --git a/internal/ai/harness_test.go b/internal/ai/harness_test.go index 53a81f30..34929e6d 100644 --- a/internal/ai/harness_test.go +++ b/internal/ai/harness_test.go @@ -3,7 +3,7 @@ package ai import ( "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // TestNewHeuristicHarness checks the live-battle opponent: its primary strategy diff --git a/internal/ai/heuristic.go b/internal/ai/heuristic.go index a6c304e5..a5885ea9 100644 --- a/internal/ai/heuristic.go +++ b/internal/ai/heuristic.go @@ -3,8 +3,8 @@ package ai import ( "context" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // HeuristicAgent is the "Easy" strategy: a depth-0 evaluator. It scores every diff --git a/internal/ai/itemfog_test.go b/internal/ai/itemfog_test.go index 987784ae..8cf570bd 100644 --- a/internal/ai/itemfog_test.go +++ b/internal/ai/itemfog_test.go @@ -4,8 +4,8 @@ import ( "encoding/json" "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // TestView_FoeItemNeverReachesWire locks the held item into the same diff --git a/internal/ai/main_test.go b/internal/ai/main_test.go index 53f136f8..ac9b7648 100644 --- a/internal/ai/main_test.go +++ b/internal/ai/main_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // main_test.go turns every test in this package into an invariant test. See diff --git a/internal/ai/random.go b/internal/ai/random.go index 6cdd9ef7..f921af78 100644 --- a/internal/ai/random.go +++ b/internal/ai/random.go @@ -3,7 +3,7 @@ package ai import ( "context" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // RandomAgent picks a uniformly random legal action. It is not meant for real diff --git a/internal/ai/teams.go b/internal/ai/teams.go index 7198fcfb..402aa553 100644 --- a/internal/ai/teams.go +++ b/internal/ai/teams.go @@ -7,8 +7,8 @@ import ( "math/rand" "os" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // TeamPool serves curated, validated AI teams. Loaded once at startup; diff --git a/internal/ai/teams_test.go b/internal/ai/teams_test.go index 60e2ebef..56696230 100644 --- a/internal/ai/teams_test.go +++ b/internal/ai/teams_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // TestLoadTeamPool_ExplicitPicksUseTunedMoves proves the pool honors explicit diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 29fbc2c9..999670aa 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -10,7 +10,7 @@ import ( "fmt" "time" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" "github.com/redis/go-redis/v9" ) diff --git a/internal/domain/domain.go b/internal/domain/domain.go index e88a4954..b337e4ea 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -13,7 +13,7 @@ import ( "os" "sort" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/specs" ) // Type is one of the 18 elemental types. diff --git a/internal/domain/nature_test.go b/internal/domain/nature_test.go index 3e2408ac..fa59d02d 100644 --- a/internal/domain/nature_test.go +++ b/internal/domain/nature_test.go @@ -11,8 +11,8 @@ import ( "testing" "testing/fstest" - "pokearena/internal/domain" - _ "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + _ "github.com/shaumik/PokeArena/internal/engine" ) // TestNatureMultiplier covers the ratio table, including the two shapes that diff --git a/internal/engine/abilities.go b/internal/engine/abilities.go index 37cc999d..0ccd81fc 100644 --- a/internal/engine/abilities.go +++ b/internal/engine/abilities.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // AbilityKind identifies a Pokémon's ability by slug (lowercase kebab-case, diff --git a/internal/engine/abilities_behavior_test.go b/internal/engine/abilities_behavior_test.go index 86973d5f..1e3c0cb7 100644 --- a/internal/engine/abilities_behavior_test.go +++ b/internal/engine/abilities_behavior_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // abilities_behavior_test.go plays real battles for a set of mechanics that diff --git a/internal/engine/abilities_test.go b/internal/engine/abilities_test.go index 36ca23b6..71e0bfa5 100644 --- a/internal/engine/abilities_test.go +++ b/internal/engine/abilities_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // TestPinchAbilityBoostsMatchingType: Blaze multiplies the holder's Fire-move diff --git a/internal/engine/abilitysetting.go b/internal/engine/abilitysetting.go index 44911055..4ce04d71 100644 --- a/internal/engine/abilitysetting.go +++ b/internal/engine/abilitysetting.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // abilitysetting.go owns the four moves that rewrite an ability in place: diff --git a/internal/engine/abilitysetting_behavior_test.go b/internal/engine/abilitysetting_behavior_test.go index a244b7a3..9a2fe01f 100644 --- a/internal/engine/abilitysetting_behavior_test.go +++ b/internal/engine/abilitysetting_behavior_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // abilitysetting_behavior_test.go covers the four moves that rewrite an ability diff --git a/internal/engine/aim.go b/internal/engine/aim.go index 0761b8cf..201a4857 100644 --- a/internal/engine/aim.go +++ b/internal/engine/aim.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // aim.go owns the volatiles that bend crit / accuracy / evasion math: diff --git a/internal/engine/battle.go b/internal/engine/battle.go index c8459124..ffb99d49 100644 --- a/internal/engine/battle.go +++ b/internal/engine/battle.go @@ -8,7 +8,7 @@ import ( "errors" "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // Phase is the part of the turn cycle a battle is waiting on. diff --git a/internal/engine/behavior_helpers_test.go b/internal/engine/behavior_helpers_test.go index c9fdbc8d..7c322501 100644 --- a/internal/engine/behavior_helpers_test.go +++ b/internal/engine/behavior_helpers_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // behavior_helpers_test.go is the shared vocabulary for the *_behavior_test.go diff --git a/internal/engine/buffs.go b/internal/engine/buffs.go index d7342af4..3daa2c74 100644 --- a/internal/engine/buffs.go +++ b/internal/engine/buffs.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/specs" ) // buffs.go owns the per-side helper conditions that aren't damage screens diff --git a/internal/engine/callbackmoves.go b/internal/engine/callbackmoves.go index 2f85dfe6..e53417ff 100644 --- a/internal/engine/callbackmoves.go +++ b/internal/engine/callbackmoves.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // callbackmoves.go implements the moves Showdown encodes as JS callbacks diff --git a/internal/engine/callbackmoves_behavior_test.go b/internal/engine/callbackmoves_behavior_test.go index f5508778..18558dd9 100644 --- a/internal/engine/callbackmoves_behavior_test.go +++ b/internal/engine/callbackmoves_behavior_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // callbackmoves_behavior_test.go is the whole-battle half of callbackmoves.go. diff --git a/internal/engine/callbackmoves_test.go b/internal/engine/callbackmoves_test.go index f6ac4145..8a2cfea6 100644 --- a/internal/engine/callbackmoves_test.go +++ b/internal/engine/callbackmoves_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // callbackBattle sets up Snorlax vs Snorlax with a three-deep bench on side 1, diff --git a/internal/engine/calledmoves.go b/internal/engine/calledmoves.go index ff7a7ad0..b629a0c1 100644 --- a/internal/engine/calledmoves.go +++ b/internal/engine/calledmoves.go @@ -3,7 +3,7 @@ package engine import ( "sort" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // calledmoves.go implements the moves whose whole content is "resolve some diff --git a/internal/engine/calledmoves_behavior_test.go b/internal/engine/calledmoves_behavior_test.go index bd166d79..65e38e69 100644 --- a/internal/engine/calledmoves_behavior_test.go +++ b/internal/engine/calledmoves_behavior_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // calledmoves_behavior_test.go covers the moves that resolve as some other diff --git a/internal/engine/clauses.go b/internal/engine/clauses.go index e1fd772c..b28947ac 100644 --- a/internal/engine/clauses.go +++ b/internal/engine/clauses.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // clauses.go is the format layer: the rules that make a match a *format* diff --git a/internal/engine/clauses_test.go b/internal/engine/clauses_test.go index b63e2107..ac03f3f6 100644 --- a/internal/engine/clauses_test.go +++ b/internal/engine/clauses_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // picksWithMove returns a legal six-mon team whose first slot actually learns diff --git a/internal/engine/coverage.go b/internal/engine/coverage.go index 10c3b66c..1f02f500 100644 --- a/internal/engine/coverage.go +++ b/internal/engine/coverage.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/specs" ) // The vocabularies the audit checks against (flags, volatiles, side diff --git a/internal/engine/coverage_test.go b/internal/engine/coverage_test.go index 69e0d9de..f4b7949f 100644 --- a/internal/engine/coverage_test.go +++ b/internal/engine/coverage_test.go @@ -7,7 +7,7 @@ import ( "os" "testing" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/specs" ) // TestSpecsRegistriesPopulated guards against a silent drop in the init() diff --git a/internal/engine/damage.go b/internal/engine/damage.go index fe891382..d22956c5 100644 --- a/internal/engine/damage.go +++ b/internal/engine/damage.go @@ -1,7 +1,7 @@ package engine import ( - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // Level is the fixed level every Pokémon battles at. A single level keeps diff --git a/internal/engine/drainvolatiles.go b/internal/engine/drainvolatiles.go index 993a0635..76a7c675 100644 --- a/internal/engine/drainvolatiles.go +++ b/internal/engine/drainvolatiles.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // drainvolatiles.go owns the residual-heal / residual-drain volatiles: diff --git a/internal/engine/effects.go b/internal/engine/effects.go index 12c1fb85..2f7c498e 100644 --- a/internal/engine/effects.go +++ b/internal/engine/effects.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // volatileHandler is the contract a mechanic file fulfills to claim a diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index f602e95b..4401f137 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) func loadDex(t *testing.T) *domain.Dex { diff --git a/internal/engine/fullgame_integration_test.go b/internal/engine/fullgame_integration_test.go index 9b409c23..f42bf421 100644 --- a/internal/engine/fullgame_integration_test.go +++ b/internal/engine/fullgame_integration_test.go @@ -12,7 +12,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // fullgame_integration_test.go plays complete battles end to end and audits diff --git a/internal/engine/futuresight_behavior_test.go b/internal/engine/futuresight_behavior_test.go index f7c2627b..75e0df05 100644 --- a/internal/engine/futuresight_behavior_test.go +++ b/internal/engine/futuresight_behavior_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // futuresight_behavior_test.go covers the one move in this dataset whose damage diff --git a/internal/engine/gender_test.go b/internal/engine/gender_test.go index 2d6f1363..f32ae4c0 100644 --- a/internal/engine/gender_test.go +++ b/internal/engine/gender_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // Dex numbers used below: Snorlax (both genders, 87.5% male), Nidoqueen diff --git a/internal/engine/gimmicks.go b/internal/engine/gimmicks.go index f786c6c9..d8a5a951 100644 --- a/internal/engine/gimmicks.go +++ b/internal/engine/gimmicks.go @@ -4,8 +4,8 @@ import ( "fmt" "math" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // gimmicks.go owns eight one-offs that don't cluster with any of the diff --git a/internal/engine/guards.go b/internal/engine/guards.go index 3580bc83..55a61515 100644 --- a/internal/engine/guards.go +++ b/internal/engine/guards.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/specs" ) // guards.go owns Quick Guard and Wide Guard — the doubles-flavored diff --git a/internal/engine/hazards.go b/internal/engine/hazards.go index f30d4a1f..4ae2b6b1 100644 --- a/internal/engine/hazards.go +++ b/internal/engine/hazards.go @@ -4,8 +4,8 @@ import ( "fmt" "sort" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // HazardKind identifies an entry-hazard side condition. Empty means none; diff --git a/internal/engine/hazards_behavior_test.go b/internal/engine/hazards_behavior_test.go index 58ef7940..436522a7 100644 --- a/internal/engine/hazards_behavior_test.go +++ b/internal/engine/hazards_behavior_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // hazards_behavior_test.go plays real battles through the public entry diff --git a/internal/engine/healblock_test.go b/internal/engine/healblock_test.go index 0c3091d7..cb6d12b0 100644 --- a/internal/engine/healblock_test.go +++ b/internal/engine/healblock_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // healblock_test.go covers the Heal Block volatile, which arrives in this diff --git a/internal/engine/historypower_test.go b/internal/engine/historypower_test.go index 90b3a5a2..f74a8302 100644 --- a/internal/engine/historypower_test.go +++ b/internal/engine/historypower_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // historypower_test.go covers the moves whose power depends on something that diff --git a/internal/engine/itemcoverage.go b/internal/engine/itemcoverage.go index 4fe8c962..9be7d057 100644 --- a/internal/engine/itemcoverage.go +++ b/internal/engine/itemcoverage.go @@ -3,7 +3,7 @@ package engine import ( "sort" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // itemcoverage.go is the held-item analog of coverage.go's move audit. Where diff --git a/internal/engine/items.go b/internal/engine/items.go index 35054339..677fbf38 100644 --- a/internal/engine/items.go +++ b/internal/engine/items.go @@ -5,7 +5,7 @@ import ( "math" "sort" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items.go is the held-item layer. It mirrors the ability system (abilities.go): diff --git a/internal/engine/items_berries.go b/internal/engine/items_berries.go index c694eb72..a97bcaf1 100644 --- a/internal/engine/items_berries.go +++ b/internal/engine/items_berries.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_berries.go is the consumable-item family: Berries (plus Berry Juice, diff --git a/internal/engine/items_berries_test.go b/internal/engine/items_berries_test.go index 5a7455a7..768bc906 100644 --- a/internal/engine/items_berries_test.go +++ b/internal/engine/items_berries_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_berries_test.go covers the consumable-item family. Every test asserts diff --git a/internal/engine/items_core.go b/internal/engine/items_core.go index 925f497e..76c2f589 100644 --- a/internal/engine/items_core.go +++ b/internal/engine/items_core.go @@ -1,6 +1,6 @@ package engine -import "pokearena/internal/domain" +import "github.com/shaumik/PokeArena/internal/domain" // items_core.go holds the original curated six: the always-on stat and damage // modifiers that shipped with the item scaffold. Their hooks are the ones the diff --git a/internal/engine/items_field.go b/internal/engine/items_field.go index 755d28ca..33e7af3a 100644 --- a/internal/engine/items_field.go +++ b/internal/engine/items_field.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // Not here, deliberately: Ability Shield. Nothing in this engine can suppress diff --git a/internal/engine/items_fling.go b/internal/engine/items_fling.go index 629c304d..f1e660b0 100644 --- a/internal/engine/items_fling.go +++ b/internal/engine/items_fling.go @@ -1,6 +1,6 @@ package engine -import "pokearena/internal/domain" +import "github.com/shaumik/PokeArena/internal/domain" // --- Fling and Natural Gift data --- // diff --git a/internal/engine/items_integration_test.go b/internal/engine/items_integration_test.go index c4988e68..f2f10865 100644 --- a/internal/engine/items_integration_test.go +++ b/internal/engine/items_integration_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_integration_test.go plays real battles to completion with items held, diff --git a/internal/engine/items_modifiers.go b/internal/engine/items_modifiers.go index 8b6711fb..31b33c77 100644 --- a/internal/engine/items_modifiers.go +++ b/internal/engine/items_modifiers.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_modifiers.go is the always-on family: items that change a number every diff --git a/internal/engine/items_modifiers_test.go b/internal/engine/items_modifiers_test.go index 56049dba..b73d66a3 100644 --- a/internal/engine/items_modifiers_test.go +++ b/internal/engine/items_modifiers_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_modifiers_test.go covers the always-on family. The shape of the risk is diff --git a/internal/engine/items_moves.go b/internal/engine/items_moves.go index 2df6e6f9..c2823ac8 100644 --- a/internal/engine/items_moves.go +++ b/internal/engine/items_moves.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_moves.go is the item-manipulation move family: the moves whose whole diff --git a/internal/engine/items_moves_behavior_test.go b/internal/engine/items_moves_behavior_test.go index 15bedf0b..42153ff5 100644 --- a/internal/engine/items_moves_behavior_test.go +++ b/internal/engine/items_moves_behavior_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_moves_behavior_test.go pins the item-manipulation move family from the diff --git a/internal/engine/items_moves_fling_test.go b/internal/engine/items_moves_fling_test.go index 355d4f91..0d1cfa8d 100644 --- a/internal/engine/items_moves_fling_test.go +++ b/internal/engine/items_moves_fling_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_moves_fling_test.go covers the rider a thrown item carries beyond its diff --git a/internal/engine/items_moves_test.go b/internal/engine/items_moves_test.go index 01d4cb24..bd835005 100644 --- a/internal/engine/items_moves_test.go +++ b/internal/engine/items_moves_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_moves_test.go covers the item-manipulation move family. Every case here diff --git a/internal/engine/items_reactive.go b/internal/engine/items_reactive.go index 1c59975d..c9f398d6 100644 --- a/internal/engine/items_reactive.go +++ b/internal/engine/items_reactive.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // items_reactive.go is the fourth item family: things that answer an *event* diff --git a/internal/engine/items_test.go b/internal/engine/items_test.go index 1529394c..6b496cd0 100644 --- a/internal/engine/items_test.go +++ b/internal/engine/items_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // leftoversBattle sets up a 1v1 where side 0 holds Leftovers and both sides diff --git a/internal/engine/lockedmove.go b/internal/engine/lockedmove.go index e11f6f09..f082041c 100644 --- a/internal/engine/lockedmove.go +++ b/internal/engine/lockedmove.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/specs" ) func init() { diff --git a/internal/engine/lockon_behavior_test.go b/internal/engine/lockon_behavior_test.go index bbb1c6aa..fd961809 100644 --- a/internal/engine/lockon_behavior_test.go +++ b/internal/engine/lockon_behavior_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // lockon_behavior_test.go covers Lock-On and Mind Reader — one mechanic diff --git a/internal/engine/lockrestrict.go b/internal/engine/lockrestrict.go index 9a386e98..34da63ca 100644 --- a/internal/engine/lockrestrict.go +++ b/internal/engine/lockrestrict.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // lockrestrict.go owns the volatiles that restrict which move the holder diff --git a/internal/engine/mechanics_test.go b/internal/engine/mechanics_test.go index c3ad3d7c..3872a04e 100644 --- a/internal/engine/mechanics_test.go +++ b/internal/engine/mechanics_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // TestAccStageMultiplier checks the Gen 3+ accuracy/evasion curve, distinct diff --git a/internal/engine/move_inert_test.go b/internal/engine/move_inert_test.go index ece94b12..e27b2201 100644 --- a/internal/engine/move_inert_test.go +++ b/internal/engine/move_inert_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // move_inert_test.go is the audit for one defect class: a curated move that diff --git a/internal/engine/movegaps_behavior_test.go b/internal/engine/movegaps_behavior_test.go index 9807f6fd..910534b1 100644 --- a/internal/engine/movegaps_behavior_test.go +++ b/internal/engine/movegaps_behavior_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // movegaps_behavior_test.go covers ten moves that shipped with a mechanic diff --git a/internal/engine/powder_test.go b/internal/engine/powder_test.go index 78af32ec..8a2d89ef 100644 --- a/internal/engine/powder_test.go +++ b/internal/engine/powder_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // powderLands runs one powder move from side 0 into side 1 and reports diff --git a/internal/engine/protect.go b/internal/engine/protect.go index 392f04b6..22e27a12 100644 --- a/internal/engine/protect.go +++ b/internal/engine/protect.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) func init() { diff --git a/internal/engine/pseudoweather.go b/internal/engine/pseudoweather.go index f7884e62..0ba8a8b3 100644 --- a/internal/engine/pseudoweather.go +++ b/internal/engine/pseudoweather.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // pseudoweather.go owns the field-wide non-weather conditions: Trick diff --git a/internal/engine/pursuit_pivot_test.go b/internal/engine/pursuit_pivot_test.go index 12985cca..cb8ad677 100644 --- a/internal/engine/pursuit_pivot_test.go +++ b/internal/engine/pursuit_pivot_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // pursuit_pivot_test.go covers Pursuit's second interception site: a pivot move diff --git a/internal/engine/roost.go b/internal/engine/roost.go index f281be0b..b128cf1e 100644 --- a/internal/engine/roost.go +++ b/internal/engine/roost.go @@ -1,6 +1,6 @@ package engine -import "pokearena/internal/domain" +import "github.com/shaumik/PokeArena/internal/domain" // roost.go lifts Roost's defining side effect: while the user is roosting it // loses its Flying type for the rest of the turn. The 50% self-heal already diff --git a/internal/engine/screens.go b/internal/engine/screens.go index 7fbd3a2d..e130a676 100644 --- a/internal/engine/screens.go +++ b/internal/engine/screens.go @@ -1,8 +1,8 @@ package engine import ( - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // ScreenKind identifies a per-side damage-reducing condition. The empty diff --git a/internal/engine/selfsecondary_test.go b/internal/engine/selfsecondary_test.go index 86c83e57..7e989c36 100644 --- a/internal/engine/selfsecondary_test.go +++ b/internal/engine/selfsecondary_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // selfBoost is a synthetic 100%-chance secondary aimed at the user, so the diff --git a/internal/engine/selfswitch_target_test.go b/internal/engine/selfswitch_target_test.go index 81c91cc4..1ac1c0fa 100644 --- a/internal/engine/selfswitch_target_test.go +++ b/internal/engine/selfswitch_target_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) func slotPtr(i int) *int { return &i } diff --git a/internal/engine/showdown/harness_selftest_test.go b/internal/engine/showdown/harness_selftest_test.go index 8aac0be3..79e877f9 100644 --- a/internal/engine/showdown/harness_selftest_test.go +++ b/internal/engine/showdown/harness_selftest_test.go @@ -15,8 +15,8 @@ import ( "strings" "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // harness_selftest_test.go proves the instrument before anybody trusts a diff --git a/internal/engine/showdown/harness_test.go b/internal/engine/showdown/harness_test.go index 44d99f81..95c1530e 100644 --- a/internal/engine/showdown/harness_test.go +++ b/internal/engine/showdown/harness_test.go @@ -14,8 +14,8 @@ import ( "sync" "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // harness_test.go is the vocabulary the ports are written in. It exists so a diff --git a/internal/engine/showdown/misc_prng_test.go b/internal/engine/showdown/misc_prng_test.go index 18bdc4c7..eab5a269 100644 --- a/internal/engine/showdown/misc_prng_test.go +++ b/internal/engine/showdown/misc_prng_test.go @@ -5,7 +5,7 @@ package showdown import ( "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // Ported from test/sim/misc/prng.js. diff --git a/internal/engine/showdown/names_test.go b/internal/engine/showdown/names_test.go index 9c6444a4..ed90419c 100644 --- a/internal/engine/showdown/names_test.go +++ b/internal/engine/showdown/names_test.go @@ -9,7 +9,7 @@ import ( "strings" "sync" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // names_test.go is the translation layer between Showdown's vocabulary and diff --git a/internal/engine/skydrop.go b/internal/engine/skydrop.go index be1a876f..0a18c4ab 100644 --- a/internal/engine/skydrop.go +++ b/internal/engine/skydrop.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // skydrop.go implements Sky Drop: the two-turn move that takes its target with diff --git a/internal/engine/slotconditions.go b/internal/engine/slotconditions.go index 4652ce59..c0b77a2d 100644 --- a/internal/engine/slotconditions.go +++ b/internal/engine/slotconditions.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // slotconditions.go owns the per-side "slot conditions" — state that diff --git a/internal/engine/spread_test.go b/internal/engine/spread_test.go index fe384bdf..1aa08865 100644 --- a/internal/engine/spread_test.go +++ b/internal/engine/spread_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // snorlax is a convenient stat stick: base Atk 110, base HP 160, base Spe 30. diff --git a/internal/engine/state.go b/internal/engine/state.go index a04cb300..055f6e17 100644 --- a/internal/engine/state.go +++ b/internal/engine/state.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // faint marks p as fainted and wipes its transient state: HP to zero, diff --git a/internal/engine/statswapmoves_behavior_test.go b/internal/engine/statswapmoves_behavior_test.go index 5b43b257..6945e28f 100644 --- a/internal/engine/statswapmoves_behavior_test.go +++ b/internal/engine/statswapmoves_behavior_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // statswapmoves_behavior_test.go covers the twelve moves the inert audit found: diff --git a/internal/engine/statusvols.go b/internal/engine/statusvols.go index 041d9cbf..21e34aea 100644 --- a/internal/engine/statusvols.go +++ b/internal/engine/statusvols.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // statusvols.go owns the status-adjacent volatiles — each has its own diff --git a/internal/engine/statusvols_behavior_test.go b/internal/engine/statusvols_behavior_test.go index 61069035..2c659613 100644 --- a/internal/engine/statusvols_behavior_test.go +++ b/internal/engine/statusvols_behavior_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // statusvols_behavior_test.go plays the status-adjacent volatiles diff --git a/internal/engine/substitute.go b/internal/engine/substitute.go index 5dd0e9c8..2ec8f508 100644 --- a/internal/engine/substitute.go +++ b/internal/engine/substitute.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) func init() { diff --git a/internal/engine/substitute_behavior_test.go b/internal/engine/substitute_behavior_test.go index e2951409..b1375e79 100644 --- a/internal/engine/substitute_behavior_test.go +++ b/internal/engine/substitute_behavior_test.go @@ -4,7 +4,7 @@ import ( "fmt" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // substitute_behavior_test.go plays REAL BATTLES for a set of mechanics that diff --git a/internal/engine/switching.go b/internal/engine/switching.go index a53daf40..be68207b 100644 --- a/internal/engine/switching.go +++ b/internal/engine/switching.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // doSwitch brings in a teammate. Stat stages and volatiles reset on both the diff --git a/internal/engine/team_validation.go b/internal/engine/team_validation.go index 5e27c2ea..0b2cbc4f 100644 --- a/internal/engine/team_validation.go +++ b/internal/engine/team_validation.go @@ -3,7 +3,7 @@ package engine import ( "fmt" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // TeamPick is one slot in a submitted team: a species (by Pokédex number), diff --git a/internal/engine/team_validation_test.go b/internal/engine/team_validation_test.go index 65f95eb9..8bd0b0d7 100644 --- a/internal/engine/team_validation_test.go +++ b/internal/engine/team_validation_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // validPicks builds a 6-pick team straight from the first six species in diff --git a/internal/engine/terrain.go b/internal/engine/terrain.go index f965fcfc..8e415e4f 100644 --- a/internal/engine/terrain.go +++ b/internal/engine/terrain.go @@ -1,8 +1,8 @@ package engine import ( - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) func init() { diff --git a/internal/engine/trapping_release_test.go b/internal/engine/trapping_release_test.go index bcc3fbd6..1305cb22 100644 --- a/internal/engine/trapping_release_test.go +++ b/internal/engine/trapping_release_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // trapping_release_test.go covers the two rules a move-based trap has beyond diff --git a/internal/engine/turn.go b/internal/engine/turn.go index 867d0384..d8de91b6 100644 --- a/internal/engine/turn.go +++ b/internal/engine/turn.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // maxTurns caps a battle so two defensive teams cannot loop forever; at the diff --git a/internal/engine/twoturn_skip_test.go b/internal/engine/twoturn_skip_test.go index c475f617..38666514 100644 --- a/internal/engine/twoturn_skip_test.go +++ b/internal/engine/twoturn_skip_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // twoTurnBattle sets up Venusaur vs Snorlax with one move on each side, so a diff --git a/internal/engine/typechange.go b/internal/engine/typechange.go index a4348635..3a81ac66 100644 --- a/internal/engine/typechange.go +++ b/internal/engine/typechange.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // typechange.go lifts the moves that rewrite a Pokémon's typing mid-battle: diff --git a/internal/engine/volatiles.go b/internal/engine/volatiles.go index f6a149af..0d217f55 100644 --- a/internal/engine/volatiles.go +++ b/internal/engine/volatiles.go @@ -3,8 +3,8 @@ package engine import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) // volatiles.go owns the small move-inflicted volatiles that don't warrant diff --git a/internal/engine/weather.go b/internal/engine/weather.go index 5624c624..89d492db 100644 --- a/internal/engine/weather.go +++ b/internal/engine/weather.go @@ -1,8 +1,8 @@ package engine import ( - "pokearena/internal/domain" - "pokearena/internal/specs" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/specs" ) func init() { diff --git a/internal/engine/willcrit_test.go b/internal/engine/willcrit_test.go index 6de89367..2fea2c13 100644 --- a/internal/engine/willcrit_test.go +++ b/internal/engine/willcrit_test.go @@ -3,7 +3,7 @@ package engine import ( "testing" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // TestAlwaysCritLandsEveryTime: Frost Breath and Storm Throw always deal a diff --git a/internal/eval/game.go b/internal/eval/game.go index 2729313e..d09e10cd 100644 --- a/internal/eval/game.go +++ b/internal/eval/game.go @@ -17,10 +17,10 @@ import ( "hash/fnv" "time" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/usage" ) // usageReporter is the opt-in capability a model-backed agent implements to diff --git a/internal/eval/game_test.go b/internal/eval/game_test.go index 52303f71..bacecd28 100644 --- a/internal/eval/game_test.go +++ b/internal/eval/game_test.go @@ -5,9 +5,9 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) func loadDex(t *testing.T) *domain.Dex { diff --git a/internal/eval/items_integration_test.go b/internal/eval/items_integration_test.go index fd5847ba..c127d805 100644 --- a/internal/eval/items_integration_test.go +++ b/internal/eval/items_integration_test.go @@ -4,9 +4,9 @@ import ( "reflect" "testing" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // items_integration_test.go runs held items through the real driver: fog-of-war diff --git a/internal/eval/library.go b/internal/eval/library.go index d99223e0..dec836a4 100644 --- a/internal/eval/library.go +++ b/internal/eval/library.go @@ -5,8 +5,8 @@ import ( "fmt" "os" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // A team library is the benchmark's curated, versioned set of competitive diff --git a/internal/eval/library_test.go b/internal/eval/library_test.go index 6dc74d7b..1f585c3e 100644 --- a/internal/eval/library_test.go +++ b/internal/eval/library_test.go @@ -3,8 +3,8 @@ package eval import ( "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) const libraryPath = "../../data/benchmark-teams.json" diff --git a/internal/eval/main_test.go b/internal/eval/main_test.go index 5be50b95..6225f288 100644 --- a/internal/eval/main_test.go +++ b/internal/eval/main_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // main_test.go turns every test in this package into an invariant test. See diff --git a/internal/eval/match.go b/internal/eval/match.go index 52c1d696..6a9384a9 100644 --- a/internal/eval/match.go +++ b/internal/eval/match.go @@ -3,9 +3,9 @@ package eval import ( "fmt" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // sideSalt derives a distinct-but-deterministic seed for the side-1 agent so diff --git a/internal/eval/match_test.go b/internal/eval/match_test.go index 42ed4cc0..bc155ed7 100644 --- a/internal/eval/match_test.go +++ b/internal/eval/match_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "pokearena/internal/ai" + "github.com/shaumik/PokeArena/internal/ai" ) func randomC(name string) Contestant { diff --git a/internal/eval/provenance.go b/internal/eval/provenance.go index 8f1439de..1217688c 100644 --- a/internal/eval/provenance.go +++ b/internal/eval/provenance.go @@ -8,8 +8,8 @@ import ( "path/filepath" "runtime/debug" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // The run header is the first line of every JSONL trace. It pins everything a diff --git a/internal/eval/provenance_test.go b/internal/eval/provenance_test.go index 80cf6eb6..307e4b80 100644 --- a/internal/eval/provenance_test.go +++ b/internal/eval/provenance_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // TestLoadProvenance reads the shipped dataset identity and checks the fields diff --git a/internal/eval/replay.go b/internal/eval/replay.go index ce96513b..ed16346f 100644 --- a/internal/eval/replay.go +++ b/internal/eval/replay.go @@ -3,9 +3,9 @@ package eval import ( "fmt" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // A replay is a self-contained recording of one battle: a snapshot of the board diff --git a/internal/eval/replay_reconstruct.go b/internal/eval/replay_reconstruct.go index c0a29117..a85affed 100644 --- a/internal/eval/replay_reconstruct.go +++ b/internal/eval/replay_reconstruct.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // StoredTurn is one persisted turn of a live battle: the engine state after the diff --git a/internal/eval/replay_reconstruct_test.go b/internal/eval/replay_reconstruct_test.go index bdeb5afc..9070ba49 100644 --- a/internal/eval/replay_reconstruct_test.go +++ b/internal/eval/replay_reconstruct_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // TestReplayFromStored checks a persisted battle round-trips into a watchable diff --git a/internal/eval/replay_test.go b/internal/eval/replay_test.go index 39b6521e..b43555d5 100644 --- a/internal/eval/replay_test.go +++ b/internal/eval/replay_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "pokearena/internal/ai" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/usage" ) // TestRunGameCaptured_ProducesCoherentFrames plays a real deterministic game and diff --git a/internal/eval/report_test.go b/internal/eval/report_test.go index 874a1e11..fff765ec 100644 --- a/internal/eval/report_test.go +++ b/internal/eval/report_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // TestRenderHTMLReport checks the report is a self-contained page that carries diff --git a/internal/eval/results.go b/internal/eval/results.go index 9dcb1b8c..59ae3d8f 100644 --- a/internal/eval/results.go +++ b/internal/eval/results.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // A benchmark that only prints its numbers forgets them. This file makes a run diff --git a/internal/eval/results_test.go b/internal/eval/results_test.go index 23189f01..a2f88771 100644 --- a/internal/eval/results_test.go +++ b/internal/eval/results_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // mkMatch builds a synthetic two-contestant match with the given per-seat token diff --git a/internal/eval/team.go b/internal/eval/team.go index b33a842f..62496f83 100644 --- a/internal/eval/team.go +++ b/internal/eval/team.go @@ -3,8 +3,8 @@ package eval import ( "fmt" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) // PicksFromDex expands a list of dex numbers into a legal team of TeamPicks, diff --git a/internal/eval/tournament.go b/internal/eval/tournament.go index b09116dd..d6cf92fc 100644 --- a/internal/eval/tournament.go +++ b/internal/eval/tournament.go @@ -4,7 +4,7 @@ import ( "fmt" "sort" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/domain" ) // A team tournament measures team QUALITY, not policy. It cross-matches every diff --git a/internal/eval/tournament_test.go b/internal/eval/tournament_test.go index 8b5f462c..92da5cb0 100644 --- a/internal/eval/tournament_test.go +++ b/internal/eval/tournament_test.go @@ -3,7 +3,7 @@ package eval import ( "testing" - "pokearena/internal/ai" + "github.com/shaumik/PokeArena/internal/ai" ) // TestTeamTournament_Bookkeeping runs a small real tournament and checks the diff --git a/internal/eval/vsreference.go b/internal/eval/vsreference.go index 783721bd..f7bd8297 100644 --- a/internal/eval/vsreference.go +++ b/internal/eval/vsreference.go @@ -9,8 +9,8 @@ import ( "strconv" "strings" - "pokearena/internal/ai" - "pokearena/internal/domain" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" ) // The benchmark report reunites the two arms — the baseline round-robin and the diff --git a/internal/gwclient/gwclient.go b/internal/gwclient/gwclient.go index c3104da3..b76c53ca 100644 --- a/internal/gwclient/gwclient.go +++ b/internal/gwclient/gwclient.go @@ -13,7 +13,7 @@ import ( "net/url" "sync" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/protocol" "github.com/gorilla/websocket" ) diff --git a/internal/gwclient/gwclient_test.go b/internal/gwclient/gwclient_test.go index 1d355029..7053fb20 100644 --- a/internal/gwclient/gwclient_test.go +++ b/internal/gwclient/gwclient_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/protocol" "github.com/gorilla/websocket" ) diff --git a/internal/httpapi/hub.go b/internal/httpapi/hub.go index ef13310b..adde15f1 100644 --- a/internal/httpapi/hub.go +++ b/internal/httpapi/hub.go @@ -8,8 +8,8 @@ import ( "strings" "sync" - "pokearena/internal/messages" - "pokearena/internal/mq" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" ) // Event is a domain event delivered to a battle's watchers. diff --git a/internal/httpapi/hub_test.go b/internal/httpapi/hub_test.go index a68423fb..e05f3c7c 100644 --- a/internal/httpapi/hub_test.go +++ b/internal/httpapi/hub_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "pokearena/internal/messages" + "github.com/shaumik/PokeArena/internal/messages" ) // newBareHub builds a Hub with no event queue. Safe as long as the test only diff --git a/internal/httpapi/items_test.go b/internal/httpapi/items_test.go index d892907d..82c7d659 100644 --- a/internal/httpapi/items_test.go +++ b/internal/httpapi/items_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - "pokearena/internal/domain" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" ) func loadDexForItems(t *testing.T) *domain.Dex { diff --git a/internal/httpapi/live.go b/internal/httpapi/live.go index 8239f1c7..6d9349b0 100644 --- a/internal/httpapi/live.go +++ b/internal/httpapi/live.go @@ -3,7 +3,7 @@ package httpapi import ( "math/rand" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // pickAITeam draws a curated AI roster, seeded by the battle's seed so the same diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7a6bc118..f27a732d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -8,15 +8,15 @@ import ( "net/http" "sort" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/config" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/protocol" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/config" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/protocol" + "github.com/shaumik/PokeArena/internal/store" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go index 67875bb6..9a6cefc9 100644 --- a/internal/httpapi/sse.go +++ b/internal/httpapi/sse.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "pokearena/internal/messages" + "github.com/shaumik/PokeArena/internal/messages" "github.com/go-chi/chi/v5" ) diff --git a/internal/httpapi/ws.go b/internal/httpapi/ws.go index c5d6e4d2..312f0987 100644 --- a/internal/httpapi/ws.go +++ b/internal/httpapi/ws.go @@ -8,10 +8,10 @@ import ( "sync/atomic" "time" - "pokearena/internal/cache" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/protocol" "github.com/go-chi/chi/v5" "github.com/google/uuid" diff --git a/internal/livebattle/bridge.go b/internal/livebattle/bridge.go index 97340770..6db72fc9 100644 --- a/internal/livebattle/bridge.go +++ b/internal/livebattle/bridge.go @@ -3,7 +3,7 @@ package livebattle import ( "sync" - "pokearena/internal/messages" + "github.com/shaumik/PokeArena/internal/messages" ) // slotIndex maps a wire slot name ("p1"|"p2") to its 0/1 array position, or -1. diff --git a/internal/livebattle/bridge_test.go b/internal/livebattle/bridge_test.go index 67e29529..bd226a44 100644 --- a/internal/livebattle/bridge_test.go +++ b/internal/livebattle/bridge_test.go @@ -5,10 +5,10 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/protocol" ) // legalMoveFromView is a minimal honest client: it picks a legal action from the diff --git a/internal/livebattle/cancel_test.go b/internal/livebattle/cancel_test.go index 17775c87..26bbc2e2 100644 --- a/internal/livebattle/cancel_test.go +++ b/internal/livebattle/cancel_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/protocol" ) // TestMatch_RunStopsOnContextCancel pins the contract that the coordinator's diff --git a/internal/livebattle/coordinator.go b/internal/livebattle/coordinator.go index 7060ec04..38131428 100644 --- a/internal/livebattle/coordinator.go +++ b/internal/livebattle/coordinator.go @@ -8,10 +8,10 @@ import ( "log" "time" - "pokearena/internal/ai" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/protocol" ) // Run drives the match from the picker phase through a successful close (engine diff --git a/internal/livebattle/coordinator_test.go b/internal/livebattle/coordinator_test.go index d244d4c6..7e1968c7 100644 --- a/internal/livebattle/coordinator_test.go +++ b/internal/livebattle/coordinator_test.go @@ -8,11 +8,11 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/protocol" ) // --- in-memory host fakes --- diff --git a/internal/livebattle/livebattle.go b/internal/livebattle/livebattle.go index eae118de..4e7206f1 100644 --- a/internal/livebattle/livebattle.go +++ b/internal/livebattle/livebattle.go @@ -18,9 +18,9 @@ import ( "sync/atomic" "time" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/protocol" ) // DefaultRoomDeadline is the picker-room budget per docs/team-picker-room.md §7. diff --git a/internal/livebattle/main_test.go b/internal/livebattle/main_test.go index 9a782012..2d178b46 100644 --- a/internal/livebattle/main_test.go +++ b/internal/livebattle/main_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // main_test.go turns every test in this package into an invariant test. See diff --git a/internal/livebattle/resume_test.go b/internal/livebattle/resume_test.go index 155f794d..41ef9312 100644 --- a/internal/livebattle/resume_test.go +++ b/internal/livebattle/resume_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "pokearena/internal/engine" - "pokearena/internal/messages" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" ) // TestMatch_ResumeFromMidBattle proves the failover-takeover path: a battle is diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index 814c3490..0e969816 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -14,7 +14,7 @@ import ( "strings" "time" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // defaultMaxTokens is the output cap for a plain (no-thinking) decision. One diff --git a/internal/llm/anthropic_test.go b/internal/llm/anthropic_test.go index 5354f1ca..20251db6 100644 --- a/internal/llm/anthropic_test.go +++ b/internal/llm/anthropic_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // captureServer records the last request body the client sent and replies with diff --git a/internal/llm/client.go b/internal/llm/client.go index c91fa8bf..69562942 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // Client is the provider-agnostic decision boundary: given the static system diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 55b42361..aead7e6f 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // Gemini talks to the Generative Language API (generateContent). Third vendor diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go index 6adca48a..b4c50a05 100644 --- a/internal/llm/gemini_test.go +++ b/internal/llm/gemini_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // The cot Gemini client passes the thinking budget straight through, targets diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index c25df37c..eee01cd3 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -8,7 +8,7 @@ import ( "os" "time" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // Ollama talks to a local Ollama server's /api/chat. It is the open/local arm diff --git a/internal/llm/ollama_test.go b/internal/llm/ollama_test.go index b95f0bcd..4206c406 100644 --- a/internal/llm/ollama_test.go +++ b/internal/llm/ollama_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // The Ollama client posts to /api/chat with streaming off, maps max_tokens onto diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 52cbd5b7..d53a88b9 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -7,7 +7,7 @@ import ( "net/http" "time" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // OpenAI talks to the Chat Completions API. It is the second vendor behind the diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go index bfe098ef..9b4d57d9 100644 --- a/internal/llm/openai_test.go +++ b/internal/llm/openai_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "pokearena/internal/usage" + "github.com/shaumik/PokeArena/internal/usage" ) // The raw (no-thinking) OpenAI client sends max_completion_tokens, omits diff --git a/internal/mcpserver/session.go b/internal/mcpserver/session.go index 2a1ac90d..8f7d6d23 100644 --- a/internal/mcpserver/session.go +++ b/internal/mcpserver/session.go @@ -8,10 +8,10 @@ import ( "sync" "time" - "pokearena/internal/ai" - "pokearena/internal/engine" - "pokearena/internal/gwclient" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/gwclient" + "github.com/shaumik/PokeArena/internal/protocol" ) // viewWire renders a typed ai.View as a generic JSON object. It is the diff --git a/internal/mcpserver/session_test.go b/internal/mcpserver/session_test.go index 24967533..017b2969 100644 --- a/internal/mcpserver/session_test.go +++ b/internal/mcpserver/session_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/engine" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/protocol" "github.com/gorilla/websocket" ) diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index c5ad2aff..6122a818 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/internal/mcpserver/viewwire_test.go b/internal/mcpserver/viewwire_test.go index ef8668e3..124822f2 100644 --- a/internal/mcpserver/viewwire_test.go +++ b/internal/mcpserver/viewwire_test.go @@ -4,10 +4,10 @@ import ( "encoding/json" "testing" - "pokearena/internal/ai" - "pokearena/internal/engine" - "pokearena/internal/gwclient" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/gwclient" + "github.com/shaumik/PokeArena/internal/protocol" ) // TestViewWire_RedactsFoeSoSchemaCannotRequireHiddenFields is the regression diff --git a/internal/messages/messages.go b/internal/messages/messages.go index 9b8d9015..b358ffb2 100644 --- a/internal/messages/messages.go +++ b/internal/messages/messages.go @@ -4,7 +4,7 @@ package messages import ( - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/engine" ) // RabbitMQ topology. diff --git a/internal/mq/live.go b/internal/mq/live.go index 48ffe47a..c0e84f3d 100644 --- a/internal/mq/live.go +++ b/internal/mq/live.go @@ -5,8 +5,8 @@ import ( "encoding/json" "time" - "pokearena/internal/messages" - "pokearena/internal/protocol" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/protocol" amqp "github.com/rabbitmq/amqp091-go" ) diff --git a/internal/mq/mq.go b/internal/mq/mq.go index 105e9534..9d084b58 100644 --- a/internal/mq/mq.go +++ b/internal/mq/mq.go @@ -14,7 +14,7 @@ import ( "sync" "time" - "pokearena/internal/messages" + "github.com/shaumik/PokeArena/internal/messages" amqp "github.com/rabbitmq/amqp091-go" ) diff --git a/internal/protocol/pvp.go b/internal/protocol/pvp.go index a56de6c6..2e7ff788 100644 --- a/internal/protocol/pvp.go +++ b/internal/protocol/pvp.go @@ -11,8 +11,8 @@ package protocol import ( "encoding/json" - "pokearena/internal/ai" - "pokearena/internal/engine" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" ) // Frame types sent server → client. The MatchUpdate.Type field carries diff --git a/internal/session/abandon_test.go b/internal/session/abandon_test.go index db435b7d..c49fa970 100644 --- a/internal/session/abandon_test.go +++ b/internal/session/abandon_test.go @@ -19,13 +19,13 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" "github.com/google/uuid" ) diff --git a/internal/session/concurrent_test.go b/internal/session/concurrent_test.go index 6c3daaa8..720f15f3 100644 --- a/internal/session/concurrent_test.go +++ b/internal/session/concurrent_test.go @@ -15,14 +15,14 @@ import ( "github.com/google/uuid" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/httpapi" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/httpapi" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" ) // startPvP wires one live_pvp battle through the given gateways and drives both diff --git a/internal/session/distribution_test.go b/internal/session/distribution_test.go index 020351f2..41e2d03e 100644 --- a/internal/session/distribution_test.go +++ b/internal/session/distribution_test.go @@ -23,16 +23,16 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/httpapi" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/protocol" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/httpapi" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/protocol" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" "github.com/google/uuid" ) diff --git a/internal/session/failover_test.go b/internal/session/failover_test.go index d15a314a..2fee0d73 100644 --- a/internal/session/failover_test.go +++ b/internal/session/failover_test.go @@ -11,12 +11,12 @@ import ( "testing" "time" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" "github.com/google/uuid" ) diff --git a/internal/session/latejoin_test.go b/internal/session/latejoin_test.go index cf81e3e9..93a4bcf9 100644 --- a/internal/session/latejoin_test.go +++ b/internal/session/latejoin_test.go @@ -16,12 +16,12 @@ import ( "github.com/google/uuid" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" ) func TestSpectator_LateJoinStillSeesCompletion(t *testing.T) { diff --git a/internal/session/liveai_test.go b/internal/session/liveai_test.go index 850125b2..6278657f 100644 --- a/internal/session/liveai_test.go +++ b/internal/session/liveai_test.go @@ -16,12 +16,12 @@ import ( "github.com/google/uuid" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" ) // legalAIDecider plays the in-process AI side with the engine's first legal diff --git a/internal/session/pickerleave_test.go b/internal/session/pickerleave_test.go index b1e46675..e3da4687 100644 --- a/internal/session/pickerleave_test.go +++ b/internal/session/pickerleave_test.go @@ -17,13 +17,13 @@ import ( "github.com/google/uuid" - "pokearena/internal/ai" - "pokearena/internal/cache" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" ) func TestPickerLeave_RoomAbandonedNotResurrected(t *testing.T) { diff --git a/internal/session/reconnect_test.go b/internal/session/reconnect_test.go index 5136e657..7402dc3d 100644 --- a/internal/session/reconnect_test.go +++ b/internal/session/reconnect_test.go @@ -24,13 +24,13 @@ import ( "github.com/google/uuid" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" ) // publishConn announces a connection-scoped phase (attach/disconnect) for a slot. diff --git a/internal/session/session.go b/internal/session/session.go index c693b643..8cacad57 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -18,14 +18,14 @@ import ( "sync" "time" - "pokearena/internal/cache" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/livebattle" - "pokearena/internal/messages" - "pokearena/internal/mq" - "pokearena/internal/protocol" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/cache" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/livebattle" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/mq" + "github.com/shaumik/PokeArena/internal/protocol" + "github.com/shaumik/PokeArena/internal/store" ) const ( diff --git a/internal/session/spectator_test.go b/internal/session/spectator_test.go index a6bbcfba..bf108bbc 100644 --- a/internal/session/spectator_test.go +++ b/internal/session/spectator_test.go @@ -18,13 +18,13 @@ import ( "github.com/google/uuid" - "pokearena/internal/ai" - "pokearena/internal/domain" - "pokearena/internal/engine" - "pokearena/internal/httpapi" - "pokearena/internal/messages" - "pokearena/internal/session" - "pokearena/internal/store" + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/httpapi" + "github.com/shaumik/PokeArena/internal/messages" + "github.com/shaumik/PokeArena/internal/session" + "github.com/shaumik/PokeArena/internal/store" ) // specWatch records the domain events a spectator receives. A slow watcher must diff --git a/internal/specs/specs.go b/internal/specs/specs.go index 4838b659..0d2c2b59 100644 --- a/internal/specs/specs.go +++ b/internal/specs/specs.go @@ -11,7 +11,7 @@ // // Because population happens at engine package init, any binary that // consumes specs must also import internal/engine (even if only for side -// effects: `_ "pokearena/internal/engine"`). cmd/data-validate and +// effects: `_ "github.com/shaumik/PokeArena/internal/engine"`). cmd/data-validate and // cmd/data-sync do exactly that — their validation work runs after engine // init has populated the registries. package specs From 5caa7fb4317c61a08991a4950d8329beafb082ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:28:01 +0000 Subject: [PATCH 02/12] docs: lead with the reproducibility claim, not the leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README opened on the arena and its leaderboard — which the same README then admits is unverified, keyed on free-text names with no ownership. We were leading with the weakest asset on the page. The strongest one was buried in benchmark.md section 2: the engine is ours rather than a Showdown wrapper, which buys variance-controlled mirror matches. Same seed, same team, both sides, byte-identical RNG stream, so the only free variable is the policy. That is the claim no Showdown-wrapping harness can structurally make, so it is now the headline. The first runnable block was 'docker compose up' — Postgres, RabbitMQ, Redis and four services. An agent evaluating the repo in a sandbox cannot run that and silently gives up. cmd/bench needs none of it, so it goes first; the full arena keeps its section further down. Add AGENTS.md for the agent doing the discovering: the no-infra path, what does and does not need services, the MCP tool surface, and the observation and action shapes. The heading is now 'Connect your agent (Pv-Agent)', which repairs the anchor ARCHITECTURE.md and mcp-protocol.md were already linking to. --- AGENTS.md | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 278 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 530 insertions(+), 71 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1a1584ac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,323 @@ +# AGENTS.md — PokéArena for coding agents + +Read this first if you are an agent (Claude Code, Cursor, Codex, …) deciding +whether this repo is usable. Short answer: **yes, with zero infrastructure**, as +long as you stay on the benchmark/engine path. The browser arena is the only +part that needs services. + +Module path: `github.com/shaumik/PokeArena` (Go 1.26). License: MIT. + +--- + +## What this is, in one paragraph + +PokéArena is a **deterministic Pokémon battle engine** plus an **LLM-agent +benchmark** and an **arena** built on top of it. The engine is a pure function — +`(state, actionP1, actionP2) → (newState, events)`, no I/O — so a battle is +replayable bit-for-bit from its turn log. It is **not** a wrapper around Pokémon +Showdown, which is the whole point: because the RNG stream is ours and seeded, +the benchmark can run **variance-controlled mirror matches** (same seed, same +team, both sides, both seat orientations), so the only free variable in a result +is the policy. It is a two-player, turn-based, **hidden-information (fog-of-war) +multi-agent environment**: each side sees its own team in full and only a +redacted view of the opponent's active Pokémon. Agents can plug in as an +in-process Go `ai.Agent`, over a WebSocket, or through an **MCP server**. + +--- + +## Fastest verified path to a result (no services, no API key) + +```bash +git clone https://github.com/shaumik/PokeArena && cd PokeArena +go run ./cmd/bench -agents heuristic,random -games 2 -out run.jsonl -runs "" +``` + +**Time:** a few seconds after the first compile. **Needs:** a Go toolchain. No +Postgres, no Redis, no RabbitMQ, no Docker, no network, no model key. + +**Expected output** (verbatim — this is a real run): + +``` +[bench] round-robin: 2 contestants, 1 pairings x 6 teams, 2 seeds x2 orientations = 4 games/team, 24 total + +per-team Elo: + Genesis heuristic 1676 random 1324 + Spectrum heuristic 1676 random 1324 + Keystone heuristic 1676 random 1324 + Bruiser heuristic 1676 random 1324 + Bastion heuristic 1676 random 1324 + Blitz heuristic 1676 random 1324 + +overall standings (Elo, win rate with Wilson 95% CI): + agent elo winrate 95% CI W-L-D + heuristic 1804 100.0% [ 86.2%, 100.0%] 24-0-0 (n=24) + random 1196 0.0% [ 0.0%, 13.8%] 0-24-0 (n=24) +``` + +Standings go to **stderr**; the per-decision JSONL trace goes to `-out` (or +stdout if you omit it — that's hundreds of lines, so pass `-out`). `-runs ""` +suppresses the persisted run record; drop it to save `runs/.json` and +append `runs/index.jsonl`. + +Bigger, still infrastructure-free (~1 minute for 240 games): + +```bash +go run ./cmd/bench -agents heuristic,expectimax -games 20 -out run.jsonl +``` + +Add LLM contestants (needs the relevant `_API_KEY`; `ollama` needs none): + +```bash +go run ./cmd/bench -agents heuristic \ + -llm 'haiku=claude-haiku-4-5-20251001,openai:gpt-5/cot,ollama:llama3.1:8b' \ + -games 10 -out run.jsonl +``` + +### `cmd/bench` flags (from `cmd/bench/main.go`) + +| Flag | Default | Meaning | +|---|---|---| +| `-agents` | `random,heuristic,expectimax` | programmatic contestants; `expectimax@N` pins a depth as a distinct contestant | +| `-llm` | — | LLM contestants, `[label=][vendor:]model[/condition]`; vendor ∈ `anthropic` (default), `openai`, `gemini`, `ollama`; condition `raw` (default) or `cot` | +| `-games` | `20` | seeds per pairing per team, each played in both orientations | +| `-teams` | `data/benchmark-teams.json` | curated team library; every team is mirror-matched | +| `-team` | — | ad-hoc override: comma-separated dex numbers, mirrored to both sides | +| `-depth` | `2` | fixed search depth for expectimax | +| `-budget-ms` | `0` | per-decision time budget (recommended for LLM agents) | +| `-out` | stdout | JSONL trace path | +| `-runs` | `runs` | run-record dir (`""` disables) | +| `-pricing` | `data/model-pricing.json` | price table for costing measured token usage | +| `-cot-budget` | `2048` | thinking-token budget for `/cot` contestants | +| `-data` | `data` | dataset directory, **read from disk** | + +> `-data` is why `go run github.com/shaumik/PokeArena/cmd/bench@latest` does not +> work from an arbitrary directory: the dataset is loaded from `data/` on disk, +> not embedded in this binary. Clone the repo (or point `-data` at a checkout). + +Reproducibility: deterministic contestants on the same agents/teams/seeds give +byte-identical games — same winners, same turn counts, same per-decision +`state_hash`. Every trace opens with a run header pinning engine revision, +dataset sim-version + curation SHA, ruleset, `team_library`, `team_profile`, +contestants, depth and seeds. + +--- + +## What needs the full stack, and what does not + +| Thing | Command | Postgres / Redis / RabbitMQ? | +|---|---|---| +| Engine + AI unit tests | `make test` | **No** | +| Build everything | `go build ./...` | **No** | +| Benchmark round-robin | `go run ./cmd/bench …` | **No** | +| LLM contestants in the benchmark | `go run ./cmd/bench -llm …` | **No** (needs a vendor API key, or local Ollama) | +| Benchmark report / history | `go run ./cmd/bench-report`, `./cmd/bench-history` | **No** (reads persisted run JSON) | +| Team legality + cross-team balance | `go run ./cmd/team-validate` | **No** | +| Spread-impact measurement | `go run ./cmd/spread-impact` | **No** | +| File-backed 2-agent match broker | `go run ./cmd/royale …` | **No** | +| Dataset validation | `make validate-data` | **No** | +| Python environment (`python/`) | `pip install pokearena` | **No** | +| Browser arena / team builder UI | `docker compose up --build` | **Yes** | +| Live PvP, spectating, MCP against a live battle | `docker compose up --build` + `make mcp` | **Yes** | +| Elo leaderboard (arena side) | `docker compose up --build` | **Yes** | +| Integration tests | `make test-integration` | **Yes** (brings its own stack up) | + +The stack is Postgres (system of record), Redis (live state + cache), RabbitMQ +(work + events), and five Go services: `gateway`, `battle-worker`, +`battle-session`, `ai-service`, `leaderboard-worker`. + +### Two more zero-infrastructure paths + +- **`cmd/royale`** — a file-backed, two-seat match director. Two independent + agent processes play a full battle against the real engine with no server, no + WebSocket and no shared memory; `state.json` is the only source of truth and + each seat reaches it through `royale view --id M --slot p1 --wait` and + `royale act --id M --slot p1 --action move:0`. `view` renders the engine's own + fog-of-war projection, so a player agent cannot see the opponent's bench even + by accident. Referee commands (`log`, `report`, `state`) are gated behind a + judge token. +- **`internal/eval`** — the harness `cmd/bench` is built from, if you want to + drive matches from Go directly (`RunMatch`, `SeedRange`, `Contestant`). + +--- + +## The MCP tool surface + +`cmd/pokearena-mcp` is a **stdio MCP server that runs on your machine** and +bridges tool calls to the gateway WebSocket. It needs a running gateway (see the +stack table above). + +```bash +go build -o bin/pokearena-mcp ./cmd/pokearena-mcp # or: make mcp +claude mcp add pokearena -- "$(pwd)/bin/pokearena-mcp" +claude mcp list # should list "pokearena" +``` + +Gateway address comes from `POKEARENA_GATEWAY_URL` (default `ws://localhost:8080`; +use `wss://` for TLS). For a non-Claude MCP client, register the binary as the +command with that env var set. + +Ten tools (`internal/mcpserver/tools.go`): + +| Tool | Purpose | +|---|---| +| `join_battle(battle_id, slot, join_token)` | Bind the session to a battle and get the initial view. Call first — everything else requires it. For a live vs-AI battle pass only `battle_id` (you are seated p1); for PvP pass `slot` + `join_token`. | +| `submit_team(picks)` | Required if `join_battle` returns `phase: "open"`. Exactly 6 picks: `{dex_no, moves[1-4], ability?, item?, nature?, evs?, ivs?}`. IDs are kebab-case (`body-slam`, `choice-band`). Omitting the spread is legal (no EVs, 31 IVs, no nature). | +| `wait(timeout_seconds=60)` | The loop primitive. Blocks until it's your turn / the battle ends / timeout. Clamped to `[1,120]`. Returns `{ready, terminal?, view?}`. | +| `view()` | Non-blocking current fog-of-war view. Prefer `wait` between turns. | +| `act(kind, index)` | Submit the turn's action. `kind` is `"move"` (index 0–3) or `"switch"` (team slot 0–5). | +| `leave_battle()` | Close the session. A forfeit if the battle is live. | +| `find_pokemon(query)` | Substring search of the curated dex. Returns `{dex_no, name, type1, type2}`, capped at 30. | +| `get_pokemon(dex_no)` | Full species detail: base stats, ability slots, and the authoritative legal move list for `submit_team`. | +| `list_items()` | The held-item catalog. Any item is legal on any Pokémon, one per Pokémon. | +| `list_natures()` | The 25 natures plus the battle level and the EV/IV caps `submit_team` enforces. | + +Standard loop: `join_battle` → (`submit_team` if `phase == "open"`) → repeat +`wait` → `view`/inspect → `act` until `terminal: true` → `leave_battle`. +Contract details and error semantics: [docs/mcp-protocol.md](docs/mcp-protocol.md). + +`go run ./cmd/mcp-smoke` walks one full turn through the real binary with verbose +checkpoints (needs a running gateway). + +--- + +## Observation and action shape + +**Action** — one of two things per turn: + +```json +{"kind": "move", "index": 0} // move slot 0..3 of the active Pokémon +{"kind": "switch", "index": 3} // team slot 0..5 +``` + +Illegal actions are rejected by the gateway. In the benchmark harness an +unparseable or illegal LLM reply falls back to the first legal move and is +flagged in the trace as `fallback: true` — that legality-fallback rate is itself +a reported signal. + +**Observation** — `ai.View` (`internal/ai/agent.go`), the engine's fog-of-war +projection. Keys: + +| Key | Contents | +|---|---| +| `me` | side index you control | +| `self` | **your whole side, unredacted** — all six Pokémon, exact HP, stats, EVs/IVs/nature, moves with PP, items, abilities | +| `foe` | the opponent's **active Pokémon only**, redacted (below) | +| `foe_bench_alive` | count of unfainted benched opponents — a number, never their identities | +| `turn`, `phase`, `replace` | turn counter; engine phase; `replace: true` when you must replace a fainted active | +| `weather`, `terrain`, `pseudo_weather` | field state (public) | +| `foe_conditions`, `foe_slot_conditions` | the foe's side conditions (screens, hazards) and pending slot effects, with the Wish heal *amount* stripped | + +What the `foe` object **never** contains (`foeWire`, mirroring what Showdown +sends a player): exact `hp`/`max_hp` — you get `hp_pct` 0–100 instead; `stats`; +`evs`/`ivs`/`nature`; `ability` and `item` until each visibly activates (tracked +by `AbilityRevealed` / `ItemRevealed`, which never un-set); `last_consumed_item`; +and PP on revealed moves (revealed slots carry `move_id` only). A live Choice +lock is cleared too, since its presence would name the item. + +Fog of war is enforced by the **return type**, not by policy the agent is asked +to respect — the hidden bytes are never sent. Full contract: +[docs/battle-state.md](docs/battle-state.md) (§ *Hidden information*, § *Ability +and item fog of war*). + +--- + +## Repo layout + +``` +cmd/ one main package per binary + bench the benchmark round-robin ← start here + bench-report a saved run → self-contained HTML report + bench-history Elo/cost timeline across runs + pokearena-mcp stdio MCP server (user-side adapter) + pokearena-agent reference headless LLM harness (dials the gateway WS) + royale file-backed two-seat match broker, no server + gateway edge service: REST + WebSocket + SSE + static SPA + battle-worker quick-sim consumer battle-session live-battle owner + ai-service AI-decision consumer leaderboard-worker Elo consumer + team-validate / spread-impact / data-sync / data-validate / db-replay + mcp-smoke / pvp-smoke / showdown-triage (smoke + triage tools) + +internal/ + engine the battle engine — pure, deterministic, the bulk of the code + ai agent harness: Agent interface, random/heuristic/expectimax, + and MakeView — the fog-of-war projection + eval benchmark harness: RunMatch, seeds, Elo, Wilson CIs, run records + llm provider adapters (anthropic/openai/gemini/ollama) behind one iface + agentloop the one-shot LLM decision loop used by bench and pokearena-agent + mcpserver the MCP tool surface + session + domain static reference data loading (dex, moves, items, natures, types) + specs the engine's slug vocabulary + protocol gateway↔client wire shapes + gwclient thin WS client to the gateway's live_pvp path + httpapi, livebattle, session, store, cache, mq, messages, config, usage + +data/ curated dataset: pokedex.json (80 species), moves.json, + typechart.json, items.json, natures.json (25), + benchmark-teams.json (6 mirror-match teams), ai-teams.json, + model-pricing.json, _provenance.json +docs/ architecture, benchmark scope/limits, MCP protocol, battle-state + contract, live-PvP protocol, engine findings +web/ the SPA served by the gateway +royale/ tournament runbook, team files, report generator (Python) +scripts/bench live agentic-harness comparison scripts +tools/ data-sync helpers (Node) +``` + +--- + +## Build, test, lint + +```bash +go build ./... # everything +make test # go test ./... -count=1 + royale/test_report.py (python3) +make vet # go vet, including the showdown build tag +make lint # golangci-lint, config in .golangci.yml +make lint-fix # apply auto-fixable findings +make fmt # gofmt -w . +make hooks # opt in to .githooks/pre-commit (build + lint) +``` + +`make lint` uses **golangci-lint v2.12.2**, pinned in the `Makefile` and +auto-installed with the same Go toolchain that builds the module. `.golangci.yml` +starts from the `standard` set and adds correctness linters (`staticcheck`, +`bodyclose`, `errorlint`, `noctx`, `rowserrcheck`, `sqlclosecheck`, +`ineffassign`, `unused`, …). Test files are linted too. + +`make test-showdown` runs the ~2,000-case port of Showdown's sim suite behind the +`showdown` build tag. It is **expected to be partly red** — it documents where +this engine and competitive Pokémon disagree, and only fails when a case +disagrees with the ledger in `gaps_test.go`. Plain `go test ./...` never compiles +it. + +--- + +## Known limitations — read before you cite anything + +- **The arena leaderboard has no identity.** Trainers are keyed on a free-text + name with no ownership and clients barely prompt for one, so live-arena games + collapse onto `"Trainer Red"` vs `"AI"`. That board is *for fun, unverified*. + The `cmd/bench` benchmark, with named contestants and fixed seeds, is the part + that is measurement-grade. +- **Expectimax is not an optimality oracle.** Fixed-depth expectimax on this + format is non-monotonic in depth — deeper search plays *worse* (d1 48.1%, d2 + 36.7%, d3 42.1% vs the heuristic, per `docs/benchmark.md` §6). The known cause + is an opponent model that cannot switch. Per-move regret against it was cut + from the benchmark for exactly this reason. Use it as a strong baseline + opponent, never as ground truth. +- **The format is custom, not a downloadable competitive tier.** 80 Gen-1 + species with full modern movepools, L50, EV/IV/nature spreads, curated item + catalog, Species/Item/Evasion/OHKO/Sleep clauses, mirror-matched. Teams in + `data/benchmark-teams.json` were authored *for* it; standard competitive + intuitions do not transfer cleanly. +- **Elo here is relative within one round-robin.** Bradley-Terry MM, so it is + order-independent and reproducible — but it is not calibrated against Showdown + or any external ladder, and not comparable across runs. +- **LLM contestants are not deterministic.** They are not seeded; that is handled + by Wilson intervals and a reported legality-fallback rate, not pretended away. +- **The team library is deliberately not internally balanced.** That is correct + for a mirror benchmark (a team only plays itself) but means `cmd/team-validate` + will report imbalance — it is advisory, and exits 0. + +Scope, metrics and the full limitation list live in +[docs/benchmark.md](docs/benchmark.md); it is worth reading before publishing a +number from this repo. diff --git a/README.md b/README.md index 7f518ded..6c74b119 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,109 @@ # PokéArena -> **Any agent can play.** A human, a deterministic game-tree AI, or an LLM — -> over an open WebSocket protocol (MCP, a CLI, or your own client). One faithful -> Pokémon battle engine, one leaderboard, ranked by who plays best. +> **The Pokémon battle environment where you can prove it wasn't luck.** +> PokéArena runs on its **own deterministic engine** — not a Showdown wrapper — +> so a match can be replayed byte-for-byte and run as a **mirror match on an +> identical seed**: same team, both sides, the same RNG stream. The only free +> variable left is the policy. -PokéArena is an **arena for battle agents**. The engine plays faithful, -turn-by-turn Pokémon battles; *what drives each trainer* is up to you. The same -slot can be a human in a browser, a deterministic game-tree AI, an LLM, or -anything you can write that speaks the gateway's WebSocket protocol — MCP and a -CLI harness ship as **examples, not requirements**. Every result feeds a -leaderboard, so "my bot beats your bot" has an answer. +A deterministic, hidden-information, two-player environment for **LLM agents, +search agents, and humans** — with a benchmark harness that runs **in-process, +with no services and no API key**, and an arena with a browser UI, MCP server, +and live PvP when you want them. -If you've ever wanted a clean, deterministic, hidden-information game to test an -agent against — and a scoreboard to prove it — that's the point of this repo. +If you've ever wanted a clean multi-agent game to test an agent against, and a +way to show the win rate was the agent and not the dice, that's this repo. + +--- + +## Run the benchmark — 60 seconds, no stack, no API key + +The fastest path to a real number. It runs entirely **in-process**: no Postgres, +no Redis, no RabbitMQ, no Docker, no network, no model key. + +```bash +git clone https://github.com/shaumik/PokeArena && cd PokeArena +go run ./cmd/bench -agents heuristic,random -games 2 -out run.jsonl -runs "" +``` + +That plays a round-robin across all six curated library teams, mirror-matched, +each seed played in **both side orientations**, and prints: + +``` +overall standings (Elo, win rate with Wilson 95% CI): + agent elo winrate 95% CI W-L-D + heuristic 1804 100.0% [ 86.2%, 100.0%] 24-0-0 (n=24) + random 1196 0.0% [ 0.0%, 13.8%] 0-24-0 (n=24) +``` + +*(Verbatim output. It also prints a per-team Elo line for each of the six teams +— Genesis, Spectrum, Keystone, Bruiser, Bastion, Blitz.)* + +`-out run.jsonl` sends the full per-decision JSONL trace to a file instead of +your terminal — every decision with its `state_hash`, plus a run header pinning +the engine revision, dataset version, ruleset, and team library. Drop `-runs ""` +to also persist a run record under `runs/`. + +Two things that quickstart is quietly doing: + +- **It is the benchmark's own validity check.** Heuristic beats random on *every + one of the six teams*, 24–0. "On every team, a better policy beats a worse + one" is the property a mirror benchmark actually needs — see + [docs/benchmark.md §7](docs/benchmark.md). +- **It is reproducible.** Deterministic contestants on the same agents, teams + and seeds produce byte-identical games: same winners, same turn counts, same + per-decision state hashes. No CI, no pipeline, no trust required. + +Scale it up (240 games, ~1 minute on a laptop): + +```bash +go run ./cmd/bench -agents heuristic,expectimax -games 20 -out run.jsonl +``` + +Add an LLM contestant — Anthropic, OpenAI, Gemini, or a local Ollama model — +behind one `Client` interface, in `raw` or `cot` conditions: + +```bash +export ANTHROPIC_API_KEY=sk-ant-… +go run ./cmd/bench -agents heuristic \ + -llm 'haiku=claude-haiku-4-5-20251001,openai:gpt-5/cot' -games 10 -out run.jsonl +``` + +Token cost is **measured** from real usage, never estimated. Full flag table and +the agentic-harness comparison: **[docs/running-the-benchmark.md](docs/running-the-benchmark.md)**. + +> **On `go run …@latest`:** a module-path invocation +> (`go run github.com/shaumik/PokeArena/cmd/bench@latest`) needs two things this +> repo doesn't have yet — a published tag, **and** a `data/` directory in your +> working directory, because `bench` loads the curated dataset from disk +> (`-data`, default `data`). The clone above is the path that works today. + +--- + +## Why the engine is our own + +LLMs playing Pokémon is crowded prior art and we claim no novelty over the +domain — PokéLLMon, PokéChamp and several open harnesses got there first. The +difference is structural, and it comes from not wrapping Pokémon Showdown: + +| | Showdown-wrapping harness | PokéArena | +|---|---|---| +| Mirror match on an identical seed | Not available | Yes — same team, both sides, byte-identical RNG stream | +| Byte-reproducible from a clone | No | Yes — same agents/teams/seeds ⇒ same games and state hashes | +| Runs with no external service | No | Yes — the engine is a pure function, in-process | + +> **Variance-controlled mirror matches.** Same seed, same team, both sides. The +> only free variable is the policy — so across enough seeds, a win rate above +> 50% is evidence about the player, not the dice. That is the direct answer to +> "Pokémon is just luck." + +Four controls keep the measurement on the policy: mirror matches, both seat +orientations per seed, a fixed named seed set (`0..n-1`), and agents rebuilt +fresh per game. The scope, the metrics, and — importantly — the +[limitations we walked back](docs/benchmark.md) are all written down before the +numbers were. + +--- ## Watch: two agents battle, no human in the loop @@ -25,7 +116,7 @@ https://github.com/user-attachments/assets/6719547f-bdc2-4f87-aa34-4bc785ded4cd *Click to play.* Both trainer slots are driven by external agents over the gateway WebSocket — each sees only fog-of-war, calls `view` → picks a move → `act`, and the engine resolves the turn. Swap either side for a human, a script, or a -different model. [How to connect your own ↓](#connect-your-agent) +different model. [How to connect your own ↓](#connect-your-agent-pv-agent) | Build a team — stats, abilities, and a real move table | Battle — live weather, terrain, hazards, status, boosts, and both benches | |---|---| @@ -40,13 +131,18 @@ reveals them. --- -## Plug in any controller +## Fog of war, by construction A battle is two trainer slots. A *controller* fills a slot — the engine doesn't care what's behind it, only that it returns a legal action each turn from the -**fog-of-war view** it's handed (your team in full; the opponent's active Pokémon -only). Fairness is by construction: hidden data is never in the bytes a controller -receives. +**fog-of-war view** it's handed: **your team in full; the opponent's active +Pokémon only**, and even that is redacted — HP as a percentage, no exact stats, +no EVs/IVs/nature, no ability or held item until one visibly activates, revealed +moves without PP. Plus a count of how many benched foes are still alive. + +Fairness isn't policy an agent has to honor — hidden data is never in the bytes +a controller receives. The redaction contract is in +[docs/battle-state.md](docs/battle-state.md). | Controller | How it drives a slot | Use it for | |---|---|---| @@ -56,65 +152,26 @@ receives. | **Reference harness** | `pokearena-agent` dials the WS directly, BYO API key | A scriptable headless bot; swap providers in one file | | **Your own bot** | Speak the gateway WS / MCP protocol | Whatever you want to enter on the board | -The two reference clients ([below](#connect-your-agent)) exist so you have a -working example to fork — not because they're the only way in. - ---- - -## The leaderboard — whose bot did best - -Every completed battle updates an Elo rating (K=32) for both trainers, persisted -and idempotent (a redelivered result is a no-op). The board answers the only -question that matters in an arena: **which controller wins.** - -> **Honest status:** the rating math works; **identity does not yet.** Trainers -> are keyed on a free-text name with no ownership, and the clients barely prompt -> for one — so today most games collapse onto `"Trainer Red"` vs `"AI"` and the -> board is *for fun, unverified*. Making the leaderboard trustworthy is the top -> item in [Status & what we're fixing](#status--what-were-fixing). We'd rather say -> this out loud than ship a scoreboard that quietly lies. - ---- - -## The baseline bot - -The built-in "AI" isn't really an AI — it's a **deterministic expectimax** over the -game tree. That's a feature, not a limitation. It exists to be: - -- a **floor on the leaderboard** — beat the baseline before you brag; -- a **sparring partner** — play or test against it with zero setup; -- a **regression fixture** — same seed + same state ⇒ same line, every run, so the - engine is verifiable bit-for-bit. - -Keeping a cheap, deterministic opponent in the box is what makes the arena easy to -develop and test against. +The two reference clients ([below](#connect-your-agent-pv-agent)) exist so you +have a working example to fork — not because they're the only way in. --- -## Run it locally +## Python -Requires only Docker. +A Gymnasium/PettingZoo-style Python API wraps the same engine, so the +environment is usable from a normal RL/eval stack: ```bash -cp .env.example .env -docker compose up --build # postgres, rabbitmq, redis + the Go services +pip install pokearena ``` -The Pokédex ships in the image. Then open: - -| URL | What | -|---|---| -| http://localhost:8080 | The arena — browse the Pokédex, draft teams, battle | -| http://localhost:8080/api/healthz | Health check | - -```bash -make test # engine + AI unit tests -make down # stop and remove the stack -``` +Source lives under `python/`. Like the Go benchmark, it runs in-process — no +services. --- -## Connect your agent +## Connect your agent (Pv-Agent) Hand a trainer slot to an **external WebSocket client** running on *your* machine with *your* API key. Two reference clients ship with the repo — both speak the same @@ -152,6 +209,11 @@ claude mcp list # should include "pokearena" The browser tab is your seat (p1); make your moves there. Both sides must submit each turn before the engine resolves it. +The tool surface is ten tools — `join_battle`, `view`, `wait`, `act`, +`submit_team`, `leave_battle`, `list_natures`, `list_items`, `find_pokemon`, +`get_pokemon` — documented in [docs/mcp-protocol.md](docs/mcp-protocol.md) and +summarized for agents in [AGENTS.md](AGENTS.md). + ![Claude playing PokéArena via MCP](docs/claude-mcp.png)
@@ -189,17 +251,77 @@ export ANTHROPIC_API_KEY=sk-ant-… --- +## Run the full arena (browser UI, live PvP) + +Everything above needs no services. The **browser arena, live PvP, spectating, +and the leaderboard** do: Postgres, Redis, RabbitMQ, and five Go services. +Requires only Docker. + +```bash +cp .env.example .env +docker compose up --build # postgres, rabbitmq, redis + the Go services +``` + +The Pokédex ships in the image. Then open: + +| URL | What | +|---|---| +| http://localhost:8080 | The arena — browse the Pokédex, draft teams, battle | +| http://localhost:8080/api/healthz | Health check | + +```bash +make test # engine + AI unit tests (no stack needed) +make down # stop and remove the stack +``` + +--- + +## The baseline bot + +The built-in "AI" isn't really an AI — it's a **deterministic expectimax** over the +game tree. That's a feature, not a limitation. It exists to be: + +- a **floor on the leaderboard** — beat the baseline before you brag; +- a **sparring partner** — play or test against it with zero setup; +- a **regression fixture** — same seed + same state ⇒ same line, every run, so the + engine is verifiable bit-for-bit. + +It is **not** an optimality oracle, and we say so at length: fixed-depth +expectimax on this format is non-monotonic in depth (searching deeper plays +*worse*), which is why the per-move-regret metric was cut from the benchmark. +The full post-mortem is [docs/benchmark.md §6](docs/benchmark.md). + +--- + +## The leaderboard — whose bot did best + +Every completed battle updates an Elo rating (K=32) for both trainers, persisted +and idempotent (a redelivered result is a no-op). + +> **Honest status:** the rating math works; **identity does not yet.** Trainers +> are keyed on a free-text name with no ownership, and the clients barely prompt +> for one — so today most games collapse onto `"Trainer Red"` vs `"AI"` and the +> board is *for fun, unverified*. Making the leaderboard trustworthy is the top +> item in [Status & what we're fixing](#status--what-were-fixing). We'd rather say +> this out loud than ship a scoreboard that quietly lies. + +The **benchmark** (`cmd/bench`) is the part that is measurement-grade today: it +uses named contestants, fixed seeds, Wilson intervals, and order-independent +Bradley-Terry Elo. The live arena leaderboard is not yet. + +--- + ## Status & what we're fixing -The headline is a **commitment**, not a finished state. Here's the honest gap -between "an arena where bots compete on a real leaderboard" and what runs today. +Here's the honest gap between the pitch and what runs today. -| Area | Today | To make the headline true | +| Area | Today | To close it | |---|---|---| | **Leaderboard identity** | Free-text name, no ownership; clients barely prompt | Prompt for a trainer/agent name everywhere a battle starts; surface the board in the SPA. (Optional later: claim-a-handle + secret to stop impersonation.) | | **Leaderboard visibility** | Rating computed + stored, but not shown in the UI | A real standings page — wins/losses/Elo, sortable | | **Bot onboarding** | Two reference clients, MCP + CLI | A 5-minute "write your own bot" quickstart against a documented protocol | | **Provider coverage** | Benchmark (`cmd/bench`) runs Anthropic, OpenAI, Gemini, and local Ollama behind one `Client` interface, in `raw`/`cot` conditions; the live harness (`pokearena-agent`) is still Anthropic-only | Bring the remaining vendors to the live harness too | +| **Per-move regret** | Cut — expectimax is not a valid optimality oracle here ([§6](docs/benchmark.md)) | An opponent model in the search that can switch | If you hit something that doesn't match the pitch, that's a bug in the pitch or the product — open an issue. @@ -219,14 +341,16 @@ layer carries it all: batch sims, the live action/frame channels, cross-replica spectating, and the leaderboard. That distributed layer is real but **optional to the product** — for a single-box -deploy it collapses to a handful of processes over Postgres + Redis. If the systems -design interests you, the full topology, event contracts, ownership/failover model, and -engine internals are in **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. +deploy it collapses to a handful of processes over Postgres + Redis, and the +benchmark path uses none of it. If the systems design interests you, the full +topology, event contracts, ownership/failover model, and engine internals are in +**[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. ## Docs | Doc | What | |---|---| +| [AGENTS.md](AGENTS.md) | Start here if you *are* a coding agent — fastest path to a result, what needs no services, the tool surface | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system-design deep-dive | | [docs/benchmark.md](docs/benchmark.md) | The battle benchmark — scope, metrics, and honest limitations | | [docs/running-the-benchmark.md](docs/running-the-benchmark.md) | How to run the benchmark — the `bench` CLI and the agentic-harness comparison | @@ -235,11 +359,23 @@ engine internals are in **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)**. | [docs/agent-harness.md](docs/agent-harness.md) | The boundary between core services and the agent layer | | [docs/live-pvp.md](docs/live-pvp.md) | The claimable-slot protocol, join-token security, and cross-instance distribution model | | [docs/live-pvp-distribution.html](docs/live-pvp-distribution.html) | Animated, minimal-words diagram of how a live battle is distributed (before/after) | -| [docs/battle-state.md](docs/battle-state.md) | The battle-state and move schema contract | +| [docs/battle-state.md](docs/battle-state.md) | The battle-state and move schema contract, including the fog-of-war redaction rules | | [DEPLOY.md](DEPLOY.md) | Deployment notes | --- +## Cite this + +If you use PokéArena in research, cite it via [`CITATION.cff`](CITATION.cff) — +GitHub renders a ready-made citation from it in the sidebar ("Cite this +repository"). Please also quote the run header from your trace (engine revision, +dataset version, ruleset, `team_library`, `team_profile`), since two runs under +an identical ruleset can still be measuring different metagames. + +## License + +MIT — see [LICENSE](LICENSE). + ## Provenance Built incrementally — every component is its own commit; `git log` is the build From a8d6275d9f55e431891208539cf1c059ee902672 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:31:15 +0000 Subject: [PATCH 03/12] bench: run from anywhere, not just a clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/bench is the project's zero-setup entry point — the one thing a reader can run in a minute with no services and no API key. But it loaded the dex with LoadDex("data") and defaulted -teams to data/benchmark-teams.json, both relative to the working directory. So 'go run github.com/shaumik/PokeArena/cmd/bench@latest' died at dex load from the module cache, and the front door only ever opened from inside a clone. Widen the module-root embed to cover benchmark-teams.json, _provenance.json and model-pricing.json, and fall back to it when the dataset directory is absent. An explicit -data that does not exist is still a hard error; the user named a directory and we should not quietly ignore them. The library, provenance and pricing loaders grow fs.FS variants with the disk versions as thin wrappers, matching what LoadDex/LoadDexFS already did. Embedded and on-disk runs were diffed over 1801 trace rows of a heuristic-vs-expectimax run: byte-identical, so the fallback cannot fork the benchmark from the copy in data/. --- cmd/bench/main.go | 77 ++++++++++++++++++++++++++++++++----- dataset.go | 6 +++ internal/eval/library.go | 28 +++++++++++--- internal/eval/provenance.go | 19 ++++++++- internal/eval/results.go | 15 ++++++++ 5 files changed, 130 insertions(+), 15 deletions(-) diff --git a/cmd/bench/main.go b/cmd/bench/main.go index c2decc12..28e90543 100644 --- a/cmd/bench/main.go +++ b/cmd/bench/main.go @@ -18,12 +18,14 @@ package main import ( "flag" "fmt" + "io/fs" "log" "os" "strconv" "strings" "time" + pokearena "github.com/shaumik/PokeArena" "github.com/shaumik/PokeArena/internal/agentloop" "github.com/shaumik/PokeArena/internal/ai" "github.com/shaumik/PokeArena/internal/domain" @@ -53,12 +55,17 @@ func main() { ) flag.Parse() - dex, err := domain.LoadDex(*dataDir, "bench") + data, err := resolveDataset(*dataDir) if err != nil { - log.Fatalf("load dex from %s: %v", *dataDir, err) + log.Fatalf("%v", err) } - benchTeams, libVersion, err := loadBenchTeams(dex, *teamCSV, *libPath) + dex, err := domain.LoadDexFS(data.fsys, "bench") + if err != nil { + log.Fatalf("load dex from %s: %v", data.describe(), err) + } + + benchTeams, libVersion, err := loadBenchTeams(dex, *teamCSV, *libPath, data) if err != nil { log.Fatalf("%v", err) } @@ -100,7 +107,7 @@ func main() { // Reproducibility header: pin dataset + code + ruleset + config as the first // line, so any trace names exactly what produced it. - prov, err := eval.LoadProvenance(*dataDir) + prov, err := eval.LoadProvenanceFS(data.fsys) if err != nil { log.Fatalf("%v", err) } @@ -151,7 +158,7 @@ func main() { // JSON, and any later report all cite the exact same numbers. Pricing is // loaded only when a paid model is in the run — a baseline-only or // local-only run has nothing to cost. - pricing := loadPricing(*pricePath, len(models) > len(localModels)) + pricing := loadPricing(*pricePath, data, len(models) > len(localModels)) // Local models spend tokens but cost nothing: price them at zero so the // report shows "free", not "unknown" (which is reserved for a missing price // on a paid model). @@ -213,8 +220,8 @@ func main() { // loadPricing loads the model pricing table. It is required only when the run // has LLM contestants to cost; a baseline-only run tolerates a missing file so // the benchmark works out of the box without one. -func loadPricing(path string, required bool) map[string]usage.Pricing { - p, err := eval.LoadPricing(path) +func loadPricing(path string, data dataset, required bool) map[string]usage.Pricing { + p, err := data.pricing(path) if err != nil { if required { log.Fatalf("load pricing (needed to cost -llm token usage): %v", err) @@ -287,7 +294,7 @@ func makeContestant(name string, dex *domain.Dex, depth int) (eval.Contestant, e // loadBenchTeams resolves the teams to run on. An explicit -team (dex numbers) // is an ad-hoc single-team override; otherwise the curated, legality-checked // library at libPath is used and every team is mirror-matched. -func loadBenchTeams(dex *domain.Dex, teamCSV, libPath string) (teams []eval.NamedTeam, version string, err error) { +func loadBenchTeams(dex *domain.Dex, teamCSV, libPath string, data dataset) (teams []eval.NamedTeam, version string, err error) { if teamCSV != "" { dexNos, err := parseTeam(teamCSV) if err != nil { @@ -299,7 +306,7 @@ func loadBenchTeams(dex *domain.Dex, teamCSV, libPath string) (teams []eval.Name } return []eval.NamedTeam{{Name: "adhoc", Picks: picks}}, "adhoc", nil } - lib, err := eval.LoadTeamLibrary(libPath, dex) + lib, err := data.teamLibrary(libPath, dex) if err != nil { return nil, "", fmt.Errorf("load team library: %w", err) } @@ -499,3 +506,55 @@ func splitCSV(s string) []string { } func nPairs(n int) int { return n * (n - 1) / 2 } + +// The dataset the run reads from. cmd/bench is the project's zero-setup entry +// point — "go run github.com/shaumik/PokeArena/cmd/bench@latest" has to work +// from a module cache directory that has no data/ anywhere near it — so when +// the dataset directory is absent we fall back to the copy embedded at the +// module root. An explicit -data that does not exist is still an error: the +// user named a directory and we should not quietly ignore them. +type dataset struct { + fsys fs.FS + dir string // "" when embedded + embedded bool +} + +const ( + defaultDataDir = "data" + defaultTeamsPath = "data/benchmark-teams.json" + defaultPricingPath = "data/model-pricing.json" + embeddedTeamsName = "benchmark-teams.json" + embeddedPricingName = "model-pricing.json" +) + +func resolveDataset(dir string) (dataset, error) { + if st, err := os.Stat(dir); err == nil && st.IsDir() { + return dataset{fsys: os.DirFS(dir), dir: dir}, nil + } else if dir != defaultDataDir { + return dataset{}, fmt.Errorf("dataset directory %s: %w", dir, err) + } + return dataset{fsys: pokearena.DataFS(), embedded: true}, nil +} + +func (d dataset) describe() string { + if d.embedded { + return "the embedded dataset" + } + return d.dir +} + +// teamLibrary honours an explicit -teams path; at the default it reads from +// whichever dataset we resolved, so -data and the embedded copy both work. +func (d dataset) teamLibrary(path string, dex *domain.Dex) (*eval.TeamLibrary, error) { + if path != defaultTeamsPath { + return eval.LoadTeamLibrary(path, dex) + } + return eval.LoadTeamLibraryFS(d.fsys, embeddedTeamsName, dex) +} + +func (d dataset) pricing(path string) (map[string]usage.Pricing, error) { + if path != defaultPricingPath { + return eval.LoadPricing(path) + } + return eval.LoadPricingFS(d.fsys, embeddedPricingName) +} diff --git a/dataset.go b/dataset.go index 49572d9a..e6853c4e 100644 --- a/dataset.go +++ b/dataset.go @@ -18,11 +18,17 @@ import ( ) //go:embed data/pokedex.json data/moves.json data/typechart.json data/items.json data/natures.json +//go:embed data/benchmark-teams.json data/_provenance.json data/model-pricing.json var dataFS embed.FS // DataFS returns the embedded data directory rooted at "data/" — i.e. the // caller sees pokedex.json / moves.json / typechart.json / items.json / // natures.json at the top level, which is the shape domain.LoadDexFS expects. +// +// benchmark-teams.json and _provenance.json ride along so that cmd/bench is +// self-contained too: the benchmark is the project's zero-setup entry point, +// and "go run github.com/shaumik/PokeArena/cmd/bench@latest" runs from a +// module cache directory with no data/ anywhere near it. func DataFS() fs.FS { sub, err := fs.Sub(dataFS, "data") if err != nil { diff --git a/internal/eval/library.go b/internal/eval/library.go index dec836a4..a5c96cd3 100644 --- a/internal/eval/library.go +++ b/internal/eval/library.go @@ -3,6 +3,7 @@ package eval import ( "encoding/json" "fmt" + "io/fs" "os" "github.com/shaumik/PokeArena/internal/domain" @@ -71,21 +72,38 @@ type TeamLibrary struct { Teams []NamedTeam `json:"teams"` } -// LoadTeamLibrary reads and validates a team library file. Every team must pass -// engine.ValidateTeam (6 mons, Species Clause, 1-4 learnset-legal moves each); -// the first illegal team is a hard error naming the team, because a benchmark -// run on an illegal team is meaningless. +// LoadTeamLibrary reads and validates a team library file from disk. It is a +// thin wrapper around LoadTeamLibraryFS for callers that work with a path. func LoadTeamLibrary(path string, dex *domain.Dex) (*TeamLibrary, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read team library: %w", err) } + return parseTeamLibrary(raw, path, dex) +} + +// LoadTeamLibraryFS reads and validates a team library from an fs.FS, so a +// caller that embedded the dataset with go:embed can use the same validation +// path as one reading from disk (cmd/bench does this). +func LoadTeamLibraryFS(fsys fs.FS, name string, dex *domain.Dex) (*TeamLibrary, error) { + raw, err := fs.ReadFile(fsys, name) + if err != nil { + return nil, fmt.Errorf("read team library: %w", err) + } + return parseTeamLibrary(raw, name, dex) +} + +// parseTeamLibrary is the shared body: every team must pass engine.ValidateTeam +// (6 mons, Species Clause, 1-4 learnset-legal moves each); the first illegal +// team is a hard error naming the team, because a benchmark run on an illegal +// team is meaningless. +func parseTeamLibrary(raw []byte, src string, dex *domain.Dex) (*TeamLibrary, error) { var lib TeamLibrary if err := json.Unmarshal(raw, &lib); err != nil { return nil, fmt.Errorf("parse team library: %w", err) } if len(lib.Teams) == 0 { - return nil, fmt.Errorf("team library %s has no teams", path) + return nil, fmt.Errorf("team library %s has no teams", src) } for i, team := range lib.Teams { if team.Name == "" { diff --git a/internal/eval/provenance.go b/internal/eval/provenance.go index 1217688c..af962591 100644 --- a/internal/eval/provenance.go +++ b/internal/eval/provenance.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "os" "path/filepath" "runtime/debug" @@ -30,12 +31,28 @@ type Provenance struct { SyncedAt string `json:"synced_at"` } -// LoadProvenance reads _provenance.json from the dataset directory. +// LoadProvenance reads _provenance.json from the dataset directory on disk. It +// is a thin wrapper around LoadProvenanceFS for callers that work with a path. func LoadProvenance(dataDir string) (Provenance, error) { raw, err := os.ReadFile(filepath.Join(dataDir, "_provenance.json")) if err != nil { return Provenance{}, fmt.Errorf("read provenance: %w", err) } + return parseProvenance(raw) +} + +// LoadProvenanceFS reads _provenance.json from an fs.FS rooted at the dataset +// directory. The run header pins the dataset's identity, so a binary that +// embeds the dataset still has to be able to name it. +func LoadProvenanceFS(fsys fs.FS) (Provenance, error) { + raw, err := fs.ReadFile(fsys, "_provenance.json") + if err != nil { + return Provenance{}, fmt.Errorf("read provenance: %w", err) + } + return parseProvenance(raw) +} + +func parseProvenance(raw []byte) (Provenance, error) { var p Provenance if err := json.Unmarshal(raw, &p); err != nil { return Provenance{}, fmt.Errorf("parse provenance: %w", err) diff --git a/internal/eval/results.go b/internal/eval/results.go index 59ae3d8f..7569baa1 100644 --- a/internal/eval/results.go +++ b/internal/eval/results.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "hash/fnv" + "io/fs" "os" "path/filepath" "sort" @@ -308,6 +309,20 @@ func LoadPricing(path string) (map[string]usage.Pricing, error) { if err != nil { return nil, fmt.Errorf("read pricing %s: %w", path, err) } + return parsePricing(data, path) +} + +// LoadPricingFS reads the pricing table from an fs.FS, for binaries that embed +// the dataset rather than reading it from disk. +func LoadPricingFS(fsys fs.FS, name string) (map[string]usage.Pricing, error) { + data, err := fs.ReadFile(fsys, name) + if err != nil { + return nil, fmt.Errorf("read pricing %s: %w", name, err) + } + return parsePricing(data, name) +} + +func parsePricing(data []byte, path string) (map[string]usage.Pricing, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parse pricing %s: %w", path, err) From 37aab87cf7a51739807a2afaabc30c95662a0571 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:31:45 +0000 Subject: [PATCH 04/12] Add CITATION.cff, a Claude Code plugin, and the depth-sweep writeup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three discovery surfaces the repo had no answer for. CITATION.cff gives GitHub its 'Cite this repository' button and gives a researcher's tooling something machine-readable to pick up. CFF 1.2.0, validated against the published schema. .claude-plugin/ registers the existing MCP server so Claude Code can install PokeArena in one step, and lets the repo serve as its own marketplace. The launcher resolves a release binary, then PATH, then falls back to 'go run' from the module root, and keeps every diagnostic on stderr so stdout stays a clean JSON-RPC stream. The bundled skill is written off docs/mcp-protocol.md and the real handlers rather than the README's prose: the phase gate, {ready:false} as an ordinary timeout, act's optimistic write, and forfeit-on-leave. docs/deeper-search-played-worse.md pulls benchmark.md section 6 out into something a stranger will read: expectimax searching deeper and playing worse, traced to a fog-of-war reconstruction that scored a KO of the visible active as winning the whole game. It keeps the part that makes it honest — the corrected model is weaker than the buggy one, because the phantom KO had been inducing helpful aggression — and it keeps the doc's caveat that the d2/d3 tail is one run's suggestion, not a result. Every figure was diffed against section 6; none is new. --- .claude-plugin/README.md | 50 +++++ .claude-plugin/marketplace.json | 37 ++++ .claude-plugin/plugin.json | 41 +++++ .claude-plugin/skills/play-battle/SKILL.md | 179 ++++++++++++++++++ CITATION.cff | 40 ++++ docs/deeper-search-played-worse.md | 201 +++++++++++++++++++++ 6 files changed, 548 insertions(+) create mode 100644 .claude-plugin/README.md create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .claude-plugin/skills/play-battle/SKILL.md create mode 100644 CITATION.cff create mode 100644 docs/deeper-search-played-worse.md diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md new file mode 100644 index 00000000..0fe9e262 --- /dev/null +++ b/.claude-plugin/README.md @@ -0,0 +1,50 @@ +# PokéArena — Claude Code plugin + +Installs the [`pokearena-mcp`](../cmd/pokearena-mcp) stdio MCP server and a +`play-battle` skill, so a Claude Code session can claim a trainer slot in a live +PokéArena battle and play it to completion. + +This repository is also its own plugin marketplace +([`marketplace.json`](marketplace.json)): + +``` +/plugin marketplace add shaumik/PokeArena +/plugin install pokearena@pokearena +``` + +Or, for local development straight from a checkout: + +``` +claude --plugin-dir /path/to/PokeArena +``` + +## What it registers + +| Component | What | +|---|---| +| MCP server `pokearena` | `join_battle`, `submit_team`, `wait`, `view`, `act`, `leave_battle`, plus the drafting helpers `find_pokemon`, `get_pokemon`, `list_natures`, `list_items` | +| Skill `pokearena:play-battle` | The battle loop and the fog-of-war contract, grounded in [`docs/mcp-protocol.md`](../docs/mcp-protocol.md) | + +## The server binary + +[`bin/pokearena-mcp`](bin/pokearena-mcp) is a launcher, not the server. It looks +for a prebuilt binary and falls back to `go run ./cmd/pokearena-mcp`, so a +checkout with the Go toolchain installed works with no build step (the first +start compiles and is slow). To avoid that, build it once: + +``` +go build -o ./bin/pokearena-mcp ./cmd/pokearena-mcp +``` + +The launcher will pick up `/bin/pokearena-mcp`, anything on `PATH` +named `pokearena-mcp`, or an explicit `POKEARENA_MCP_BIN`. + +## Pointing at a gateway + +The server defaults to a local stack at `ws://localhost:8080`. For a deployed +gateway, set the plugin's **Gateway URL** config (it is passed through as +`POKEARENA_GATEWAY_URL`), or export `POKEARENA_GATEWAY_URL=wss://your.host` +in the environment Claude Code starts from. + +Then hand Claude a battle share URL — `http://…/?battle=ID&slot=p2&token=…` — +and it will join that slot and play. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..7e96edcf --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-marketplace.json", + "name": "pokearena", + "owner": { + "name": "Shaumik Ashraf", + "email": "shaumik@echelonai.com", + "url": "https://github.com/shaumik" + }, + "description": "The PokéArena repository, serving itself as a Claude Code plugin marketplace.", + "version": "0.1.0", + "plugins": [ + { + "name": "pokearena", + "displayName": "PokéArena", + "source": "./", + "description": "Play PokéArena battles from Claude Code: registers the pokearena-mcp stdio server (join/wait/view/act, team drafting) and a skill that teaches the battle loop.", + "version": "0.1.0", + "author": { + "name": "Shaumik Ashraf", + "email": "shaumik@echelonai.com", + "url": "https://github.com/shaumik" + }, + "homepage": "https://github.com/shaumik/PokeArena", + "repository": "https://github.com/shaumik/PokeArena", + "license": "MIT", + "category": "games", + "keywords": [ + "pokemon", + "game-ai", + "agent-benchmark", + "mcp", + "websocket", + "turn-based" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..591eb45d --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "pokearena", + "displayName": "PokéArena", + "version": "0.1.0", + "description": "Play PokéArena battles from Claude Code: registers the pokearena-mcp stdio server (join/wait/view/act, team drafting) and a skill that teaches the battle loop.", + "author": { + "name": "Shaumik Ashraf", + "email": "shaumik@echelonai.com", + "url": "https://github.com/shaumik" + }, + "homepage": "https://github.com/shaumik/PokeArena", + "repository": "https://github.com/shaumik/PokeArena", + "license": "MIT", + "keywords": [ + "pokemon", + "game-ai", + "agent-benchmark", + "mcp", + "websocket", + "turn-based" + ], + "skills": "./.claude-plugin/skills/", + "userConfig": { + "gateway_url": { + "type": "string", + "title": "Gateway URL", + "description": "Base ws:// or wss:// URL of the PokéArena gateway. Leave empty for a local stack (ws://localhost:8080).", + "required": false + } + }, + "mcpServers": { + "pokearena": { + "command": "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/bin/pokearena-mcp", + "env": { + "POKEARENA_GATEWAY_URL": "${user_config.gateway_url}", + "POKEARENA_PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}" + } + } + } +} diff --git a/.claude-plugin/skills/play-battle/SKILL.md b/.claude-plugin/skills/play-battle/SKILL.md new file mode 100644 index 00000000..913b10ba --- /dev/null +++ b/.claude-plugin/skills/play-battle/SKILL.md @@ -0,0 +1,179 @@ +--- +name: play-battle +description: Play a PokéArena battle to completion over the pokearena MCP server — claim a slot from a share URL, draft a team if the picker is open, then run the wait → view → act loop until the battle is terminal. Use whenever the user hands over a PokéArena battle link, asks you to take a trainer slot, or asks you to play, finish, or spectate a battle. +argument-hint: "[battle share URL, e.g. http://localhost:8080/?battle=ID&slot=p2&token=…]" +--- + +# Play a PokéArena battle + +You are a trainer in a 6v6 battle. You see **only** what a human in the same +seat sees: your own side in full, the opponent's active Pokémon with its HP as a +percentage, and how many foes are still standing. The hidden bench is not in the +bytes you receive — do not pretend to know it, and do not ask the user for it. + +The contract below is the one in +[`docs/mcp-protocol.md`](../../../docs/mcp-protocol.md); the tool descriptions in +[`internal/mcpserver/tools.go`](../../../internal/mcpserver/tools.go) are the +authority if the two ever disagree. + +## 1. Claim the seat + +A share URL looks like `http://host/?battle=ID&slot=p2&token=…`. Pull the three +query parameters out of it and call: + +``` +join_battle(battle_id=ID, slot="p2", join_token=TOKEN) +``` + +- **PvP battle** — pass all three. The token is a per-slot password: never echo + it back into the transcript or into a shell command. +- **Live vs-AI battle** — pass `battle_id` only. No slot, no token; you are + seated as p1 against the programmatic opponent. + +`join_battle` returns `battle_id`, `slot`, `your_trainer`, `opponent_trainer`, +`phase`, and — when the battle is already running — `initial_view`. + +Read `phase`: + +| `phase` | What to do next | +|---|---| +| `open` | The picker is up. Draft a team (§2) before anything else. | +| `starting` | Transient. Go to the loop (§3); `wait` will carry you through. | +| `active` | The battle is running. Go straight to the loop (§3). | + +Failure modes: a taken slot, a bad token, and a missing battle all collapse to +one opaque message on purpose — if you get it, ask the user for a fresh share +URL rather than retrying. `errAlreadyJoined` means this session already holds a +battle; call `leave_battle` first. + +## 2. Draft a team (only when `phase` is `open`) + +Four discovery tools feed `submit_team`. Call them in this order and use the +IDs they return verbatim — every ID is kebab-case (`body-slam`, `flash-fire`, +`choice-band`) and an inexact ID is a rejected team, not a warning. + +1. `find_pokemon(query)` — substring search over the curated dex. The dataset is + a filtered subset, so *check* rather than assume a species exists. Returns up + to 30 matches with `dex_no`, name, and types. +2. `get_pokemon(dex_no)` — base stats, ability slots, and the **legal move list** + for one species. `moves[].id` is exactly what `submit_team` wants. +3. `list_natures()` — the 25 natures (each raises one stat 10% and lowers + another; five are neutral) **plus** `rules`: the battle level and the EV/IV + caps actually enforced. Read the caps from here instead of assuming them. +4. `list_items()` — the held-item catalog. Items are not species-restricted; any + item is legal on any Pokémon, one per Pokémon. + +Then submit exactly six picks: + +``` +submit_team(picks=[ + {dex_no, moves: [1–4 ids from that species' learn list], + ability?, item?, nature?, evs?, ivs?}, + … 6 total … +]) +``` + +`ability` defaults to `abilities[0]` if omitted. `item` omitted means no item. +`nature`/`evs`/`ivs` are optional — omitting them gives a legal neutral spread +(no EVs, 31 IVs, no nature), so a spread is an optimization, never a +requirement. `submit_team` blocks until the server accepts (`accepted: true`) or +rejects with an error; on rejection, fix the offending ID and resubmit rather +than resubmitting the same payload. + +Build for the format that actually exists, not for a remembered metagame: check +`list_natures().rules` for the level and budget, confirm every move against +`get_pokemon`, and keep the six picks covering different types so one opposing +Pokémon cannot wall the whole team. + +## 3. The loop + +This is the whole game: + +``` +while True: + r = wait(timeout_seconds=60) + if not r.ready: # timeout — the opponent hasn't moved yet + continue + if r.terminal: # battle over + break + action = decide(r.view) + act(**action) +``` + +- **`wait(timeout_seconds=60)`** is the primitive. It blocks until it is your + turn, the battle ends, or the timeout elapses; the value is clamped to + `[1, 120]`. A `{ready: false}` return is **normal** — the engine resolves a + turn only after *both* sides submit, so you are simply waiting on the other + trainer. Call `wait` again. Do not treat a timeout as an error, and do not + poll `view` in a tight loop instead: that burns tool calls and tokens for + nothing. +- **`view()`** is the non-blocking snapshot, for when you want the current state + right now (e.g. to re-read the board before explaining a decision). It returns + the same object `wait` hands you. +- **`act(kind, index)`** submits your action: `kind="move"` with `index` 0–3, or + `kind="switch"` with `index` 0–5 (a team slot). It returns + `{accepted: true, turn: N}` the moment the write hits the wire — that is *not* + confirmation the gateway liked it. An illegal action is rejected + asynchronously and surfaces as an error on your next `wait`, so **validate + against the view before calling**: the move slot must exist and have PP, the + switch target must be a different, unfainted team member. +- **`leave_battle()`** closes the session. Mid-battle this is a **forfeit** — + only call it when the battle is terminal or the user asks you to quit. + +Act promptly each turn. The gateway substitutes a default action for a slot that +takes too long, and a defaulted turn is a turn you did not choose. + +## 4. Reading the view + +Keys you get every turn: + +| Key | Meaning | +|---|---| +| `me` | Which side index you control. | +| `self` | Your side **in full**: `trainer`, `team` (six Pokémon with exact HP, moves, PP, stats, status), `active` (index into `team`), `conditions`, `slot_conditions`. | +| `foe` | The opponent's active Pokémon — `hp_pct` (0–100, *not* exact HP), types, status, boosts, and `moves` as revealed `move_id`s with no PP. No ability, no item, no stats, no spread. | +| `foe_bench_alive` | How many unfainted Pokémon the opponent still has benched. | +| `turn`, `phase` | Turn counter and battle phase. | +| `replace` | `true` when your active fainted and you **must** switch — the only legal action is `kind="switch"`. | +| `weather`, `terrain`, `pseudo_weather` | Field state; absent keys mean none active. | +| `foe_conditions`, `foe_slot_conditions` | The foe's public side effects — screens, hazards, a pending Wish (healer + turns left, never the heal amount). | + +What is **absent** is information, too. The foe's ability and item are hidden +until they visibly activate; infer them from events (a Choice lock, a Sash save, +a Leftovers tick) rather than guessing at the start. + +## 5. Choosing well + +Rank the legal actions each turn on the state you can actually see: + +- **Speed decides who acts.** Compare your active's Speed against what the foe + has shown, adjusted for boosts, paralysis, and terrain. Winning the speed tie + changes a trade into a free KO; losing it changes a "safe" attack into a + faint. +- **Type matchups both ways.** Score your best move's effectiveness against the + foe *and* the foe's likely best move against you. A neutral hit you survive + usually beats a super-effective hit you don't get to use. +- **Count the KO.** If a move likely knocks the foe out this turn, take it — but + note that `foe_bench_alive > 0` means the game is *not* over. A KO buys you a + free switch-in for the opponent, not a win. +- **Switch on purpose.** A switch costs a turn and eats one hit; it is worth it + to break a bad matchup, to preserve a Pokémon that wins a later matchup, or to + absorb status. Do not switch merely because the current mon is damaged. +- **Status and hazards compound.** Burn, paralysis, poison, and entry hazards + pay out over the rest of the game; they are usually the strongest play on a + turn where no attack does meaningful damage. +- **`replace: true` restricts you.** Bring in something that resists what just + KO'd you, not simply the healthiest body on the bench. +- **Read the PP.** Your `self` view has exact PP. A move at 0 PP is not a legal + action. + +When the user is watching, say what you chose and why in one short line per +turn — the interesting part of this benchmark is the reasoning, not the win. + +## 6. Ending + +The loop exits when `wait` returns `terminal: true`. The accompanying view is +the final board: report the result (who won, on what turn, what was left +standing) and then call `leave_battle()` to release the session. One MCP process +can play many battles in sequence, so after leaving you are free to +`join_battle` again on a new share URL. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..4daf29ce --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,40 @@ +cff-version: 1.2.0 +message: "If you use PokéArena or cite its benchmark results, please cite it as below." +title: "PokéArena: a deterministic Pokémon battle engine, arena, and agent benchmark" +abstract: >- + PokéArena is an arena for battle agents. A deterministic, fog-of-war Pokémon + battle engine — a pure function from (state, action, action) to (state, events) + — hosts 6v6 battles in which either trainer slot can be filled by a human, a + deterministic expectimax search, an LLM over MCP, or any client that speaks the + gateway's WebSocket protocol. Because the engine is our own rather than a + wrapper around an external server, the RNG stream is byte-identical across + runs, which makes variance-controlled mirror matches possible: the same seed + and the same team on both sides, so the only free variable is the policy. On + top of that the repository ships a battle benchmark that measures tactical + decision-making under hidden information, reporting win rates with Wilson 95% + intervals and order-independent Bradley-Terry Elo, with per-run provenance + (engine revision, dataset version, ruleset, team library) recorded so any + published number is reproducible from a clone. +type: software +authors: + - family-names: Ashraf + given-names: Shaumik + email: shaumik@echelonai.com + alias: shaumik +version: 0.1.0 +date-released: "2026-08-25" +license: MIT +repository-code: "https://github.com/shaumik/PokeArena" +url: "https://github.com/shaumik/PokeArena" +keywords: + - pokemon + - battle-engine + - game-ai + - expectimax + - agent-benchmark + - llm-agents + - model-context-protocol + - reinforcement-learning-environment + - hidden-information-games + - deterministic-simulation + - go diff --git a/docs/deeper-search-played-worse.md b/docs/deeper-search-played-worse.md new file mode 100644 index 00000000..d60f7486 --- /dev/null +++ b/docs/deeper-search-played-worse.md @@ -0,0 +1,201 @@ +# Deeper search played worse + +*We gave our game-tree search more compute and it got worse at the game. The bug +was that it thought it had already won.* + +PokéArena runs 6v6 Pokémon battles on a deterministic engine, and one of the +agents in the pool is a plain fixed-depth expectimax. Search agents are supposed +to be boring: they are the thing you measure other things against. Ours was +going to be the ground truth for a per-move regret metric — score every decision +an LLM makes against what the search would have played, and you get a much +denser signal than win/loss. + +Before shipping that metric we ran the obvious sanity check: sweep the search +depth and confirm the agent gets stronger. It did the opposite. + +``` +depth 1 → 61% +depth 2 → 46% +depth 3 → 27% +``` + +That is expectimax's win rate against a fixed heuristic opponent, over a +full-library depth sweep. Thirty-four points of decline, with non-overlapping +intervals. Every extra ply of lookahead bought a materially worse player. (Those +three figures are from the v1 team library and are unrepeatable — the bug they +measure is gone.) + +An oracle that plays worse the harder it thinks cannot define "optimal," so the +regret metric went in the bin. But the sweep was worth chasing down, because a +monotonicity failure that large is never subtle when you finally see it. + +## What it is not + +Three explanations come to mind first, and it is worth saying why none of them +account for a cliff this size. + +**"The evaluation function is bad."** A weak leaf evaluator does degrade with +depth — you propagate a noisier estimate from further away. But that failure mode +is a slope, not a cliff, and it does not usually reverse the sign of "more search +helps." A 34-point collapse is not a noisy heuristic; it is a heuristic that is +confidently, systematically wrong about something. + +**"It is the horizon effect."** Deep search can learn to push a bad outcome just +past the last ply it can see. That is real, and it costs you games. It does not +cost you thirty-four points of win rate, and it does not fall away so cleanly +with every added ply. + +**"Pokémon is luck, the numbers are noise."** This is the one the benchmark was +designed to answer in advance. Battles run as mirror matches on a fixed seed set, +both seat orientations per seed, with agents rebuilt per game — the same team on +both sides, the same RNG stream, the policy as the only free variable. If the +dice were driving this, the sweep would not be monotone in depth. + +The remaining possibility is the uncomfortable one: the search was optimising +correctly, and the thing it was optimising was not the game. + +## The phantom KO + +Expectimax needs a state to search from. The agent does not get one — it gets a +**fog-of-war view**, which is the whole point of the benchmark: your own side in +full, the opponent's *active* Pokémon, and nothing else. The opponent's bench is +hidden information and is not in the bytes the agent receives. + +So the search did what it had to do: it reconstructed a battle state from the +view. Your six, and the foe's one visible active. + +That reconstruction is a perfectly good approximation for damage rolls, speed +order, and type matchups. It is a catastrophe for exactly one question: +**is this state terminal?** + +Terminality is decided by "does either side have any Pokémon left." In the +reconstructed state, the foe's side held one Pokémon. Knock it out and the foe's +side is empty. The search read that as *winning the entire game* and scored it +`+1e6` — with five foe Pokémon still sitting on a bench it could not see. + +Now the depth behaviour explains itself. At depth 1 the search can barely reach +the fiction; it mostly just picks the biggest number. At depth 2 it can see a +line that reaches the phantom win and will pay a real price to get there. At +depth 3 it can construct a *plan* to reach it — sacrificing material, ignoring +setup, refusing sensible switches — because every one of those costs is rounded +to nothing against a payoff of a million. More compute was not buying better +play. It was buying a more thorough pursuit of a KO that ended nothing. + +## The fix + +Commit `013f82e`. Terminality is no longer judged against the reconstructed +board; it is judged against **true material** carried in the search context. The +foe's hidden bench is counted as full-HP Pokémon, so a KO is a won game only when +that bench is genuinely empty. Otherwise it scores as what it actually is — a +one-Pokémon material lead, far below a win. + +That killed the collapse. + +## The honest part + +Here is the sentence this whole post exists to carry: **the corrected search is a +weaker player than the buggy one was.** + +The phantom KO had been inducing helpful aggression. On this format, on this team +library, chasing the KO of the visible active turns out to be a decent policy — +it just was not a decent *reason*. Removing it dropped expectimax from ~61% to +~50% against the heuristic. That comparison is from the v1 library only and can +never be re-measured; the buggy model no longer exists to run. + +We kept the correct model anyway. A baseline that measures what it claims to +measure is worth more than a meta-specific accident that happens to score well, +because the accident does not survive a change of format and you cannot tell +which of your other results it is quietly propping up. + +## Where the sweep stands now + +The sweep has since been re-measured twice — once when the team library changed +(v1 → v2, EV spreads and natures), and once when the engine did (a damage- +rounding fix that moved every damage figure in the format, and therefore every +game played on it). + +Expectimax win rate vs the heuristic, 240 games per depth — 6 teams × 20 seeds × +2 orientations: + +| Depth | v1 teams, pre-fix engine | v2 teams, pre-fix engine | v2 teams, current engine | current Wilson 95% CI | +|---|---:|---:|---:|---| +| 1 | 50% | 54.4% | **48.1%** | [41.9%, 54.4%] | +| 2 | 40% | 42.9% | **36.7%** | [30.8%, 42.9%] | +| 3 | 38% | 42.1% | **42.1%** | [36.0%, 48.4%] | + +**Only the last two columns are comparable with each other.** The first is on a +different team library *as well as* a different engine; it is kept for continuity, +not as a control. (All three columns are post-`013f82e` — "pre-fix engine" refers +to the damage-rounding fix, not to the phantom KO.) + +Reproduce the current column with: + +``` +go run ./cmd/bench -agents heuristic,expectimax -depth N -games 20 +``` + +What survived both changes: expectimax is weaker than the heuristic at every +depth past 1, and buying more depth does not buy it back. The d1→d2 drop is the +robust part — **11.4 points** on the current engine against **11.5** on the +pre-fix one. Two runs agreeing to a tenth of a point across an engine change that +moved every damage roll in the format is about as much confirmation as a result +in this document gets, and in both the intervals only graze (41.9–42.9% now, +48–49% before). + +**The tail is not a result.** On the pre-fix engine d2 and d3 sat on top of each +other (42.9% and 42.1%); on the corrected damage chain they separate, with d2 the +low point and d3 recovering most of the way back. Their intervals still overlap +across 36.0–42.9%, so this is one run's worth of suggestion. Do not read a "d2 is +specifically bad" story into it — we don't. Correspondingly, the d1→d3 slope +shrank from 12.3 points to 6.0, which is why an earlier claim that the *slope* +was stable has been dropped: what is stable is the sign and the first step, not +the magnitude across all three. + +One library-specific note, since these numbers should always be quoted with the +`team_library` version attached: at depth 1 expectimax now edges *ahead* of the +heuristic (54.4%) on the trained teams, where on the neutral v1 teams it drew +level. The default `bench` depth is 2, where it still loses at 42.9%, so the +headline baseline ordering is unaffected. (Both of those figures are the v2 +teams, pre-fix engine column.) + +## The residual + +The gentle slope that remains — d1 > d2 ≈ d3 — has a known cause, and it is the +second defect in the same opponent model. **The simulated foe never switches.** +We do not know its hidden species and will not fabricate them, so the search +plans against an opponent more pinned down than the real one. On a 6v6 format +where switching is central, that is a real distortion. It is no longer +catastrophic, and fixing it properly means modelling unknown switch-ins — a +larger change we have not made. + +So expectimax stays in the pool as a legitimate strong baseline opponent, and it +is still not scored against as an optimality reference. The benchmark reports +outcome-based metrics only — win rate, Elo, and confidence intervals — which need +no oracle at all. Reviving per-move regret would first require a search that can +model a foe that switches. + +## The generalisable bit + +The bug was not in the search. Expectimax was correct; the evaluation was +correct; the depth loop was correct. The bug was at the seam where an +imperfect-information view was cast into a perfect-information state, and +specifically in the one predicate where "what I can see" and "what is true" are +not interchangeable: **terminality**. + +If you are searching over a reconstructed state, the reconstruction's job is to +be *wrong in bounded ways*. Getting a damage roll slightly wrong costs you a +fraction of a point. Getting "the game is over" wrong costs you `1e6`, and a +deeper search is simply a more effective machine for finding whatever your +evaluation over-rewards. Audit the terminal predicate against ground truth before +you audit anything else — and treat non-monotonicity in depth as the smoke alarm +it is, because a search that gets worse with compute is telling you it is +optimising something other than the game. + +--- + +Full methodology, scope, and the rest of the limitations: +[docs/benchmark.md](benchmark.md) — §6 is the source for every figure above. How +to run it: [docs/running-the-benchmark.md](running-the-benchmark.md). The +damage-rounding fix referenced above is OPEN-3 in +[docs/engine-findings.md](engine-findings.md). The project itself: +[PokéArena](../README.md). From 94ea1f19756d0d0b9c0e43094bb4714316df12f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:32:42 +0000 Subject: [PATCH 05/12] gitignore: the pokearena-env binary New command, same convention as the other root-dropped build artifacts: 'go build ./cmd/pokearena-env' leaves a 5.9M binary in the repo root and it should not be committable. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9e452847..ef942728 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /pvp-smoke /pokearena-agent /pokearena-mcp +/pokearena-env /scratch-agent # local environment From ed59e949cccb7b6f6b6e42ae05747868ffcaa879 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:40:30 +0000 Subject: [PATCH 06/12] Release artifacts and an MCP Registry record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo had no tags and no releases, which is not just untidy: the official MCP Registry only accepts artifacts from GitHub or GitLab releases, so PokeArena was structurally ineligible for the place MCP clients actually browse. Nobody could find it there because it could not be listed there. server.json targets the 2025-12-11 schema. There is no Go registry type, so the binary ships as an MCPB bundle built from a release artifact, per the registry's own guidance for compiled servers. The description is 95 of its 100 permitted characters and leads with 'playable' on purpose: a search for a Pokemon MCP server returns a crowd of PokeAPI data wrappers, and the thing worth saying about this one is that an agent takes a seat and plays. GoReleaser builds the three user-facing commands across linux/darwin/windows and amd64/arm64, matching the Dockerfile's flags. cmd/bench ships as 'pokearena-bench' — a file called 'bench' in a downloads folder tells you nothing. Registry publishing is a separate job so a registry outage cannot cost the binaries. Also ignore /dist/, which goreleaser writes to. --- .github/workflows/release.yml | 290 ++++++++++++++++++++++++++++ .gitignore | 2 + .goreleaser.yaml | 142 ++++++++++++++ docs/publishing.md | 351 ++++++++++++++++++++++++++++++++++ server.json | 35 ++++ 5 files changed, 820 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml create mode 100644 docs/publishing.md create mode 100644 server.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..10001def --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,290 @@ +name: Release + +# Cuts a GitHub Release from a `v*` tag: cross-platform binaries for the three +# user-facing commands, a sha256 checksum file, and the `.mcpb` bundle the +# MCP Registry entry points at. Then publishes `io.github.shaumik/pokearena` +# to registry.modelcontextprotocol.io. +# +# Everything here runs on the default GITHUB_TOKEN plus GitHub's OIDC issuer. +# No signing, no notarization, no repository secrets to configure. +# +# Go version and setup steps track .github/workflows/ci.yml — keep them in sync. +# Operator runbook (how to cut a tag, what still needs a human): docs/publishing.md + +on: + push: + tags: + - "v*" + # Lets the owner re-run the artifact build for an existing tag without + # re-tagging. The registry publish is skipped on this path (see its `if`). + workflow_dispatch: + inputs: + tag: + description: "Existing tag to (re)build artifacts for, e.g. v0.1.0" + required: true + type: string + +permissions: + contents: read + +env: + GO_VERSION: "1.26" + +jobs: + release: + name: Build and release artifacts + runs-on: ubuntu-latest + permissions: + contents: write # create the release, upload assets + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + mcpb_sha256: ${{ steps.mcpb.outputs.sha256 }} + mcpb_url: ${{ steps.mcpb.outputs.url }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # GoReleaser needs full history + tags to build the changelog. + fetch-depth: 0 + ref: ${{ inputs.tag || github.ref }} + + - name: Resolve version + id: meta + run: | + set -euo pipefail + TAG="${{ inputs.tag || github.ref_name }}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + # Fail fast on a tag that does not even compile, before we start + # publishing anything under it. + - name: Build + run: go build ./... + + - name: GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Package the MCP server as an MCPB bundle (https://github.com/anthropics/mcpb). + # This is the artifact the MCP Registry serves: registryType `mcpb`, hosted + # on a GitHub release. manifest_version 0.3 selects the executable per OS + # but not per architecture, so macOS gets the universal (amd64+arm64) + # binary GoReleaser produced, and Linux/Windows get amd64. + - name: Build .mcpb bundle + id: mcpb + run: | + set -euo pipefail + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + BUNDLE="${GITHUB_WORKSPACE}/dist/pokearena-mcp-${VERSION}.mcpb" + rm -f "$BUNDLE" + + darwin=$(find dist -type f -path '*darwin_all*' -name 'pokearena-mcp' | head -1) + linux=$(find dist -type f -path '*linux_amd64*' -name 'pokearena-mcp' | head -1) + windows=$(find dist -type f -path '*windows_amd64*' -name 'pokearena-mcp.exe' | head -1) + for f in "$darwin" "$linux" "$windows"; do + if [ -z "$f" ]; then echo "::error::missing pokearena-mcp build in dist/"; exit 1; fi + done + + work="$(mktemp -d)" + mkdir -p "$work/server" + install -m 0755 "$darwin" "$work/server/pokearena-mcp-darwin" + install -m 0755 "$linux" "$work/server/pokearena-mcp-linux" + install -m 0755 "$windows" "$work/server/pokearena-mcp-windows.exe" + cp LICENSE "$work/LICENSE" + + cat > "$work/manifest.json" <> "$GITHUB_OUTPUT" + echo "url=https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/pokearena-mcp-${VERSION}.mcpb" >> "$GITHUB_OUTPUT" + { + echo "### MCPB bundle" + echo "" + echo '```' + echo "file: $(basename "$BUNDLE")" + echo "size: $(du -h "$BUNDLE" | cut -f1)" + echo "sha256: ${SHA}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # server.json is committed with the previous release's identifier/hash; + # the published copy is regenerated here so version, download URL and + # fileSha256 always describe *this* tag. Uploaded as a release asset so + # the record is auditable next to the artifact it describes. + - name: Render published server.json + run: | + set -euo pipefail + jq --arg v "${{ steps.meta.outputs.version }}" \ + --arg id "${{ steps.mcpb.outputs.url }}" \ + --arg sha "${{ steps.mcpb.outputs.sha256 }}" ' + .version = $v + | .packages = [ + .packages[] + | if .registryType == "mcpb" + then .identifier = $id | .version = $v | .fileSha256 = $sha + else . + end + ] + ' server.json > dist/server.json + diff <(jq -S . server.json) <(jq -S . dist/server.json) || true + + - name: Upload bundle and manifest to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release upload "${{ steps.meta.outputs.tag }}" \ + "dist/pokearena-mcp-${{ steps.meta.outputs.version }}.mcpb" \ + dist/server.json \ + --clobber + + - name: Add .mcpb to checksums.txt + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "${{ steps.mcpb.outputs.sha256 }} pokearena-mcp-${{ steps.meta.outputs.version }}.mcpb" >> dist/checksums.txt + gh release upload "${{ steps.meta.outputs.tag }}" dist/checksums.txt --clobber + + publish-registry: + name: Publish to the MCP Registry + needs: release + # Only a real tag push publishes. A manual artifact rebuild must not + # re-publish a version the registry already has. + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # GitHub OIDC — the only credential mcp-publisher needs + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.tag }} + + - name: Render server.json for this tag + run: | + set -euo pipefail + jq --arg v "${{ needs.release.outputs.version }}" \ + --arg id "${{ needs.release.outputs.mcpb_url }}" \ + --arg sha "${{ needs.release.outputs.mcpb_sha256 }}" ' + .version = $v + | .packages = [ + .packages[] + | if .registryType == "mcpb" + then .identifier = $id | .version = $v | .fileSha256 = $sha + else . + end + ] + ' server.json > server.published.json + cat server.published.json + + - name: Install mcp-publisher + run: | + set -euo pipefail + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \ + | tar xz mcp-publisher + ./mcp-publisher --help + + - name: Validate server.json + run: ./mcp-publisher validate server.published.json + + # `github-oidc` exchanges the Actions OIDC token for a registry token. + # It grants the io.github.shaumik/* namespace because the token's + # repository_owner is `shaumik`. No secret is involved. + - name: Authenticate to the MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish + run: ./mcp-publisher publish server.published.json + + - name: Confirm the registry has it + run: | + set -euo pipefail + sleep 5 + curl -fsS "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.shaumik/pokearena" \ + | jq '.servers[] | {name: .name, version: .version}' diff --git a/.gitignore b/.gitignore index ef942728..9a8bc369 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ # go artifacts vendor/ +# goreleaser build output +/dist/ *.out coverage.html diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..1fc602df --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,142 @@ +# GoReleaser config for PokéArena release artifacts. +# https://goreleaser.com — schema: `goreleaser schema`. +# +# Triggered by .github/workflows/release.yml on a `v*` tag. It cross-compiles +# the three user-facing commands, tars/zips them per platform, and writes a +# sha256 checksum file. Everything here works with the default GITHUB_TOKEN — +# no signing, no notarization, no external secrets. +# +# Build flags mirror the Dockerfile (`CGO_ENABLED=0 go build -trimpath +# -ldflags="-s -w"`) so a released binary is byte-comparable with a locally +# built one. No `-X main.version=...` injection: no command in this tree +# declares a version variable, so there is nothing to stamp. +version: 2 + +project_name: pokearena + +before: + hooks: + - go mod download + +builds: + # The MCP server Claude Code (or any MCP client) spawns as a subprocess. + # See docs/mcp-protocol.md — it runs on the user's machine and dials the + # arena gateway over WebSocket. + - id: pokearena-mcp + main: ./cmd/pokearena-mcp + binary: pokearena-mcp + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + mod_timestamp: "{{ .CommitTimestamp }}" + + # Reference LLM harness: headless CLI that plays a slot end to end. + - id: pokearena-agent + main: ./cmd/pokearena-agent + binary: pokearena-agent + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + mod_timestamp: "{{ .CommitTimestamp }}" + + # The benchmark (./cmd/bench). Renamed on the way out so the artifact is + # self-describing once it lands in someone's ~/Downloads; the package path + # and all docs still say `./cmd/bench`. + - id: pokearena-bench + main: ./cmd/bench + binary: pokearena-bench + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -s -w + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + mod_timestamp: "{{ .CommitTimestamp }}" + +# A single fat Mach-O for the MCPB bundle. An .mcpb declares one command per +# OS (manifest_version 0.3 has no per-architecture selector), so the macOS +# entry has to run on both Apple Silicon and Intel. `replace: false` keeps the +# per-arch binaries for the normal archives. +universal_binaries: + - id: pokearena-mcp-universal + ids: + - pokearena-mcp + name_template: pokearena-mcp + replace: false + +archives: + - id: pokearena + ids: + - pokearena-mcp + - pokearena-agent + - pokearena-bench + name_template: "pokearena_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + files: + - README.md + - LICENSE + - docs/mcp-protocol.md + - docs/running-the-benchmark.md + - docs/benchmark.md + +checksum: + name_template: checksums.txt + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - Merge pull request + - Merge branch + +release: + github: + owner: shaumik + name: PokeArena + draft: false + prerelease: auto + mode: append + name_template: "v{{ .Version }}" + header: | + ## PokéArena {{ .Tag }} + + Three binaries per platform, in one archive: + + | Binary | What it is | + |---|---| + | `pokearena-mcp` | stdio MCP server — hands a live battle slot to Claude Code or any MCP client ([protocol](https://github.com/shaumik/PokeArena/blob/main/docs/mcp-protocol.md)) | + | `pokearena-agent` | reference LLM harness — one-shot headless CLI that plays a slot to completion | + | `pokearena-bench` | the reproducible battle benchmark (`./cmd/bench`) | + footer: | + --- + + **MCP clients:** this release also ships `pokearena-mcp-{{ .Version }}.mcpb`, + the bundle referenced by the [MCP Registry](https://registry.modelcontextprotocol.io) + entry `io.github.shaumik/pokearena`. Verify it against `checksums.txt` before installing. + + Binaries are unsigned. On macOS, clear the quarantine attribute with + `xattr -d com.apple.quarantine ./pokearena-mcp` if Gatekeeper blocks the first run. diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 00000000..0287a3cd --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,351 @@ +# Publishing PokéArena + +An operator runbook for cutting a release and getting PokéArena in front of the +people (and agents) who go shopping for MCP servers. + +Most of this is automated by [`.github/workflows/release.yml`](../.github/workflows/release.yml). +The parts that are not are the parts that need *your* credentials or *your* +GitHub identity — those are collected in [What still needs a human](#what-still-needs-a-human) +at the bottom. + +--- + +## 0. The one-paragraph version + +The repo has zero tags today, and that is the single thing blocking discovery. +The [official MCP Registry](https://registry.modelcontextprotocol.io) only lists +servers whose artifacts live on a GitHub/GitLab release, and pkg.go.dev only +shows a Go module once it has a semver tag. **Pushing `v0.1.0` fixes both at +once**: the release workflow builds the binaries, wraps `pokearena-mcp` in an +`.mcpb` bundle, attaches everything to the GitHub release, and publishes +`io.github.shaumik/pokearena` to the registry over GitHub OIDC — no secrets +required. + +--- + +## 1. Cut `v0.1.0` + +From a clean checkout of the commit you want to release: + +```bash +# 1. Sanity: the tag must build and pass the same gates CI runs. +go build ./... +make lint +make test + +# 2. Confirm you are on the commit you mean to ship. +git log --oneline -1 +git status --porcelain # must be empty + +# 3. Annotated tag. GoReleaser reads the tag message into the release notes. +git tag -a v0.1.0 -m "PokéArena v0.1.0 — first public release" + +# 4. Push it. This is the trigger; everything after is automatic. +git push origin v0.1.0 +``` + +Watch it run: + +```bash +gh run watch --repo shaumik/PokeArena +gh release view v0.1.0 --repo shaumik/PokeArena +``` + +**If you need to redo a tag** (only ever before anyone has downloaded it — the +MCP Registry treats a published version as immutable): + +```bash +git tag -d v0.1.0 +git push origin :refs/tags/v0.1.0 +gh release delete v0.1.0 --repo shaumik/PokeArena --yes +# then re-tag and re-push +``` + +Once `v0.1.0` is published on the registry, the next release must be `v0.1.1` +or later — you cannot overwrite a version in place. + +--- + +## 2. What the workflow does for you + +`.github/workflows/release.yml` has two jobs. + +### Job `release` — artifacts (permissions: `contents: write`) + +1. **GoReleaser** (config: [`.goreleaser.yaml`](../.goreleaser.yaml)) cross-compiles + three binaries for linux/darwin/windows × amd64/arm64, using the same flags + as the Dockerfile (`CGO_ENABLED=0 -trimpath -ldflags="-s -w"`): + + | Package | Released binary | + |---|---| + | `./cmd/pokearena-mcp` | `pokearena-mcp` | + | `./cmd/pokearena-agent` | `pokearena-agent` | + | `./cmd/bench` | `pokearena-bench` — renamed on the way out so the artifact is self-describing in someone's `~/Downloads`; the package path stays `./cmd/bench` | + + All three ship in **one archive per platform** (`pokearena_0.1.0_darwin_arm64.tar.gz`, + `…_windows_amd64.zip`, …) alongside `README.md`, `LICENSE`, and the two docs + a newcomer needs. `checksums.txt` (sha256) covers every archive. + +2. **`.mcpb` bundle.** `pokearena-mcp-0.1.0.mcpb` is an + [MCP Bundle](https://github.com/anthropics/mcpb) — a zip with `manifest.json` + at the root and the server binaries under `server/`. This is the artifact the + MCP Registry entry points at. + + MCPB `manifest_version` 0.3 selects an executable **per OS but not per + architecture**, so the bundle ships a macOS *universal* binary (arm64 + amd64 + fused by GoReleaser) plus linux/amd64 and windows/amd64. Linux and Windows + **arm64** users should take the platform tarball instead of the bundle. + +3. **Rendered `server.json`.** The committed `server.json` is a template: its + `fileSha256` is a placeholder of 64 zeros and its `identifier` points at + whatever the last release was. The workflow regenerates it with this tag's + version, download URL, and real SHA-256, and uploads the result to the + release as `server.json` so the published record is auditable next to the + artifact it describes. + +### Job `publish-registry` — the MCP Registry (permissions: `id-token: write`) + +Downloads the official [`mcp-publisher`](https://github.com/modelcontextprotocol/registry) +CLI, then: + +``` +mcp-publisher validate server.published.json +mcp-publisher login github-oidc +mcp-publisher publish server.published.json +``` + +`login github-oidc` exchanges the GitHub Actions OIDC token for a registry +token. The registry grants the `io.github.shaumik/*` namespace because the OIDC +claim's `repository_owner` is `shaumik`. **There is no secret to configure** — +just the `id-token: write` permission, which the job already declares. + +The job is deliberately *separate* from the artifact job and runs only on a real +tag push: if the registry is down or rejects the payload, the binaries are still +released, and you can re-run just the publish by hand (below). A +`workflow_dispatch` re-run rebuilds artifacts for an existing tag without +re-publishing to the registry. + +--- + +## 3. Publishing to the MCP Registry by hand + +Only needed if the `publish-registry` job fails, or you want to push a metadata +fix without cutting a new tag. + +### 3.1 Install the CLI + +```bash +# macOS / Linux +curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" \ + | tar xz mcp-publisher && sudo mv mcp-publisher /usr/local/bin/ + +# or +brew install mcp-publisher + +mcp-publisher --help +``` + +### 3.2 Fill in the real artifact hash + +The committed `server.json` carries a placeholder hash. **Publishing it as-is +would ship a record whose hash never matches the download**, and MCP clients +verify that hash before running the bundle. Render a real one: + +```bash +VERSION=0.1.0 +gh release download "v${VERSION}" --repo shaumik/PokeArena \ + --pattern "pokearena-mcp-${VERSION}.mcpb" --dir /tmp + +# macOS: shasum -a 256 / Linux: sha256sum +SHA=$(shasum -a 256 "/tmp/pokearena-mcp-${VERSION}.mcpb" | cut -d' ' -f1) +URL="https://github.com/shaumik/PokeArena/releases/download/v${VERSION}/pokearena-mcp-${VERSION}.mcpb" + +jq --arg v "$VERSION" --arg id "$URL" --arg sha "$SHA" ' + .version = $v + | .packages = [ + .packages[] + | if .registryType == "mcpb" + then .identifier = $id | .version = $v | .fileSha256 = $sha + else . end + ] +' server.json > /tmp/server.published.json + +mcp-publisher validate /tmp/server.published.json +``` + +(Or simply `gh release download v0.1.0 --pattern server.json` — the workflow +already uploaded the rendered file.) + +### 3.3 Authenticate as `shaumik` + +The server name is `io.github.shaumik/pokearena`, so the registry requires proof +that you control the GitHub account `shaumik`. Interactive device flow: + +```bash +mcp-publisher login github +# → open https://github.com/login/device and enter the printed code +``` + +Nothing is scoped to the repo — the registry only reads your identity. It never +reads or writes your code. + +### 3.4 Publish and verify + +```bash +mcp-publisher publish /tmp/server.published.json + +curl -s "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.shaumik/pokearena" \ + | jq '.servers[] | {name, version}' +``` + +### Troubleshooting + +| Error | Fix | +|---|---| +| `You do not have permission to publish this server` | You authenticated as someone other than `shaumik`. `mcp-publisher logout`, then `login github` again. | +| `Invalid or expired Registry JWT token` | Re-run `mcp-publisher login github`. | +| `invalid audience` | Your `mcp-publisher` binary predates the current registry deployment. Reinstall it. | +| `expected length <= 100` on `description` | The schema caps `description` at 100 characters. Keep it short. | +| Version already exists | Versions are immutable. Bump to `v0.1.1`. | + +--- + +## 4. The `server.json` contract + +Targeted schema — pinned in the file's `$schema` field: + +``` +https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json +``` + +Required top-level fields are exactly `name`, `description`, `version`; a +package (or remote) entry is required in practice — every server currently in +the registry has one. Each package requires `registryType`, `identifier`, and +`transport`. + +References, all checked against the live registry: + +- [server.json format specification](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/generic-server-json.md) +- [Official registry requirements](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/official-registry-requirements.md) — namespace auth, ownership verification, allowed registry base URLs +- [Supported package types](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/package-types.mdx) — `npm`, `pypi`, `nuget`, `cargo`, `oci`, `mcpb` +- [Quickstart: publish a server](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx) +- [Automate publishing with GitHub Actions](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/github-actions.mdx) +- [About the registry](https://modelcontextprotocol.io/registry/about) + +### Why `mcpb` and not something else + +There is **no Go registry type**. The registry supports npm, PyPI, NuGet, Cargo, +OCI, and MCPB. For a compiled binary with no runtime, the registry's own +guidance is MCPB: *"prebuilt binary distributed via GitHub or GitLab Releases. +End users need no toolchain."* MCPB artifacts must be hosted on +`https://github.com` or `https://gitlab.com` releases, the URL must contain the +string `mcp` (ours does, twice), and the record must carry `fileSha256`. + +An OCI image on `ghcr.io` is the other viable path and would additionally +require a `Dockerfile` carrying +`LABEL io.modelcontextprotocol.server.name="io.github.shaumik/pokearena"`. +Worth considering later if Docker-first users show up; it is not needed now. + +### Optional hardening: pin the repository ID + +`repository.id` lets the registry detect a delete-and-recreate of the repo. +It needs an authenticated API call, so it is not in the committed file: + +```bash +ID=$(gh api repos/shaumik/PokeArena --jq '.id') +jq --arg id "$ID" '.repository.id = $id' server.json > server.tmp && mv server.tmp server.json +``` + +--- + +## 5. Where else to submit + +### pkg.go.dev — automatic, no submission + +pkg.go.dev indexes any public Go module the moment the module proxy sees a +semver tag. The module path is `github.com/shaumik/PokeArena` (note the capitals +— the proxy escapes them as `!poke!arena`). The proxy already knows the repo but +has **no tagged versions**, which is exactly why the docs page 404s today. + +After pushing `v0.1.0`, nudge the proxy so indexing happens in minutes instead +of hours: + +```bash +GOPROXY=https://proxy.golang.org GO111MODULE=on \ + go list -m github.com/shaumik/PokeArena@v0.1.0 + +# verify the proxy sees it +curl -s 'https://proxy.golang.org/github.com/shaumik/!poke!arena/@v/list' +``` + +Then check . Nothing else is +required — there is no submission form. + +### MCP directories + +| Where | How to submit | +|---|---| +| **Official MCP Registry** | Automated by the release workflow. Browse: | +| **punkpeye/awesome-mcp-servers** (the large one) | Pull request against — add one line under a fitting category. Read its `README.md` header for the exact line format before opening the PR. | +| **wong2/awesome-mcp-servers / mcpservers.org** | Does **not** take PRs. Use the web form at | +| **Glama** | — indexes public GitHub repos automatically; submit/claim from the site. | +| **Smithery** | — connect the GitHub repo. | +| **PulseMCP** | | + +For any of these, lead with the positioning that makes PokéArena *not* the 139th +PokéAPI wrapper: it is a **playable environment** — the agent takes a trainer +slot in a real 6v6 game under fog of war, against a human or another agent — and +it doubles as a reproducible benchmark. + +### Claude Code plugin marketplace + +Two different things, don't confuse them: + +- **The official directory** (`anthropics/claude-plugins-official`) — submit via + the form at . Entries are + reviewed against quality and security standards. +- **Community directories** — e.g. and + . + +Both expect a **plugin**, not just an MCP server: a repo containing +`.claude-plugin/plugin.json` plus an `.mcp.json` pointing at the server. That +scaffolding does not exist in this repo yet — see the checklist below. The +format is documented at +. + +--- + +## What still needs a human + +Things this repo cannot do for you, roughly in priority order. + +- [ ] **Push the first tag.** `git tag -a v0.1.0 … && git push origin v0.1.0`. + Nothing else in this document matters until this happens. Requires push + access to `shaumik/PokeArena`. +- [ ] **Confirm Actions can write releases.** Repo → Settings → Actions → + General → *Workflow permissions*. The workflow requests `contents: write` + per-job, which works under the default *read* setting — but if the org + has disabled that, the release job fails on upload. +- [ ] **Watch the first `publish-registry` run.** OIDC needs no secret, but the + first publish of a namespace is the one most likely to surprise. If it + fails, fall back to §3 (interactive `mcp-publisher login github`) — that + step needs your GitHub credentials and cannot be automated from CI. +- [ ] **Decide the public gateway story.** `POKEARENA_GATEWAY_URL` defaults to + `ws://localhost:8080`, which means every registry visitor must run + `docker compose up` before the server can do anything. If you stand up a + hosted arena, change the default in `server.json` (and the MCPB + `user_config.gateway_url` default in the release workflow) — that single + change is the difference between "install and play" and "install, then + read a README". +- [ ] **Submit to the directories in §5.** Each needs a GitHub account or a web + form; none can be done from CI. +- [ ] **Optional: add `repository.id`** to `server.json` (§4) — one authenticated + `gh api` call. +- [ ] **Optional: add Claude Code plugin scaffolding** (`.claude-plugin/plugin.json` + + `.mcp.json`) if you want a listing in the plugin marketplaces rather + than only the MCP registry. +- [ ] **Optional: sign / notarize the macOS binaries.** Deliberately out of + scope here — it needs an Apple Developer ID and repository secrets. Until + then, macOS users may need + `xattr -d com.apple.quarantine ./pokearena-mcp` on first run; this is + called out in the release notes. diff --git a/server.json b/server.json new file mode 100644 index 00000000..b35e1620 --- /dev/null +++ b/server.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.shaumik/pokearena", + "title": "PokéArena", + "description": "Playable 6v6 Pokémon battles: claim a trainer slot and fight a human or agent under fog of war.", + "version": "0.1.0", + "websiteUrl": "https://github.com/shaumik/PokeArena#connect-your-agent-pv-agent", + "repository": { + "url": "https://github.com/shaumik/PokeArena", + "source": "github" + }, + "packages": [ + { + "registryType": "mcpb", + "registryBaseUrl": "https://github.com", + "identifier": "https://github.com/shaumik/PokeArena/releases/download/v0.1.0/pokearena-mcp-0.1.0.mcpb", + "version": "0.1.0", + "fileSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "POKEARENA_GATEWAY_URL", + "description": "WebSocket base URL of the PokéArena gateway this server dials to claim a trainer slot (ws:// or wss://). Point it at your own arena - `docker compose up` locally, or your deployed host.", + "format": "string", + "default": "ws://localhost:8080", + "placeholder": "wss://arena.example.com", + "isRequired": false, + "isSecret": false + } + ] + } + ] +} From 6029add8a2cd8c8fc005abe24ccf813a0390745b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:44:43 +0000 Subject: [PATCH 07/12] cmd/pokearena-env: drive the engine over stdio JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine is a pure function and cmd/bench already drives it in-process with no services, but both of those are reachable only from Go. The audience we want — RL and LLM-eval researchers — works in Python, and their tooling reaches for a gym.make-shaped thing. There was no way in that did not start with 'learn Go'. This is the seam: one line of JSON per request on stdin, one per response on stdout. reset / step / legal_actions / observe / close, plus an info handshake that reports protocol version, engine revision and the dataset's provenance, because a result that cannot say what produced it cannot back the reproducibility claim. It reads the embedded dataset, so it runs from any directory with no data/ on disk. Observations are built through the same fog-of-war path the arena uses rather than a second implementation: a side sees its own team in full and only the foe's active, as hp_pct with the absolute HP, stats, EVs, IVs, ability, nature and move ids withheld, and the foe's bench as a count. Tests cover determinism, divergence on a different seed, that no hidden field leaks, and illegal-action rejection. --- cmd/pokearena-env/agents.go | 65 +++ cmd/pokearena-env/env_test.go | 838 ++++++++++++++++++++++++++++++++++ cmd/pokearena-env/main.go | 450 ++++++++++++++++++ cmd/pokearena-env/protocol.go | 170 +++++++ cmd/pokearena-env/session.go | 646 ++++++++++++++++++++++++++ cmd/pokearena-env/teams.go | 145 ++++++ 6 files changed, 2314 insertions(+) create mode 100644 cmd/pokearena-env/agents.go create mode 100644 cmd/pokearena-env/env_test.go create mode 100644 cmd/pokearena-env/main.go create mode 100644 cmd/pokearena-env/protocol.go create mode 100644 cmd/pokearena-env/session.go create mode 100644 cmd/pokearena-env/teams.go diff --git a/cmd/pokearena-env/agents.go b/cmd/pokearena-env/agents.go new file mode 100644 index 00000000..0159f28f --- /dev/null +++ b/cmd/pokearena-env/agents.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" +) + +// sideSalt derives a distinct-but-deterministic seed for the side-1 agent so +// two stochastic agents in a mirror match don't move in lockstep, while keeping +// the whole game a pure function of the episode seed. +// +// The value is copied from internal/eval/match.go, where it is unexported. +// Copying it is load-bearing rather than incidental: it is what makes a +// baseline-vs-baseline episode driven through this binary byte-identical to the +// same pairing run by cmd/bench, so a number measured here is comparable to a +// number on the published board. TestMatchesEvalRunGame pins that equality. +const sideSalt = 0xA5A5A5A5A5A5A5A5 + +// agentFactory builds a fresh agent for one episode, seeded from that episode's +// seed. Deterministic agents (heuristic, fixed-depth expectimax) ignore the +// seed; the stochastic one uses it, so an episode never depends on agent state +// carried over from the previous episode. +type agentFactory func(seed uint64) ai.Agent + +// controllerSpec names who plays a side. "external" hands the side to the +// client over stdio; anything else is one of the built-in baselines, played +// in-process. +// +// The baseline names are exactly cmd/bench's -agents vocabulary, including the +// expectimax@N depth pin, so "which opponent did you train against" has the +// same answer in both tools. +const externalController = "external" + +// makeController resolves a controller name. The returned factory is nil for +// the external controller. +func makeController(name string, dex *domain.Dex, defaultDepth int) (label string, newAgent agentFactory, err error) { + switch { + case name == "" || name == externalController: + return externalController, nil, nil + case name == "random": + return "random", func(seed uint64) ai.Agent { return ai.NewRandomAgent(seed) }, nil + case name == "heuristic": + return "heuristic", func(uint64) ai.Agent { return ai.NewHeuristicAgent(dex) }, nil + case name == "expectimax": + return "expectimax", func(uint64) ai.Agent { return ai.NewExpectimaxAgentFixed(dex, defaultDepth) }, nil + case strings.HasPrefix(name, "expectimax@"): + d, err := strconv.Atoi(strings.TrimPrefix(name, "expectimax@")) + if err != nil || d < 1 { + return "", nil, fmt.Errorf("bad expectimax depth in %q (want expectimax@N, N>=1)", name) + } + label := fmt.Sprintf("expectimax-d%d", d) + return label, func(uint64) ai.Agent { return ai.NewExpectimaxAgentFixed(dex, d) }, nil + default: + return "", nil, fmt.Errorf("unknown agent %q (known: external, random, heuristic, expectimax, expectimax@N)", name) + } +} + +// baselineNames lists the built-in opponents, for `handshake`. +func baselineNames() []string { + return []string{externalController, "random", "heuristic", "expectimax", "expectimax@N"} +} diff --git a/cmd/pokearena-env/env_test.go b/cmd/pokearena-env/env_test.go new file mode 100644 index 00000000..0d08b588 --- /dev/null +++ b/cmd/pokearena-env/env_test.go @@ -0,0 +1,838 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" +) + +// --- harness -------------------------------------------------------------- + +// driver exercises the binary through the exact code path a subprocess client +// hits: a JSON line in, a JSON line out. Nothing in these tests reaches around +// the protocol to poke at the episode directly, because the protocol is the +// product. +type driver struct { + t *testing.T + srv *server +} + +func newDriver(t *testing.T) *driver { + t.Helper() + srv, err := newServer("", "", "test", 2) + if err != nil { + t.Fatalf("newServer: %v", err) + } + return &driver{t: t, srv: srv} +} + +// call sends one request and returns the decoded response. It fails the test +// if the response is not valid JSON — a client can only recover from a +// well-formed error object. +func (d *driver) call(cmd string, args any) Response { + d.t.Helper() + req := map[string]any{"cmd": cmd} + if args != nil { + req["args"] = args + } + line, err := json.Marshal(req) + if err != nil { + d.t.Fatalf("marshal request: %v", err) + } + resp, _ := d.srv.handleLine(line) + // Round-trip through JSON so the tests see exactly the bytes a client sees. + raw, err := json.Marshal(resp) + if err != nil { + d.t.Fatalf("marshal response: %v", err) + } + var out Response + if err := json.Unmarshal(raw, &out); err != nil { + d.t.Fatalf("response is not valid JSON: %v", err) + } + return out +} + +// mustCall requires success and decodes the result into dst. +func (d *driver) mustCall(cmd string, args any, dst any) { + d.t.Helper() + resp := d.call(cmd, args) + if !resp.OK { + d.t.Fatalf("%s failed: %+v", cmd, resp.Error) + } + if dst == nil { + return + } + raw, err := json.Marshal(resp.Result) + if err != nil { + d.t.Fatalf("marshal result: %v", err) + } + if err := json.Unmarshal(raw, dst); err != nil { + d.t.Fatalf("decode %s result: %v", cmd, err) + } +} + +// stepResultOf decodes a reset/step result. +func (d *driver) stepResultOf(cmd string, args any) StepResult { + d.t.Helper() + var sr StepResult + d.mustCall(cmd, args, &sr) + return sr +} + +// hasObs reports whether an observation slot actually carries one. A response +// that crossed the wire renders an absent observation as the four bytes "null" +// rather than as a nil slice, so a plain nil check would silently pass. +func hasObs(raw json.RawMessage) bool { + return len(raw) > 0 && string(raw) != "null" +} + +// firstLegal returns the first legal flat action index for a side, which is the +// same deterministic fallback the engine and eval.RunGame use. +func firstLegal(t *testing.T, sr StepResult, side int) int { + t.Helper() + legal := sr.LegalActions[side] + if len(legal) == 0 { + t.Fatalf("side %d has no legal actions at turn %d phase %s", side, sr.Turn, sr.Phase) + } + return legal[0].Index +} + +// --- determinism ---------------------------------------------------------- + +// TestDeterminism_SameSeedSameTrajectory is the headline promise: the same seed +// and the same policy produce a byte-identical game. It compares whole response +// lines, not summaries, so a divergence anywhere — an event's wording, a state +// hash, an HP figure — fails it. +func TestDeterminism_SameSeedSameTrajectory(t *testing.T) { + run := func() []string { + d := newDriver(t) + var lines []string + sr := d.stepResultOf("reset", map[string]any{ + "seed": 12345, "team": "Blitz", "agents": []string{"external", "heuristic"}, + }) + lines = append(lines, mustJSON(t, sr)) + for !sr.Terminated && !sr.Truncated { + sr = d.stepResultOf("step", map[string]any{"action": firstLegal(t, sr, 0)}) + lines = append(lines, mustJSON(t, sr)) + } + return lines + } + + a, b := run(), run() + if len(a) != len(b) { + t.Fatalf("trajectory lengths differ: %d vs %d", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("step %d differs between identical-seed runs:\n first: %s\nsecond: %s", i, a[i], b[i]) + } + } + if len(a) < 2 { + t.Fatalf("expected a multi-step game, got %d steps", len(a)) + } +} + +// TestDeterminism_DifferentSeedsDiverge guards the other direction: if every +// seed produced the same game the determinism test above would pass vacuously. +func TestDeterminism_DifferentSeedsDiverge(t *testing.T) { + trace := func(seed int) string { + d := newDriver(t) + var b strings.Builder + sr := d.stepResultOf("reset", map[string]any{ + "seed": seed, "team": "Blitz", "agents": []string{"external", "heuristic"}, + }) + for i := 0; i < 8 && !sr.Terminated && !sr.Truncated; i++ { + sr = d.stepResultOf("step", map[string]any{"action": firstLegal(t, sr, 0)}) + b.WriteString(mustJSON(t, sr.Events)) + } + return b.String() + } + if trace(1) == trace(2) { + t.Fatal("seeds 1 and 2 produced identical event logs; the seed is not reaching the engine RNG") + } +} + +// TestMatchesEvalRunGame pins this binary's driver to the benchmark's. It plays +// the same pairing two ways — through eval.RunGame (what cmd/bench runs) and +// through the stdio protocol — and requires the same winner, the same turn +// count, and the same per-decision state hashes. +// +// The pairing is random-vs-random on purpose: a stochastic agent is the only +// one that can detect a wrong seed or a wrong side-salt, and the side salt is a +// value this package copies from internal/eval. If that copy ever drifts, this +// test is what says so. +func TestMatchesEvalRunGame(t *testing.T) { + const seed = 4242 + + d := newDriver(t) + teamPicks, _, err := TeamSpec{Library: "Bastion"}.resolve(d.srv.dex, d.srv.lib) + if err != nil { + t.Fatalf("resolve team: %v", err) + } + + // The benchmark's own pairing: side 0 seeded from the game seed, side 1 + // from seed ^ sideSalt (internal/eval/match.go resolvedGame). + agents := [2]ai.Agent{ai.NewRandomAgent(seed), ai.NewRandomAgent(seed ^ sideSalt)} + want, err := eval.RunGame(d.srv.dex, agents, [2][]engine.TeamPick{teamPicks, teamPicks}, seed, 0) + if err != nil { + t.Fatalf("eval.RunGame: %v", err) + } + + // The same game over the wire, with side 0 driven externally by an + // identically seeded RandomAgent and side 1 left to the binary's own. + external := ai.NewRandomAgent(seed) + sr := d.stepResultOf("reset", map[string]any{ + "seed": seed, "team": "Bastion", "agents": []string{"external", "random"}, + }) + + var gotHashes []string + for !sr.Terminated && !sr.Truncated { + gotHashes = append(gotHashes, sr.Info.StateHash[0]) + var v ai.View + if err := json.Unmarshal(sr.Observations[0], &v); err != nil { + t.Fatalf("decode observation: %v", err) + } + act, err := external.Decide(context.Background(), v) + if err != nil { + t.Fatalf("external agent: %v", err) + } + sr = d.stepResultOf("step", map[string]any{"actions": []any{act, nil}}) + } + + if sr.Winner != want.Winner { + t.Errorf("winner: env %d, eval.RunGame %d", sr.Winner, want.Winner) + } + if sr.Turn != want.Turns { + t.Errorf("turns: env %d, eval.RunGame %d", sr.Turn, want.Turns) + } + + var wantHashes []string + for _, dec := range want.Decisions { + if dec.Side == 0 { + wantHashes = append(wantHashes, dec.StateHash) + } + } + if len(gotHashes) != len(wantHashes) { + t.Fatalf("side-0 decision points: env %d, eval.RunGame %d", len(gotHashes), len(wantHashes)) + } + for i := range gotHashes { + if gotHashes[i] != wantHashes[i] { + t.Fatalf("state hash at side-0 decision %d: env %s, eval.RunGame %s", i, gotHashes[i], wantHashes[i]) + } + } +} + +// --- fog of war ----------------------------------------------------------- + +// foeForbiddenKeys are the fields the wire projection must never carry for the +// opponent's active Pokémon. Each one is a free read the games do not give you: +// exact HP and max HP name the foe's HP investment, stats/EVs/IVs/nature are a +// damage calculator, and ability/item are inferred in canon, never announced. +var foeForbiddenKeys = []string{ + "hp", "max_hp", "stats", "evs", "ivs", "nature", "ability", "item", "last_consumed_item", +} + +// TestFogOfWar_NoHiddenFieldsLeak walks a full agent-vs-agent game and audits +// every observation on both sides: the opponent's bench must not appear at all, +// and the opponent's active must not carry any of the hidden fields. +// +// The teams are deliberately asymmetric. A mirror match would make this test +// pass for the wrong reason — every species name on the board would be on both +// teams, so a leaked bench species would look like the viewer's own. +func TestFogOfWar_NoHiddenFieldsLeak(t *testing.T) { + d := newDriver(t) + + sr := d.stepResultOf("reset", map[string]any{ + "seed": 9, + "team": map[string]any{"dex": []int{150, 149, 143}}, + "opponent_team": map[string]any{"dex": []int{6, 9, 3}}, + "agents": []string{"external", "external"}, + }) + + // Resolve each side's roster so the test knows what must not appear in the + // other side's bytes. + rosters := [2][]string{} + for side := 0; side < 2; side++ { + obs := sr.Observations[side] + if !hasObs(obs) { + t.Fatalf("side %d has no opening observation", side) + } + var v struct { + Self struct { + Team []struct{ Name string } `json:"team"` + Active int `json:"active"` + } `json:"self"` + } + if err := json.Unmarshal(obs, &v); err != nil { + t.Fatalf("decode observation: %v", err) + } + for _, p := range v.Self.Team { + rosters[side] = append(rosters[side], p.Name) + } + } + if len(rosters[0]) != 3 || len(rosters[1]) != 3 { + t.Fatalf("expected 3v3, got %d vs %d", len(rosters[0]), len(rosters[1])) + } + + checked := 0 + audit := func(sr StepResult) { + for side := 0; side < 2; side++ { + obs := sr.Observations[side] + if !hasObs(obs) { + continue + } + checked++ + auditFog(t, obs, rosters[1-side], sr.Turn, side) + } + } + + audit(sr) + for steps := 0; !sr.Terminated && !sr.Truncated; steps++ { + if steps > 400 { + t.Fatal("game did not terminate") + } + actions := make([]any, 2) + for _, side := range sr.ToMove { + actions[side] = firstLegal(t, sr, side) + } + sr = d.stepResultOf("step", map[string]any{"actions": actions}) + audit(sr) + } + if checked < 10 { + t.Fatalf("audited only %d observations; the game was too short to be evidence", checked) + } + t.Logf("audited %d observations across %d turns", checked, sr.Turn) +} + +// auditFog asserts one observation carries nothing it should not. +func auditFog(t *testing.T, obs json.RawMessage, foeRoster []string, turn, side int) { + t.Helper() + + var v struct { + Foe map[string]any `json:"foe"` + } + if err := json.Unmarshal(obs, &v); err != nil { + t.Fatalf("decode observation: %v", err) + } + for _, k := range foeForbiddenKeys { + if _, ok := v.Foe[k]; ok { + t.Fatalf("turn %d side %d: observation leaks foe.%s (value %v)", turn, side, k, v.Foe[k]) + } + } + if _, ok := v.Foe["hp_pct"]; !ok { + t.Fatalf("turn %d side %d: foe carries no hp_pct; the redaction dropped the public HP too", turn, side) + } + + // The whole opponent bench must be absent from the bytes. The active foe is + // the one name allowed through, so it is excluded from the search. + activeName, _ := v.Foe["name"].(string) + text := string(obs) + for _, name := range foeRoster { + if name == activeName { + continue + } + if strings.Contains(text, name) { + t.Fatalf("turn %d side %d: observation leaks benched opponent %q", turn, side, name) + } + } +} + +// TestFogOfWar_SingleAgentGivesNoOpponentObservation covers the other half of +// the guarantee: in the single-agent shape the opponent's observation is not +// merely redacted, it is not in the response at all. +func TestFogOfWar_SingleAgentGivesNoOpponentObservation(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 3, "team": "Spectrum", "agents": []string{"external", "expectimax@1"}, + }) + if !hasObs(sr.Observations[0]) { + t.Fatal("side 0 (external) got no observation") + } + if hasObs(sr.Observations[1]) { + t.Fatalf("side 1 is a built-in baseline but its observation was returned: %s", sr.Observations[1]) + } + if sr.LegalActions[1] != nil { + t.Fatalf("side 1's legal actions were returned: %v", sr.LegalActions[1]) + } + if sr.Info.StateHash[1] != "" { + t.Fatalf("side 1's state hash was returned: %q", sr.Info.StateHash[1]) + } +} + +// TestObserve_RejectsNothingButStillRedacts checks the standalone observe +// command goes through the same projection as a step observation. +func TestObserve_RejectsNothingButStillRedacts(t *testing.T) { + d := newDriver(t) + d.stepResultOf("reset", map[string]any{ + "seed": 5, "team": "Keystone", "agents": []string{"external", "heuristic"}, + }) + var out struct { + Side int `json:"side"` + Observation json.RawMessage `json:"observation"` + StateHash string `json:"state_hash"` + } + d.mustCall("observe", map[string]any{"side": 0}, &out) + var v struct { + Foe map[string]any `json:"foe"` + } + if err := json.Unmarshal(out.Observation, &v); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range foeForbiddenKeys { + if _, ok := v.Foe[k]; ok { + t.Fatalf("observe leaks foe.%s", k) + } + } + if out.StateHash == "" { + t.Fatal("observe returned no state hash") + } +} + +// --- legality ------------------------------------------------------------- + +// TestIllegalAction_RejectedAndStateUnchanged requires that a bad action is a +// clean, recoverable error: named code, the legal set attached, and — the part +// that matters most — an episode still sitting on the same decision point, so +// the client can simply try again. +func TestIllegalAction_RejectedAndStateUnchanged(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 11, "team": "Bruiser", "agents": []string{"external", "heuristic"}, + }) + if sr.ActionMask[0][flatSwitchBase] != 0 { + t.Fatal("expected switching to the already-active slot 0 to be illegal") + } + + resp := d.call("step", map[string]any{"action": flatSwitchBase}) // switch to the active Pokémon + if resp.OK { + t.Fatal("switching to the already-active Pokémon was accepted") + } + if resp.Error.Code != ErrIllegalAction { + t.Fatalf("error code = %q, want %q (%s)", resp.Error.Code, ErrIllegalAction, resp.Error.Message) + } + details, _ := resp.Error.Details.(map[string]any) + if _, ok := details["legal_actions"]; !ok { + t.Fatalf("illegal_action carried no legal_actions: %+v", resp.Error.Details) + } + if _, ok := details["action_mask"]; !ok { + t.Fatalf("illegal_action carried no action_mask: %+v", resp.Error.Details) + } + + // The rejection must not have advanced the battle. + after := d.stepResultOf("step", map[string]any{"action": 0}) + if after.Info.DecisionIndex != 1 { + t.Fatalf("decision_index = %d after one rejected + one accepted action, want 1", after.Info.DecisionIndex) + } + if after.Turn != 1 { + t.Fatalf("turn = %d, want 1: the rejected action moved the battle", after.Turn) + } +} + +// TestIllegalAction_OutOfRangeIndex is the other rejection path: an integer +// that is not in the discrete space at all fails at decode time, as a +// bad_request rather than an illegal_action. +func TestIllegalAction_OutOfRangeIndex(t *testing.T) { + d := newDriver(t) + d.stepResultOf("reset", map[string]any{"seed": 1, "team": "Blitz"}) + resp := d.call("step", map[string]any{"action": FlatActionCount + 5}) + if resp.OK { + t.Fatal("out-of-range action index was accepted") + } + if resp.Error.Code != ErrBadRequest { + t.Fatalf("error code = %q, want %q", resp.Error.Code, ErrBadRequest) + } +} + +// TestStep_RejectsActionForNonMovingSide keeps the single-agent contract +// honest: a client cannot reach across and play the baseline's side. +func TestStep_RejectsActionForNonMovingSide(t *testing.T) { + d := newDriver(t) + d.stepResultOf("reset", map[string]any{ + "seed": 2, "team": "Genesis", "agents": []string{"external", "heuristic"}, + }) + resp := d.call("step", map[string]any{"actions": []any{0, 0}}) + if resp.OK { + t.Fatal("an action for the baseline-controlled side was accepted") + } + if resp.Error.Code != ErrBadRequest { + t.Fatalf("error code = %q, want %q (%s)", resp.Error.Code, ErrBadRequest, resp.Error.Message) + } +} + +// TestLegalActions_MatchesEngine cross-checks the enumerated set against the +// engine's own ruling, so the mask can never advertise something the engine +// would refuse. +func TestLegalActions_MatchesEngine(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 77, "team": "Bastion", "agents": []string{"external", "heuristic"}, + }) + for steps := 0; !sr.Terminated && !sr.Truncated; steps++ { + if steps > 400 { + t.Fatal("game did not terminate") + } + var out struct { + LegalActions []LegalAction `json:"legal_actions"` + ActionMask []int `json:"action_mask"` + } + d.mustCall("legal_actions", map[string]any{"side": 0}, &out) + if len(out.LegalActions) == 0 { + t.Fatalf("turn %d: no legal actions", sr.Turn) + } + for _, la := range out.LegalActions { + if !engine.ActionAllowed(d.srv.dex, d.srv.ep.state, 0, la.Action) { + t.Fatalf("turn %d: enumerated %s but the engine refuses it", sr.Turn, describeAction(la.Action)) + } + if la.Index < 0 || la.Index >= FlatActionCount || out.ActionMask[la.Index] != 1 { + t.Fatalf("turn %d: action %s has flat index %d, not set in the mask", sr.Turn, describeAction(la.Action), la.Index) + } + if la.Label == "" { + t.Fatalf("turn %d: action %s has no label", sr.Turn, describeAction(la.Action)) + } + } + sr = d.stepResultOf("step", map[string]any{"action": out.LegalActions[0].Index}) + } +} + +// --- full games ----------------------------------------------------------- + +// TestFullGame_ReachesTermination plays complete games against each built-in +// baseline and checks the terminal contract: a winner, a nonzero turn count, +// a final observation, an empty to_move, and a ±1 reward that agrees with the +// winner. +func TestFullGame_ReachesTermination(t *testing.T) { + for _, opponent := range []string{"random", "heuristic", "expectimax@1"} { + t.Run(opponent, func(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 2024, "team": "Genesis", "agents": []string{"external", opponent}, + }) + steps := 0 + for !sr.Terminated && !sr.Truncated { + if steps++; steps > 1000 { + t.Fatal("game did not terminate within 1000 steps") + } + sr = d.stepResultOf("step", map[string]any{"action": firstLegal(t, sr, 0)}) + } + if !sr.Terminated { + t.Fatalf("game truncated rather than terminated at turn %d", sr.Turn) + } + if sr.Winner < 0 || sr.Winner > 2 { + t.Fatalf("winner = %d, want 0, 1 or 2", sr.Winner) + } + if sr.Turn == 0 { + t.Fatal("game ended on turn 0") + } + if len(sr.ToMove) != 0 { + t.Fatalf("to_move = %v after termination, want empty", sr.ToMove) + } + if sr.Observations[0] == nil { + t.Fatal("no final observation for the external side") + } + want := map[int]float64{0: 1, 1: -1, 2: 0}[sr.Winner] + if sr.Rewards[0] != want { + t.Fatalf("reward[0] = %v with winner %d, want %v", sr.Rewards[0], sr.Winner, want) + } + if sr.Rewards[0]+sr.Rewards[1] != 0 { + t.Fatalf("rewards %v are not zero-sum", sr.Rewards) + } + t.Logf("%s: winner=%d turns=%d steps=%d", opponent, sr.Winner, sr.Turn, steps) + }) + } +} + +// TestFullGame_ZeroExternalSidesPlaysOut covers the baseline-vs-baseline mode: +// with nobody external, reset itself plays the battle to the end. This is the +// shape the reproducibility check uses. +func TestFullGame_ZeroExternalSidesPlaysOut(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 8, "team": "Blitz", "agents": []string{"heuristic", "random"}, + }) + if !sr.Terminated { + t.Fatalf("reset with no external sides did not play out: phase %s turn %d", sr.Phase, sr.Turn) + } + if len(sr.Events) == 0 { + t.Fatal("no events from a whole battle") + } + if hasObs(sr.Observations[0]) || hasObs(sr.Observations[1]) { + t.Fatal("observations were returned for sides nobody external controls") + } +} + +// TestTruncation_MaxTurns checks the client-imposed time limit reports as a +// truncation rather than a termination, per the Gymnasium distinction. +func TestTruncation_MaxTurns(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 6, "team": "Bastion", "agents": []string{"external", "heuristic"}, "max_turns": 3, + }) + for !sr.Terminated && !sr.Truncated { + sr = d.stepResultOf("step", map[string]any{"action": firstLegal(t, sr, 0)}) + } + if !sr.Truncated { + t.Fatalf("expected truncation at max_turns=3, got terminated=%v turn=%d", sr.Terminated, sr.Turn) + } + if sr.Terminated { + t.Fatal("a turn-limit stop must not report as terminated") + } + if sr.Turn < 3 { + t.Fatalf("truncated at turn %d, want >= 3", sr.Turn) + } + if sr.Info.TurnLimit != 3 { + t.Fatalf("info.turn_limit = %d, want 3", sr.Info.TurnLimit) + } + resp := d.call("step", map[string]any{"action": 0}) + if resp.OK || resp.Error.Code != ErrEpisodeOver { + t.Fatalf("stepping a finished episode: ok=%v err=%+v, want %s", resp.OK, resp.Error, ErrEpisodeOver) + } +} + +// TestRewardHPDelta checks the opt-in dense reward is zero-sum and actually +// moves when damage happens. +func TestRewardHPDelta(t *testing.T) { + d := newDriver(t) + sr := d.stepResultOf("reset", map[string]any{ + "seed": 31, "team": "Blitz", "agents": []string{"external", "heuristic"}, "reward": "hp_delta", + }) + var total float64 + nonzero := false + for !sr.Terminated && !sr.Truncated { + sr = d.stepResultOf("step", map[string]any{"action": firstLegal(t, sr, 0)}) + if sr.Rewards[0]+sr.Rewards[1] != 0 { + t.Fatalf("hp_delta rewards %v are not zero-sum at turn %d", sr.Rewards, sr.Turn) + } + if sr.Rewards[0] != 0 { + nonzero = true + } + total += sr.Rewards[0] + } + if !nonzero { + t.Fatal("hp_delta produced no nonzero step reward across a whole game") + } + t.Logf("hp_delta return = %.3f, winner = %d", total, sr.Winner) +} + +// --- protocol ------------------------------------------------------------- + +// TestProtocol_ErrorsAreObjectsNotCrashes walks the failure surface. Every one +// of these must come back as a well-formed error object; none may kill the +// process or produce a non-JSON line. +func TestProtocol_ErrorsAreObjectsNotCrashes(t *testing.T) { + d := newDriver(t) + + cases := []struct { + name string + line string + code string + }{ + {"malformed json", `{"cmd":"reset"`, ErrBadRequest}, + {"not an object", `[1,2,3]`, ErrBadRequest}, + {"no cmd", `{"args":{}}`, ErrBadRequest}, + {"unknown cmd", `{"cmd":"teleport"}`, ErrUnknownCommand}, + {"step before reset", `{"cmd":"step","args":{"action":0}}`, ErrNoEpisode}, + {"observe before reset", `{"cmd":"observe"}`, ErrNoEpisode}, + {"legal_actions before reset", `{"cmd":"legal_actions"}`, ErrNoEpisode}, + {"reset with no team", `{"cmd":"reset","args":{"seed":1}}`, ErrBadRequest}, + {"reset with unknown team", `{"cmd":"reset","args":{"team":"Nonesuch"}}`, ErrBadRequest}, + {"reset with unknown agent", `{"cmd":"reset","args":{"team":"Blitz","agents":["external","oracle"]}}`, ErrBadRequest}, + {"reset with one agent", `{"cmd":"reset","args":{"team":"Blitz","agents":["external"]}}`, ErrBadRequest}, + {"reset with bad reward", `{"cmd":"reset","args":{"team":"Blitz","reward":"vibes"}}`, ErrBadRequest}, + {"reset with unknown dex number", `{"cmd":"reset","args":{"team":{"dex":[99999]}}}`, ErrBadRequest}, + {"reset with typo'd arg", `{"cmd":"reset","args":{"team":"Blitz","sead":1}}`, ErrBadRequest}, + {"reset with two team routes", `{"cmd":"reset","args":{"team":{"library":"Blitz","dex":[1]}}}`, ErrBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp, stop := d.srv.handleLine([]byte(tc.line)) + if stop { + t.Fatal("a failing request asked the loop to stop") + } + if resp.OK { + t.Fatalf("expected failure, got ok with %v", resp.Result) + } + if resp.Error == nil { + t.Fatal("failure carried no error object") + } + if resp.Error.Code != tc.code { + t.Fatalf("code = %q, want %q (%s)", resp.Error.Code, tc.code, resp.Error.Message) + } + if resp.Error.Message == "" { + t.Fatal("error carried no message") + } + if _, err := json.Marshal(resp); err != nil { + t.Fatalf("error response does not marshal: %v", err) + } + }) + } +} + +// TestServe_EndToEndOverPipes drives the real serve loop over an io.Reader / +// io.Writer pair — the same loop main() hands stdin and stdout — and checks the +// output is one JSON object per line, ids echoed, close honoured, and nothing +// written after close. +func TestServe_EndToEndOverPipes(t *testing.T) { + srv, err := newServer("", "", "test", 2) + if err != nil { + t.Fatalf("newServer: %v", err) + } + in := strings.NewReader(strings.Join([]string{ + `{"id":"a","cmd":"handshake"}`, + ``, // a blank line is padding, not a request + `{"id":"b","cmd":"reset","args":{"seed":1,"team":"Blitz"}}`, + `{"id":"c","cmd":"step","args":{"action":0}}`, + `{"id":"d","cmd":"close"}`, + `{"id":"e","cmd":"handshake"}`, // must never be read + }, "\n") + "\n") + + var out strings.Builder + if err := srv.serve(in, &out); err != nil { + t.Fatalf("serve: %v", err) + } + lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("got %d response lines, want 4 (close must stop the loop):\n%s", len(lines), out.String()) + } + wantIDs := []string{"a", "b", "c", "d"} + for i, line := range lines { + var r Response + if err := json.Unmarshal([]byte(line), &r); err != nil { + t.Fatalf("line %d is not JSON: %v\n%s", i, err, line) + } + if !r.OK { + t.Fatalf("line %d failed: %+v", i, r.Error) + } + var id string + if err := json.Unmarshal(r.ID, &id); err != nil || id != wantIDs[i] { + t.Fatalf("line %d id = %s, want %q", i, r.ID, wantIDs[i]) + } + } +} + +// TestActionEncoding_RoundTrips pins the flat discrete space. The mapping is +// part of the protocol: renumbering it would silently change what every integer +// in a saved trajectory means. +func TestActionEncoding_RoundTrips(t *testing.T) { + if FlatActionCount != 11 { + t.Fatalf("action space size = %d, want 11 (4 moves + Struggle + 6 switches)", FlatActionCount) + } + for i := 0; i < FlatActionCount; i++ { + a, err := decodeFlat(i) + if err != nil { + t.Fatalf("decodeFlat(%d): %v", i, err) + } + if got := encodeFlat(a); got != i { + t.Fatalf("encodeFlat(decodeFlat(%d)) = %d", i, got) + } + } + if _, err := decodeFlat(-1); err == nil { + t.Fatal("decodeFlat(-1) should fail") + } + if _, err := decodeFlat(FlatActionCount); err == nil { + t.Fatalf("decodeFlat(%d) should fail", FlatActionCount) + } + if got := encodeFlat(engine.Action{Kind: engine.ActionMove, Index: engine.StruggleMoveIndex}); got != flatStruggle { + t.Fatalf("Struggle encodes to %d, want %d", got, flatStruggle) + } +} + +// TestActionInput_AcceptsBothEncodings checks the object form is accepted +// alongside the integer form, including the self-switch pivot target that only +// the object form can express. +func TestActionInput_AcceptsBothEncodings(t *testing.T) { + var flat ActionInput + if err := json.Unmarshal([]byte(`6`), &flat); err != nil { + t.Fatalf("integer form: %v", err) + } + if flat.Kind != engine.ActionSwitch || flat.Index != 1 { + t.Fatalf("6 decoded to %s", describeAction(flat.Action)) + } + + var obj ActionInput + if err := json.Unmarshal([]byte(`{"kind":"move","index":2,"switch_target":3}`), &obj); err != nil { + t.Fatalf("object form: %v", err) + } + if obj.Kind != engine.ActionMove || obj.Index != 2 || obj.SwitchTarget == nil || *obj.SwitchTarget != 3 { + t.Fatalf("object form decoded to %s", describeAction(obj.Action)) + } + + var bad ActionInput + if err := json.Unmarshal([]byte(`{"kind":"forfeit","index":0}`), &bad); err == nil { + t.Fatal("an unknown action kind was accepted") + } +} + +// TestTeamSpec_Shorthands covers the two shorthands, since they are what people +// actually type. +func TestTeamSpec_Shorthands(t *testing.T) { + var byName TeamSpec + if err := json.Unmarshal([]byte(`"Genesis"`), &byName); err != nil || byName.Library != "Genesis" { + t.Fatalf("bare string: %v %+v", err, byName) + } + var byDex TeamSpec + if err := json.Unmarshal([]byte(`[150,149]`), &byDex); err != nil || len(byDex.Dex) != 2 { + t.Fatalf("bare array: %v %+v", err, byDex) + } + var obj TeamSpec + if err := json.Unmarshal([]byte(`{"dex":[6,9,3]}`), &obj); err != nil || len(obj.Dex) != 3 { + t.Fatalf("object: %v %+v", err, obj) + } + var empty TeamSpec + if err := json.Unmarshal([]byte(`{}`), &empty); err != nil || !empty.IsZero() { + t.Fatalf("empty: %v %+v", err, empty) + } +} + +// TestReset_CustomPicksAreValidated makes sure the raw-picks escape hatch still +// goes through engine.ValidateTeam rather than trusting the client. +func TestReset_CustomPicksAreValidated(t *testing.T) { + d := newDriver(t) + resp := d.call("reset", map[string]any{ + "seed": 1, + "team": map[string]any{"picks": []map[string]any{ + {"dex_no": 150, "moves": []string{"splash-that-does-not-exist"}}, + }}, + }) + if resp.OK { + t.Fatal("a team with an illegal move was accepted") + } + if resp.Error.Code != ErrBadRequest { + t.Fatalf("code = %q, want %q (%s)", resp.Error.Code, ErrBadRequest, resp.Error.Message) + } +} + +// TestReset_RestartsCleanly checks a second reset does not inherit anything +// from the first — the property that makes a training loop's episodes +// independent. +func TestReset_RestartsCleanly(t *testing.T) { + d := newDriver(t) + args := map[string]any{"seed": 99, "team": "Spectrum", "agents": []string{"external", "heuristic"}} + + first := d.stepResultOf("reset", args) + for i := 0; i < 5 && !first.Terminated && !first.Truncated; i++ { + first = d.stepResultOf("step", map[string]any{"action": firstLegal(t, first, 0)}) + } + second := d.stepResultOf("reset", args) + if second.Turn != 0 || second.Info.DecisionIndex != 0 { + t.Fatalf("reset returned turn %d decision %d, want 0/0", second.Turn, second.Info.DecisionIndex) + } + + fresh := newDriver(t).stepResultOf("reset", args) + if mustJSON(t, second) != mustJSON(t, fresh) { + t.Fatal("a reset after a partial episode differs from a reset in a fresh process") + } +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} diff --git a/cmd/pokearena-env/main.go b/cmd/pokearena-env/main.go new file mode 100644 index 00000000..3d33b122 --- /dev/null +++ b/cmd/pokearena-env/main.go @@ -0,0 +1,450 @@ +// Command pokearena-env exposes the PokéArena battle engine as a +// line-oriented JSON environment over stdin/stdout — one JSON request object +// per line in, one JSON response object per line out. +// +// It exists so the engine is reachable from outside Go without a server. The +// engine itself is a pure function `(state, actionP1, actionP2) -> (state, +// events)`; this binary is the thinnest honest wrapper around that: no network +// listener, no database, no data directory (the dataset is embedded), no +// background goroutines. A client — the `pokearena` Python package is the +// reference one — spawns it as a subprocess, writes a line, reads a line. +// +// Three properties are the product, and each is enforced here rather than +// assumed: +// +// - Determinism. The same seed, teams and controllers produce a +// byte-identical battle, including every observation's state hash. Seeding +// goes through exactly the path cmd/bench uses, so a number measured here +// is comparable to a number on the published board. +// - Fog of war. A side's observation is `ai.View` marshaled — the same single +// redaction path the MCP server and the PvP WebSocket serialize through. +// The opponent's bench is not in the bytes, and neither is the active foe's +// exact HP, ability, item, stats, spread or move PP. +// - No panics, no bare exits. Every failure — a malformed line, an unknown +// command, an illegal action, a panic inside the engine — comes back as a +// JSON error object on stdout. +// +// Usage: +// +// pokearena-env # embedded dataset, embedded team library +// pokearena-env -data data # read the dataset from disk instead +// pokearena-env -teams my-teams.json # swap the team library +// +// The wire contract is documented in docs/python-env.md. +package main + +import ( + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" +) + +// maxLineBytes bounds one request line. A reset carrying two fully specified +// 6-Pokémon rosters is a few kilobytes; 8 MiB is far past any legitimate +// request and keeps a runaway producer from exhausting memory. +const maxLineBytes = 8 << 20 + +func main() { + var ( + dataDir = flag.String("data", "", "dataset directory (default: the dataset embedded in this binary)") + teamsPath = flag.String("teams", "", "competitive team library JSON (default: the library embedded in this binary)") + dataVer = flag.String("data-version", "embedded", "label recorded as the dataset version") + depth = flag.Int("depth", 2, "default fixed search depth for the expectimax baseline") + printProto = flag.Bool("protocol-version", false, "print the stdio protocol version and exit") + ) + flag.Parse() + + if *printProto { + fmt.Println(ProtocolVersion) + return + } + + srv, err := newServer(*dataDir, *teamsPath, *dataVer, *depth) + if err != nil { + // A startup failure is the one thing that cannot be reported as a + // response object — there is no request to answer yet. Say so on + // stderr, in a form a wrapper can surface verbatim, and exit nonzero. + fmt.Fprintf(os.Stderr, "pokearena-env: %v\n", err) + os.Exit(1) + } + if err := srv.serve(os.Stdin, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "pokearena-env: %v\n", err) + os.Exit(1) + } +} + +// server holds the per-process, read-only world (dataset + team library) and +// the single live episode. One process is one environment instance; a client +// that wants N parallel environments runs N processes, which is what keeps +// every episode's RNG stream trivially isolated from every other. +type server struct { + dex *domain.Dex + lib *eval.TeamLibrary + prov eval.Provenance + dataVer string + depth int + + ep *episode +} + +func newServer(dataDir, teamsPath, dataVer string, depth int) (*server, error) { + dex, err := loadDex(dataDir, dataVer) + if err != nil { + return nil, fmt.Errorf("load dataset: %w", err) + } + lib, err := loadTeamLibrary(teamsPath, dex) + if err != nil { + return nil, fmt.Errorf("load team library: %w", err) + } + prov, err := loadProvenance(dataDir) + if err != nil { + return nil, fmt.Errorf("load provenance: %w", err) + } + if depth < 1 { + depth = 1 + } + return &server{dex: dex, lib: lib, prov: prov, dataVer: dataVer, depth: depth}, nil +} + +// serve runs the request loop until stdin closes or a `close` command arrives. +// Reaching either is a clean exit, not an error: a wrapper that closes the pipe +// is the normal way this process ends. +func (s *server) serve(in io.Reader, out io.Writer) error { + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 0, 64<<10), maxLineBytes) + + enc := json.NewEncoder(out) + enc.SetEscapeHTML(false) // species and move names are plain text; keep them readable + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue // blank lines are padding, not requests + } + resp, stop := s.handleLine([]byte(line)) + if err := enc.Encode(resp); err != nil { + return fmt.Errorf("write response: %w", err) + } + if f, ok := out.(interface{ Sync() error }); ok { + // os.Stdout is unbuffered, but a caller may hand us something that + // isn't; flush so a client blocked on a read never deadlocks. + _ = f.Sync() + } + if stop { + return nil + } + } + if err := scanner.Err(); err != nil { + if errors.Is(err, bufio.ErrTooLong) { + return fmt.Errorf("request line exceeded %d bytes", maxLineBytes) + } + return fmt.Errorf("read stdin: %w", err) + } + return nil +} + +// handleLine parses and dispatches one request, converting any panic into an +// internal error response. It returns the response plus whether the loop should +// stop. +func (s *server) handleLine(line []byte) (resp Response, stop bool) { + var req Request + if err := json.Unmarshal(line, &req); err != nil { + return Response{OK: false, Error: errorf(ErrBadRequest, "malformed request line: %v", err)}, false + } + + defer func() { + // A panic inside the engine is a bug, but it must not take the process + // down: the client would see a closed pipe and no explanation. Turn it + // into an error response naming the command that caused it. + if r := recover(); r != nil { + resp = Response{ID: req.ID, Cmd: req.Cmd, OK: false, + Error: errorf(ErrInternal, "panic handling %q: %v", req.Cmd, r)} + stop = false + // The episode is no longer trustworthy after a panic mid-resolve; + // drop it so the next reset starts clean rather than compounding. + s.ep = nil + } + }() + + result, stopNow, errObj := s.dispatch(req) + if errObj != nil { + return Response{ID: req.ID, Cmd: req.Cmd, OK: false, Error: errObj}, false + } + return Response{ID: req.ID, Cmd: req.Cmd, OK: true, Result: result}, stopNow +} + +func (s *server) dispatch(req Request) (result any, stop bool, errObj *ErrorObject) { + switch req.Cmd { + case "handshake", "info": + return s.handshake(), false, nil + case "reset": + r, e := s.reset(req.Args) + return r, false, e + case "step": + r, e := s.step(req.Args) + return r, false, e + case "legal_actions": + r, e := s.legalActions(req.Args) + return r, false, e + case "observe": + r, e := s.observe(req.Args) + return r, false, e + case "close": + s.ep = nil + return map[string]any{"closed": true}, true, nil + case "": + return nil, false, errorf(ErrBadRequest, `request has no "cmd"`) + default: + return nil, false, errorf(ErrUnknownCommand, "unknown command %q (known: handshake, reset, step, legal_actions, observe, close)", req.Cmd) + } +} + +// --- commands ------------------------------------------------------------- + +// Handshake is the provenance record: everything a third party needs to say +// which engine, which dataset and which rules produced a trajectory. The +// benchmark makes the same promise for its runs (docs/benchmark.md §8) and this +// carries it through to the Python side, so an RL result can name its substrate +// as precisely as a benchmark result can. +type Handshake struct { + ProtocolVersion string `json:"protocol_version"` + EngineRevision string `json:"engine_revision"` + Level int `json:"level"` + Ruleset string `json:"ruleset"` + Dataset Dataset `json:"dataset"` + TeamLibrary Library `json:"team_library"` + ActionSpace ActSpace `json:"action_space"` + Agents []string `json:"agents"` + RewardModes []string `json:"reward_modes"` + Commands []string `json:"commands"` + MaxTurns int `json:"max_turns"` +} + +// Dataset names the exact data the engine is running on. +type Dataset struct { + Version string `json:"version"` + SimVersion string `json:"sim_version"` + CurationSHA string `json:"curation_sha"` + SourceGen int `json:"source_gen"` + SyncedAt string `json:"synced_at,omitempty"` + Species int `json:"species"` + Moves int `json:"moves"` + Items int `json:"items"` +} + +// Library names the team library and what is in it. +type Library struct { + Version string `json:"version"` + Teams []string `json:"teams"` + Profile string `json:"profile"` +} + +// ActSpace describes the fixed discrete action space so a client can build its +// spaces without hardcoding the layout. +type ActSpace struct { + N int `json:"n"` + MoveSlots int `json:"move_slots"` + Struggle int `json:"struggle_index"` + SwitchBase int `json:"switch_base"` + TeamSize int `json:"team_size"` +} + +func (s *server) handshake() Handshake { + return Handshake{ + ProtocolVersion: ProtocolVersion, + EngineRevision: eval.EngineRevision(), + Level: engine.Level, + Ruleset: eval.Ruleset(), + Dataset: Dataset{ + Version: s.dataVer, + SimVersion: s.prov.SimVersion, + CurationSHA: s.prov.CurationSHA, + SourceGen: s.prov.SourceGen, + SyncedAt: s.prov.SyncedAt, + Species: len(s.dex.Species), + Moves: len(s.dex.Moves), + Items: len(s.dex.Items), + }, + TeamLibrary: Library{ + Version: s.lib.Version, + Teams: teamNames(s.lib), + Profile: eval.TeamProfile(s.lib.Teams), + }, + ActionSpace: ActSpace{ + N: FlatActionCount, + MoveSlots: engine.MovesMax, + Struggle: flatStruggle, + SwitchBase: flatSwitchBase, + TeamSize: engine.TeamSize, + }, + Agents: baselineNames(), + RewardModes: []string{rewardWinLoss, rewardHPDelta}, + Commands: []string{"handshake", "reset", "step", "legal_actions", "observe", "close"}, + MaxTurns: 300, + } +} + +func (s *server) reset(raw json.RawMessage) (any, *ErrorObject) { + var a resetArgs + if e := decodeArgs(raw, &a); e != nil { + return nil, e + } + ep, errObj := newEpisode(s.dex, s.lib, s.depth, a) + if errObj != nil { + return nil, errObj + } + s.ep = ep + if e := ep.start(); e != nil { + s.ep = nil + return nil, e + } + return ep.result() +} + +func (s *server) step(raw json.RawMessage) (any, *ErrorObject) { + if s.ep == nil { + return nil, errorf(ErrNoEpisode, "no episode: call reset first") + } + if s.ep.done() { + // Checked before the arguments are even parsed: a finished episode has + // no decision point, so "which sides must act" has no answer and a + // shape complaint about the action would be the wrong diagnosis. + return nil, errorf(ErrEpisodeOver, "episode already finished (terminated=%v truncated=%v); call reset", + s.ep.state.Ended(), s.ep.truncated) + } + var a stepArgs + if e := decodeArgs(raw, &a); e != nil { + return nil, e + } + supplied, e := s.ep.collectActions(a) + if e != nil { + return nil, e + } + if e := s.ep.step(supplied); e != nil { + return nil, e + } + return s.ep.result() +} + +// collectActions normalizes the two accepted shapes of a step request into a +// side→action map. +func (ep *episode) collectActions(a stepArgs) (map[int]engine.Action, *ErrorObject) { + out := map[int]engine.Action{} + if a.Action != nil && a.Actions != nil { + return nil, errorf(ErrBadRequest, `set either "action" or "actions", not both`) + } + switch { + case a.Action != nil: + need := ep.externalToMove() + if len(need) != 1 { + return nil, errorf(ErrBadRequest, + `"action" is shorthand for the single-side case, but %d external sides must move (%v); use "actions"`, + len(need), need) + } + out[need[0]] = a.Action.Action + case a.Actions != nil: + if len(a.Actions) != 2 { + return nil, errorf(ErrBadRequest, `"actions" must be a 2-element array indexed by side (use null for a side that is not acting), got %d`, len(a.Actions)) + } + for side, in := range a.Actions { + if in != nil { + out[side] = in.Action + } + } + } + return out, nil +} + +func (s *server) legalActions(raw json.RawMessage) (any, *ErrorObject) { + if s.ep == nil { + return nil, errorf(ErrNoEpisode, "no episode: call reset first") + } + side, e := s.resolveSide(raw) + if e != nil { + return nil, e + } + if s.ep.done() { + return nil, errorf(ErrEpisodeOver, "episode finished; there are no legal actions") + } + legal := s.ep.legalFor(side) + return map[string]any{ + "side": side, + "turn": s.ep.state.Turn, + "phase": s.ep.state.Phase, + "to_move": s.ep.toMove(), + "legal_actions": legal, + "action_mask": maskOf(legal), + }, nil +} + +func (s *server) observe(raw json.RawMessage) (any, *ErrorObject) { + if s.ep == nil { + return nil, errorf(ErrNoEpisode, "no episode: call reset first") + } + side, e := s.resolveSide(raw) + if e != nil { + return nil, e + } + obs, err := s.ep.observation(side) + if err != nil { + return nil, errorf(ErrInternal, "%v", err) + } + return map[string]any{ + "side": side, + "turn": s.ep.state.Turn, + "phase": s.ep.state.Phase, + "observation": obs, + "state_hash": hashBytes(obs), + "terminated": s.ep.state.Ended(), + "truncated": s.ep.truncated, + "winner": s.ep.state.Winner, + }, nil +} + +// resolveSide reads the optional "side" argument, defaulting to the single +// external side that has to move (the unambiguous case). +func (s *server) resolveSide(raw json.RawMessage) (int, *ErrorObject) { + var a sideArgs + if e := decodeArgs(raw, &a); e != nil { + return 0, e + } + if a.Side != nil { + if *a.Side != 0 && *a.Side != 1 { + return 0, errorf(ErrBadRequest, `"side" must be 0 or 1, got %d`, *a.Side) + } + return *a.Side, nil + } + need := s.ep.externalToMove() + if len(need) == 1 { + return need[0], nil + } + for side := 0; side < 2; side++ { + if s.ep.ctrl[side].external { + return side, nil + } + } + return 0, errorf(ErrBadRequest, `no external side to default to; name one with "side"`) +} + +// decodeArgs unmarshals a command's arguments, treating an absent args block as +// an empty object so every command's arguments can be optional. +func decodeArgs(raw json.RawMessage, dst any) *ErrorObject { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() // a typo'd argument is a bug, not a silent default + if err := dec.Decode(dst); err != nil { + return errorf(ErrBadRequest, "bad args: %v", err) + } + return nil +} diff --git a/cmd/pokearena-env/protocol.go b/cmd/pokearena-env/protocol.go new file mode 100644 index 00000000..072660e6 --- /dev/null +++ b/cmd/pokearena-env/protocol.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/shaumik/PokeArena/internal/engine" +) + +// ProtocolVersion is the stdio contract's version, reported by `handshake`. +// It is a semver-ish "major.minor": a minor bump only adds optional fields, a +// major bump may remove or repurpose one. A client that pins the major and +// tolerates unknown fields keeps working across minor bumps. +// +// The contract itself is documented in docs/python-env.md; that file and this +// constant move together. +const ProtocolVersion = "1.0" + +// Request is one line of stdin: a command plus its arguments. +// +// ID is echoed back verbatim on the response so a client that pipelines +// requests can match them up. It is opaque to the server — a number, a string, +// or absent — and is carried as a raw JSON value for exactly that reason. +type Request struct { + ID json.RawMessage `json:"id,omitempty"` + Cmd string `json:"cmd"` + Args json.RawMessage `json:"args,omitempty"` +} + +// Response is one line of stdout. Exactly one of Result or Error is set. +type Response struct { + ID json.RawMessage `json:"id,omitempty"` + Cmd string `json:"cmd,omitempty"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *ErrorObject `json:"error,omitempty"` +} + +// ErrorObject is the machine-readable failure. Every failure path in this +// binary — a malformed line, an unknown command, an illegal action, even a +// panic inside the engine — surfaces as one of these. The process never exits +// nonzero on a request-level failure and never lets a panic escape, because a +// wrapper that has to parse a stack trace off stderr is not a protocol. +type ErrorObject struct { + Code string `json:"code"` + Message string `json:"message"` + // Details carries structured context where there is any — the legal action + // set for an illegal_action rejection, for instance. + Details any `json:"details,omitempty"` +} + +// Error codes. These are the stable half of the error contract; Message is +// human-facing and may be reworded. +const ( + ErrBadRequest = "bad_request" // unparseable line, or args that don't fit the command + ErrUnknownCommand = "unknown_command" // no such cmd + ErrNoEpisode = "no_episode" // step/legal_actions/observe before reset + ErrEpisodeOver = "episode_over" // step after terminated/truncated + ErrIllegalAction = "illegal_action" // the submitted action is not in the legal set + ErrInternal = "internal" // a panic or an engine-level failure +) + +func errorf(code, format string, a ...any) *ErrorObject { + return &ErrorObject{Code: code, Message: fmt.Sprintf(format, a...)} +} + +// --- Action encoding ------------------------------------------------------ +// +// Two encodings are accepted everywhere an action is taken, and both are +// emitted by `legal_actions`: +// +// - the engine's own object form, {"kind":"move","index":0}, which is the +// only one that can name a self-switch pivot target; and +// - a flat integer index into a fixed-size discrete space, which is what a +// Gymnasium `Discrete` action space needs. +// +// The flat space is deliberately fixed-size (not "however many actions are +// legal right now") so the action space is constant across the episode, which +// is what every RL library assumes. Illegal entries are masked, not renumbered +// — renumbering would make the same integer mean different things on different +// turns and quietly destroy any learned policy. + +const ( + // flatStruggle is the index of the Struggle / forced-move sentinel + // (engine.StruggleMoveIndex == -1), which the engine offers when a + // Pokémon has no usable move or is spending a recharge turn. + flatStruggle = engine.MovesMax + // flatSwitchBase is the first index of the switch block: flatSwitchBase+j + // switches to team slot j. + flatSwitchBase = engine.MovesMax + 1 + // FlatActionCount is the size of the discrete action space: + // 4 move slots + Struggle + 6 team slots = 11. + FlatActionCount = engine.MovesMax + 1 + engine.TeamSize +) + +// encodeFlat maps an engine action onto its fixed discrete index, or -1 if the +// action falls outside the space (which cannot happen for an action the engine +// itself enumerated). +func encodeFlat(a engine.Action) int { + switch a.Kind { + case engine.ActionMove: + if a.Index == engine.StruggleMoveIndex { + return flatStruggle + } + if a.Index >= 0 && a.Index < engine.MovesMax { + return a.Index + } + case engine.ActionSwitch: + if a.Index >= 0 && a.Index < engine.TeamSize { + return flatSwitchBase + a.Index + } + } + return -1 +} + +// decodeFlat maps a discrete index back to an engine action. SwitchTarget is +// left nil — the flat space has no room for a pivot target, so a self-switch +// move chosen this way lets the engine pick the incoming teammate (its +// documented default: the lowest-indexed live one). Name a target with the +// object form if you need to aim a U-turn. +func decodeFlat(i int) (engine.Action, error) { + switch { + case i >= 0 && i < engine.MovesMax: + return engine.Action{Kind: engine.ActionMove, Index: i}, nil + case i == flatStruggle: + return engine.Action{Kind: engine.ActionMove, Index: engine.StruggleMoveIndex}, nil + case i >= flatSwitchBase && i < FlatActionCount: + return engine.Action{Kind: engine.ActionSwitch, Index: i - flatSwitchBase}, nil + } + return engine.Action{}, fmt.Errorf("action index %d out of range [0,%d)", i, FlatActionCount) +} + +// ActionInput is an action as it arrives from a client: either a bare integer +// (flat index) or the engine's object form. Anything else is a bad_request. +type ActionInput struct { + engine.Action +} + +// UnmarshalJSON accepts both encodings so a client can use whichever fits. +func (a *ActionInput) UnmarshalJSON(b []byte) error { + // Integer form first: it is unambiguous (an object never parses as a + // number) and it is the common case for RL callers. + var n int + if err := json.Unmarshal(b, &n); err == nil { + act, err := decodeFlat(n) + if err != nil { + return err + } + a.Action = act + return nil + } + var obj engine.Action + if err := json.Unmarshal(b, &obj); err != nil { + return fmt.Errorf("action must be an integer in [0,%d) or an object {\"kind\":\"move\"|\"switch\",\"index\":n}: %w", FlatActionCount, err) + } + if obj.Kind != engine.ActionMove && obj.Kind != engine.ActionSwitch { + return fmt.Errorf("action kind must be %q or %q, got %q", engine.ActionMove, engine.ActionSwitch, obj.Kind) + } + a.Action = obj + return nil +} + +// LegalAction is one entry of the legal-action set. It carries every encoding +// at once so no client has to convert: the flat index for a Discrete space, +// the engine object for a faithful replay, and a human/LLM-readable label. +type LegalAction struct { + Index int `json:"index"` // flat discrete index; -1 if unrepresentable + Action engine.Action `json:"action"` + Label string `json:"label"` +} diff --git a/cmd/pokearena-env/session.go b/cmd/pokearena-env/session.go new file mode 100644 index 00000000..13681528 --- /dev/null +++ b/cmd/pokearena-env/session.go @@ -0,0 +1,646 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "hash/fnv" + "time" + + "github.com/shaumik/PokeArena/internal/ai" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" +) + +// hardDecisionCap bounds a single episode so a non-terminating battle (an +// engine bug, or two controllers that stall forever) fails loudly instead of +// hanging the client. Same value and same reasoning as eval.maxDecisions. +const hardDecisionCap = 20000 + +// Reward modes. +const ( + rewardWinLoss = "win_loss" // 0 every step; +1/-1/0 at the terminal step + rewardHPDelta = "hp_delta" // dense shaping; see episode.rewards +) + +// controller is one side's pilot for an episode. +type controller struct { + label string + external bool + agent ai.Agent +} + +// episode is one battle in progress. There is at most one per process: the +// binary is a single environment instance, and parallelism is the client's job +// (run N processes), which keeps every episode's RNG stream trivially isolated. +type episode struct { + dex *domain.Dex + state *engine.BattleState + + ctrl [2]controller + seed uint64 + battleID string + teamLabels [2]string + + reward string + budget time.Duration + maxTurns int // client-imposed truncation; 0 = only the engine's own 300-turn cap + maxDecisions int + + decisions int + truncated bool + + // hpFrac is each side's team HP fraction as of the last emitted step, the + // baseline the hp_delta shaping differences against. + hpFrac [2]float64 + // pendingEvents accumulates the engine log across every decision point + // resolved inside one client-visible step (a step may auto-advance through + // decision points that need no external input). + pendingEvents []engine.LogLine + // fallback records, per side, whether a built-in baseline's proposal had to + // be replaced by a legal one at the most recent decision point. + fallback [2]bool +} + +// --- request payloads ----------------------------------------------------- + +type resetArgs struct { + Seed *uint64 `json:"seed,omitempty"` + Team TeamSpec `json:"team"` + OpponentTeam TeamSpec `json:"opponent_team,omitempty"` + // Agents names the pilot for each side, index 0 and 1. Defaults to + // ["external","heuristic"] — the single-agent Gymnasium shape, against the + // strongest programmatic baseline on the board. + Agents []string `json:"agents,omitempty"` + ExpectimaxDepth int `json:"expectimax_depth,omitempty"` + Reward string `json:"reward,omitempty"` + MaxTurns int `json:"max_turns,omitempty"` + MaxDecisions int `json:"max_decisions,omitempty"` + BudgetMs int `json:"budget_ms,omitempty"` + BattleID string `json:"battle_id,omitempty"` +} + +type stepArgs struct { + // Action is the shorthand for the common case: exactly one external side + // has to move. + Action *ActionInput `json:"action,omitempty"` + // Actions is the general form: a 2-element array indexed by side, with + // null for a side that is not external or does not have to move. + Actions []*ActionInput `json:"actions,omitempty"` +} + +type sideArgs struct { + Side *int `json:"side,omitempty"` +} + +// --- response payloads ---------------------------------------------------- + +// StepResult is what reset and step return. Every per-side field is a +// 2-element array indexed by side, with null for a side this call says nothing +// about — which is the mechanism that keeps fog-of-war honest in the +// single-agent case: side 1's observation is simply not in the bytes. +type StepResult struct { + Turn int `json:"turn"` + Phase engine.Phase `json:"phase"` + ToMove []int `json:"to_move"` + Observations [2]json.RawMessage `json:"observations"` + LegalActions [2][]LegalAction `json:"legal_actions"` + ActionMask [2][]int `json:"action_mask"` + Rewards [2]float64 `json:"rewards"` + Terminated bool `json:"terminated"` + Truncated bool `json:"truncated"` + Winner int `json:"winner"` // -1 ongoing, 0/1 side, 2 draw + Events []engine.LogLine `json:"events"` + Info StepInfo `json:"info"` +} + +// StepInfo is the non-observation metadata. Nothing here is hidden state: the +// per-side state hashes fingerprint the *already redacted* observation bytes, +// and the rest is configuration the client supplied itself. +type StepInfo struct { + DecisionIndex int `json:"decision_index"` + Seed uint64 `json:"seed"` + BattleID string `json:"battle_id"` + Teams [2]string `json:"teams"` + Agents [2]string `json:"agents"` + StateHash [2]string `json:"state_hash"` + // Fallback[i] is true when side i's built-in baseline proposed something + // illegal at the last decision point and was replaced by the first legal + // action — the same measurable failure mode cmd/bench records. + Fallback [2]bool `json:"fallback"` + // TurnLimit is the effective truncation cap in turns (client cap, or the + // engine's own maxTurns when the client set none). + TurnLimit int `json:"turn_limit"` +} + +// --- construction --------------------------------------------------------- + +// newEpisode builds a battle from a reset request. It is a pure function of the +// arguments plus the dataset: same args, same episode, byte for byte. +func newEpisode(dex *domain.Dex, lib *eval.TeamLibrary, defaultDepth int, a resetArgs) (*episode, *ErrorObject) { + seed := uint64(0) + if a.Seed != nil { + seed = *a.Seed + } + + if a.Team.IsZero() { + return nil, errorf(ErrBadRequest, `reset needs a "team" (a library name, a list of dex numbers, or {"picks":[…]})`) + } + own, ownLabel, err := a.Team.resolve(dex, lib) + if err != nil { + return nil, errorf(ErrBadRequest, "team: %v", err) + } + foe, foeLabel := own, ownLabel + if !a.OpponentTeam.IsZero() { + foe, foeLabel, err = a.OpponentTeam.resolve(dex, lib) + if err != nil { + return nil, errorf(ErrBadRequest, "opponent_team: %v", err) + } + } + + depth := defaultDepth + if a.ExpectimaxDepth > 0 { + depth = a.ExpectimaxDepth + } + names := []string{externalController, "heuristic"} + if len(a.Agents) > 0 { + if len(a.Agents) != 2 { + return nil, errorf(ErrBadRequest, `"agents" must have exactly 2 entries (one per side), got %d`, len(a.Agents)) + } + names = a.Agents + } + + reward := a.Reward + if reward == "" { + reward = rewardWinLoss + } + if reward != rewardWinLoss && reward != rewardHPDelta { + return nil, errorf(ErrBadRequest, `"reward" must be %q or %q, got %q`, rewardWinLoss, rewardHPDelta, reward) + } + + id := a.BattleID + if id == "" { + // The same battle id cmd/bench uses. The id is cosmetic to the engine, + // but matching it keeps a trajectory recorded here textually identical + // to the same game recorded by the benchmark. + id = fmt.Sprintf("eval-%d", seed) + } + + st, berr := engine.NewBattleFromPicks(dex, id, "P0", own, "P1", foe, seed) + if berr != nil { + return nil, errorf(ErrBadRequest, "new battle: %v", berr) + } + + ep := &episode{ + dex: dex, + state: st, + seed: seed, + battleID: id, + teamLabels: [2]string{ownLabel, foeLabel}, + reward: reward, + budget: time.Duration(a.BudgetMs) * time.Millisecond, + maxTurns: a.MaxTurns, + maxDecisions: a.MaxDecisions, + } + if ep.maxDecisions <= 0 || ep.maxDecisions > hardDecisionCap { + ep.maxDecisions = hardDecisionCap + } + + // Side 1's agent is salted exactly the way eval.resolvedGame salts it, so a + // baseline-vs-baseline episode reproduces the benchmark's game. + seeds := [2]uint64{seed, seed ^ sideSalt} + for i := 0; i < 2; i++ { + label, factory, err := makeController(names[i], dex, depth) + if err != nil { + return nil, errorf(ErrBadRequest, "agents[%d]: %v", i, err) + } + ep.ctrl[i] = controller{label: label, external: factory == nil} + if factory != nil { + ep.ctrl[i].agent = factory(seeds[i]) + } + } + ep.hpFrac = [2]float64{ep.teamHP(0), ep.teamHP(1)} + return ep, nil +} + +// --- driving -------------------------------------------------------------- + +// toMove lists the sides that owe an action at the current decision point. +func (ep *episode) toMove() []int { + if ep.done() { + return nil + } + var out []int + switch ep.state.Phase { + case engine.PhaseChoosing: + out = []int{0, 1} + case engine.PhaseReplace: + for side := 0; side < 2; side++ { + if ep.state.Replace[side] { + out = append(out, side) + } + } + } + return out +} + +// externalToMove is the subset of toMove the client has to answer for. +func (ep *episode) externalToMove() []int { + var out []int + for _, side := range ep.toMove() { + if ep.ctrl[side].external { + out = append(out, side) + } + } + return out +} + +func (ep *episode) done() bool { return ep.state.Ended() || ep.truncated } + +// start advances a freshly built episode to its first client-visible decision +// point. With at least one external side that is a no-op (the opening choosing +// phase already needs the client); with none, it plays the whole battle, which +// is the baseline-vs-baseline reproduction mode. +func (ep *episode) start() *ErrorObject { + return ep.autoAdvance() +} + +// step consumes the client's actions for the current decision point, resolves +// it, and then auto-advances through any following decision points that need +// no external input (a lone baseline replacement after a faint, for instance). +func (ep *episode) step(supplied map[int]engine.Action) *ErrorObject { + if ep.done() { + return errorf(ErrEpisodeOver, "episode already finished (terminated=%v truncated=%v); call reset", ep.state.Ended(), ep.truncated) + } + ep.pendingEvents = nil + + need := ep.externalToMove() + // Validate everything before mutating anything, so a rejected action leaves + // the episode exactly where it was and the client can simply retry. + for _, side := range need { + act, ok := supplied[side] + if !ok { + return errorf(ErrBadRequest, "side %d must act this step but no action was supplied (to_move=%v)", side, ep.toMove()) + } + if e := ep.checkLegal(side, act); e != nil { + return e + } + } + for side := range supplied { + if side < 0 || side > 1 { + return errorf(ErrBadRequest, "action for side %d: sides are 0 and 1", side) + } + if !contains(need, side) { + return errorf(ErrBadRequest, "action supplied for side %d, which is not an external side to move (to_move=%v)", side, ep.externalToMove()) + } + } + + if e := ep.resolveOnce(supplied); e != nil { + return e + } + return ep.autoAdvance() +} + +// autoAdvance resolves decision points for as long as no external side has to +// act, stopping at termination or truncation. +func (ep *episode) autoAdvance() *ErrorObject { + for !ep.done() && len(ep.externalToMove()) == 0 { + if e := ep.resolveOnce(nil); e != nil { + return e + } + } + return nil +} + +// resolveOnce advances the battle by exactly one decision point. Sides listed +// in supplied use the client's action; every other side that owes one is asked +// of its built-in baseline, in side order, which is the order eval.RunGame uses +// and therefore the order a stochastic baseline's RNG stream expects. +func (ep *episode) resolveOnce(supplied map[int]engine.Action) *ErrorObject { + if ep.decisions >= ep.maxDecisions { + ep.truncated = true + return nil + } + s := ep.state + ep.fallback = [2]bool{} + + switch s.Phase { + case engine.PhaseChoosing: + var acts [2]engine.Action + for side := 0; side < 2; side++ { + a, e := ep.actionFor(side, supplied) + if e != nil { + return e + } + acts[side] = a + } + ep.pendingEvents = append(ep.pendingEvents, engine.ResolveTurn(ep.dex, s, acts)...) + + case engine.PhaseReplace: + var sw [2]*engine.Action + for side := 0; side < 2; side++ { + if !s.Replace[side] { + continue + } + a, e := ep.actionFor(side, supplied) + if e != nil { + return e + } + chosen := a + sw[side] = &chosen + } + ep.pendingEvents = append(ep.pendingEvents, engine.ResolveReplace(s, sw)...) + + default: + return errorf(ErrInternal, "battle %s in unexpected phase %q", ep.battleID, s.Phase) + } + + ep.decisions++ + if !s.Ended() && ep.maxTurns > 0 && s.Turn >= ep.maxTurns { + ep.truncated = true + } + if ep.decisions >= ep.maxDecisions && !s.Ended() { + ep.truncated = true + } + return nil +} + +// actionFor produces one side's action at the current decision point. +func (ep *episode) actionFor(side int, supplied map[int]engine.Action) (engine.Action, *ErrorObject) { + if ep.ctrl[side].external { + a, ok := supplied[side] + if !ok { + return engine.Action{}, errorf(ErrBadRequest, "no action supplied for external side %d", side) + } + return a, nil + } + return ep.baselineAction(side), nil +} + +// baselineAction asks a built-in agent for its move, over the same fog-of-war +// View an external client gets. An error or an illegal proposal is replaced by +// the first legal action and flagged — identical to eval.RunGame's behaviour, +// which is what keeps the two drivers' trajectories equal. +func (ep *episode) baselineAction(side int) engine.Action { + v := ai.MakeView(ep.state, side) + legal := ai.LegalActions(v) + + ctx := context.Background() + if ep.budget > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, ep.budget) + defer cancel() + } + + act, err := ep.ctrl[side].agent.Decide(ctx, v) + if err != nil || !isLegal(legal, act) { + if len(legal) > 0 { + act = legal[0] + } + ep.fallback[side] = true + } + return act +} + +// checkLegal rejects an action the engine would not accept. Rejection is an +// error rather than a silent substitution: a client that sends an illegal +// action has a bug, and quietly playing something else for it would corrupt +// both its training signal and its trajectory. +func (ep *episode) checkLegal(side int, act engine.Action) *ErrorObject { + if engine.ActionAllowed(ep.dex, ep.state, side, act) { + return nil + } + legal := ep.legalFor(side) + return &ErrorObject{ + Code: ErrIllegalAction, + Message: fmt.Sprintf("action %s is not legal for side %d at turn %d (phase %s)", + describeAction(act), side, ep.state.Turn, ep.state.Phase), + Details: map[string]any{ + "side": side, + "legal_actions": legal, + "action_mask": maskOf(legal), + }, + } +} + +func isLegal(legal []engine.Action, a engine.Action) bool { + for _, l := range legal { + if l.Equal(a) { + return true + } + } + return false +} + +func contains(xs []int, x int) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} + +// --- projection ----------------------------------------------------------- + +// observation renders one side's fog-of-war view as wire bytes. +// +// This does not reimplement the projection: it marshals ai.View, whose +// MarshalJSON is the single redaction path the MCP server and the PvP +// WebSocket also serialize through. The opponent's bench is absent by +// construction (MakeView only ever copies the foe's active Pokémon), and the +// active foe loses exact HP, ability, item, stats, EVs/IVs/nature and move PP +// on the way out. Reusing it is deliberate: a second implementation of +// fog-of-war is a second thing that can silently stop matching. +func (ep *episode) observation(side int) (json.RawMessage, error) { + b, err := json.Marshal(ai.MakeView(ep.state, side)) + if err != nil { + return nil, fmt.Errorf("marshal view for side %d: %w", side, err) + } + return json.RawMessage(b), nil +} + +// legalFor enumerates a side's legal actions from that side's own View, so the +// set is exactly what a client holding only the observation could have derived. +func (ep *episode) legalFor(side int) []LegalAction { + v := ai.MakeView(ep.state, side) + acts := ai.LegalActionsDex(ep.dex, v) + out := make([]LegalAction, 0, len(acts)) + for _, a := range acts { + out = append(out, LegalAction{ + Index: encodeFlat(a), + Action: a, + Label: ep.labelAction(v, a), + }) + } + return out +} + +// maskOf renders a legal set as a fixed-width 0/1 mask over the discrete action +// space — the shape RL libraries and PettingZoo's action-mask convention want. +func maskOf(legal []LegalAction) []int { + mask := make([]int, FlatActionCount) + for _, la := range legal { + if la.Index >= 0 && la.Index < FlatActionCount { + mask[la.Index] = 1 + } + } + return mask +} + +// labelAction renders an action the way a human (or an LLM) reads it, using +// only what is in that side's own View. +func (ep *episode) labelAction(v ai.View, a engine.Action) string { + switch a.Kind { + case engine.ActionSwitch: + if a.Index >= 0 && a.Index < len(v.Self.Team) { + return "switch to " + v.Self.Team[a.Index].Name + } + return fmt.Sprintf("switch to slot %d", a.Index) + case engine.ActionMove: + if a.Index == engine.StruggleMoveIndex { + return "Struggle (no usable move)" + } + act := v.Self.Team[v.Self.Active] + if a.Index >= 0 && a.Index < len(act.Moves) { + slot := act.Moves[a.Index] + name := slot.MoveID + if m, ok := ep.dex.Moves[slot.MoveID]; ok && m.Name != "" { + name = m.Name + } + label := fmt.Sprintf("use %s (%d/%d PP)", name, slot.PP, slot.MaxPP) + if a.SwitchTarget != nil && *a.SwitchTarget < len(v.Self.Team) { + label += ", pivot to " + v.Self.Team[*a.SwitchTarget].Name + } + return label + } + } + return describeAction(a) +} + +func describeAction(a engine.Action) string { + if a.SwitchTarget != nil { + return fmt.Sprintf("{kind=%s index=%d switch_target=%d}", a.Kind, a.Index, *a.SwitchTarget) + } + return fmt.Sprintf("{kind=%s index=%d}", a.Kind, a.Index) +} + +// --- results -------------------------------------------------------------- + +// teamHP is a side's remaining team HP as a fraction of its maximum. +func (ep *episode) teamHP(side int) float64 { + var hp, max int + for _, p := range ep.state.Sides[side].Team { + hp += p.HP + max += p.MaxHP + } + if max == 0 { + return 0 + } + return float64(hp) / float64(max) +} + +// rewards computes each side's reward for the step just taken. +// +// win_loss is the honest default: the battle's only real objective, +1/-1/0 at +// the terminal step and zero everywhere else. hp_delta adds dense shaping — +// the change in (own team HP fraction − foe team HP fraction) since the last +// step — and is opt-in because it reads privileged state: the foe's exact team +// HP is not in any observation. That asymmetry is normal for a training signal +// and dishonest in an observation, which is why it lives here and not there. +func (ep *episode) rewards() [2]float64 { + var r [2]float64 + if ep.reward == rewardHPDelta { + now := [2]float64{ep.teamHP(0), ep.teamHP(1)} + d0 := (now[0] - ep.hpFrac[0]) - (now[1] - ep.hpFrac[1]) + r[0], r[1] = d0, -d0 + ep.hpFrac = now + } + if ep.state.Ended() { + switch ep.state.Winner { + case 0: + r[0] += 1 + r[1] += -1 + case 1: + r[0] += -1 + r[1] += 1 + } + } + return r +} + +// result builds the client-visible snapshot. observeSides names which sides get +// an observation: the external sides that owe an action, or — once the episode +// is over — every external side, so each one sees its final state. +func (ep *episode) result() (*StepResult, *ErrorObject) { + res := &StepResult{ + Turn: ep.state.Turn, + Phase: ep.state.Phase, + ToMove: ep.toMove(), + Terminated: ep.state.Ended(), + Truncated: ep.truncated, + Winner: ep.state.Winner, + Rewards: ep.rewards(), + Events: ep.pendingEvents, + Info: StepInfo{ + DecisionIndex: ep.decisions, + Seed: ep.seed, + BattleID: ep.battleID, + Teams: ep.teamLabels, + Agents: [2]string{ep.ctrl[0].label, ep.ctrl[1].label}, + Fallback: ep.fallback, + TurnLimit: ep.effectiveTurnLimit(), + }, + } + if res.Events == nil { + res.Events = []engine.LogLine{} + } + if res.ToMove == nil { + res.ToMove = []int{} + } + + observe := ep.externalToMove() + if ep.done() { + observe = nil + for side := 0; side < 2; side++ { + if ep.ctrl[side].external { + observe = append(observe, side) + } + } + } + for _, side := range observe { + obs, err := ep.observation(side) + if err != nil { + return nil, errorf(ErrInternal, "%v", err) + } + res.Observations[side] = obs + res.Info.StateHash[side] = hashBytes(obs) + if !ep.done() { + legal := ep.legalFor(side) + res.LegalActions[side] = legal + res.ActionMask[side] = maskOf(legal) + } + } + return res, nil +} + +// effectiveTurnLimit reports the cap that will actually truncate this episode: +// the client's, when it set one below the engine's own. +func (ep *episode) effectiveTurnLimit() int { + const engineMaxTurns = 300 // engine/turn.go maxTurns; unexported there + if ep.maxTurns > 0 && ep.maxTurns < engineMaxTurns { + return ep.maxTurns + } + return engineMaxTurns +} + +// hashBytes fingerprints observation bytes with FNV-1a, rendered the same way +// eval.hashView renders a decision-point fingerprint — and over the same input, +// since eval hashes the marshaled ai.View too. Equal hashes across two runs +// mean byte-identical decision states, which is the reproducibility check. +func hashBytes(b []byte) string { + h := fnv.New64a() + _, _ = h.Write(b) + return fmt.Sprintf("%016x", h.Sum64()) +} diff --git a/cmd/pokearena-env/teams.go b/cmd/pokearena-env/teams.go new file mode 100644 index 00000000..89bde575 --- /dev/null +++ b/cmd/pokearena-env/teams.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + + pokearena "github.com/shaumik/PokeArena" + "github.com/shaumik/PokeArena/internal/domain" + "github.com/shaumik/PokeArena/internal/engine" + "github.com/shaumik/PokeArena/internal/eval" +) + +// TeamSpec is how a client names a team. Exactly one of the three routes must +// be set; they mirror what cmd/bench accepts, plus a raw-picks escape hatch: +// +// {"library":"Genesis"} a named team from the curated library +// {"dex":[150,149,26,6,9,3]} an ad-hoc dex-number list (bench -team) +// {"picks":[{"dex_no":150,"moves":[…]}]} full engine.TeamPick control +// +// Two shorthands decode into the same type, because they are what people +// actually type: a bare string is a library name, and a bare array of integers +// is a dex list. +type TeamSpec struct { + Library string `json:"library,omitempty"` + Dex []int `json:"dex,omitempty"` + Picks []engine.TeamPick `json:"picks,omitempty"` +} + +// UnmarshalJSON accepts the object form plus the two shorthands. +func (t *TeamSpec) UnmarshalJSON(b []byte) error { + var name string + if err := json.Unmarshal(b, &name); err == nil { + t.Library = name + return nil + } + var dex []int + if err := json.Unmarshal(b, &dex); err == nil { + t.Dex = dex + return nil + } + type alias TeamSpec // strip this method to avoid recursion + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return fmt.Errorf(`team must be a library name, a list of dex numbers, or an object with "library"/"dex"/"picks": %w`, err) + } + *t = TeamSpec(a) + return nil +} + +// IsZero reports whether the spec names nothing at all. +func (t TeamSpec) IsZero() bool { + return t.Library == "" && len(t.Dex) == 0 && len(t.Picks) == 0 +} + +// resolve turns a spec into a legality-checked roster and the label that names +// it in observations and provenance. +func (t TeamSpec) resolve(dex *domain.Dex, lib *eval.TeamLibrary) (picks []engine.TeamPick, label string, err error) { + set := 0 + for _, on := range []bool{t.Library != "", len(t.Dex) > 0, len(t.Picks) > 0} { + if on { + set++ + } + } + if set == 0 { + return nil, "", fmt.Errorf(`team spec is empty: set one of "library", "dex", or "picks"`) + } + if set > 1 { + return nil, "", fmt.Errorf(`team spec sets more than one of "library", "dex", "picks" — pick exactly one`) + } + + switch { + case t.Library != "": + for _, nt := range lib.Teams { + if nt.Name == t.Library { + // Library teams are validated at load; hand back a copy so a + // later episode cannot mutate the library through the slice. + return engine.ClonePicks(nt.Picks), nt.Name, nil + } + } + return nil, "", fmt.Errorf("unknown library team %q (available: %v)", t.Library, teamNames(lib)) + + case len(t.Dex) > 0: + // PicksFromDex is the same expansion cmd/bench's -team uses: first + // MovesMax moves of each species' learn list. Going through it (rather + // than engine.NewBattle's bare dex numbers) is what keeps the decision + // space competitive-sized — see internal/eval/team.go. + p, err := eval.PicksFromDex(dex, t.Dex) + if err != nil { + return nil, "", err + } + return p, "adhoc", nil + + default: + p := engine.ClonePicks(t.Picks) + if err := engine.ValidateTeam(p, dex); err != nil { + return nil, "", fmt.Errorf("custom picks: %w", err) + } + return p, "custom", nil + } +} + +func teamNames(lib *eval.TeamLibrary) []string { + names := make([]string, 0, len(lib.Teams)) + for _, t := range lib.Teams { + names = append(names, t.Name) + } + sort.Strings(names) + return names +} + +// loadTeamLibrary reads the curated team library. An explicit path reads from +// disk; the empty path reads the copy embedded at the module root (dataset.go), +// so `pokearena-env` needs no data/ directory beside it. Either way every team +// is legality-checked before it can be played — a benchmark run on an illegal +// team is meaningless. +func loadTeamLibrary(path string, dex *domain.Dex) (*eval.TeamLibrary, error) { + if path != "" { + return eval.LoadTeamLibrary(path, dex) + } + return eval.LoadTeamLibraryFS(pokearena.DataFS(), "benchmark-teams.json", dex) +} + +// loadProvenance reads the dataset's identity record. Like the team library it +// prefers an explicit dataset directory and falls back to the embedded copy, so +// `handshake` can always name the dataset that produced a trajectory. +func loadProvenance(dataDir string) (eval.Provenance, error) { + if dataDir != "" { + return eval.LoadProvenance(dataDir) + } + return eval.LoadProvenanceFS(pokearena.DataFS()) +} + +// loadDex loads the dataset: the embedded copy by default (self-contained +// binary, no data/ on disk), or a directory when one is named. +func loadDex(dataDir, version string) (*domain.Dex, error) { + if dataDir != "" { + if _, err := os.Stat(dataDir); err != nil { + return nil, fmt.Errorf("dataset directory %s: %w", dataDir, err) + } + return domain.LoadDex(dataDir, version) + } + return domain.LoadDexFS(pokearena.DataFS(), version) +} From 025a39e1fcd0163eac98af48a0d0c45a399bba2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:49:13 +0000 Subject: [PATCH 08/12] python: pip install pokearena, Gymnasium and PettingZoo shaped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine has been reachable only from Go. The people we most want using it — RL and LLM-eval researchers — work in Python, and their tooling reaches for a gym.make-shaped thing, which is why TextArena and the rest standardised on that shape. This is the way in. A Gymnasium-style single-agent env (a baseline drives the other side) and a PettingZoo-style two-agent env, both subprocessing cmd/pokearena-env. No required runtime dependencies: gymnasium, pettingzoo and numpy are extras, and the package imports and runs without any of them. A missing binary raises an actionable error naming the go install line, never a silent failure. The action space is a fixed Discrete(11) — four move slots, Struggle, six switches — never renumbered between turns, with legality carried as a mask, because an action space that renumbers is not learnable. step() now accepts every shape a caller might plausibly hold: a bare int, a legal_actions() record, that record's inner action object, or its index. It previously forwarded the whole record to the engine, which rejected it for a missing 'kind' the caller never typed — step(legal_actions()[0]) is the first line anyone writes, and it failed. A bad shape now raises a Python TypeError naming the forms that work instead of surfacing a message from the Go layer. 52 tests pass against the binary and skip — loudly, saying nothing was verified — without it. --- docs/python-env.md | 432 ++++++++++++++++++++++++++++++ python/LICENSE | 28 ++ python/README.md | 266 ++++++++++++++++++ python/pokearena/__init__.py | 130 +++++++++ python/pokearena/_binary.py | 133 +++++++++ python/pokearena/_client.py | 297 ++++++++++++++++++++ python/pokearena/_util.py | 141 ++++++++++ python/pokearena/env.py | 305 +++++++++++++++++++++ python/pokearena/errors.py | 84 ++++++ python/pokearena/parallel_env.py | 282 +++++++++++++++++++ python/pokearena/py.typed | 0 python/pokearena/spaces.py | 158 +++++++++++ python/pyproject.toml | 73 +++++ python/tests/_support.py | 69 +++++ python/tests/conftest.py | 15 ++ python/tests/test_client.py | 130 +++++++++ python/tests/test_determinism.py | 94 +++++++ python/tests/test_env.py | 209 +++++++++++++++ python/tests/test_offline.py | 159 +++++++++++ python/tests/test_parallel_env.py | 129 +++++++++ 20 files changed, 3134 insertions(+) create mode 100644 docs/python-env.md create mode 100644 python/LICENSE create mode 100644 python/README.md create mode 100644 python/pokearena/__init__.py create mode 100644 python/pokearena/_binary.py create mode 100644 python/pokearena/_client.py create mode 100644 python/pokearena/_util.py create mode 100644 python/pokearena/env.py create mode 100644 python/pokearena/errors.py create mode 100644 python/pokearena/parallel_env.py create mode 100644 python/pokearena/py.typed create mode 100644 python/pokearena/spaces.py create mode 100644 python/pyproject.toml create mode 100644 python/tests/_support.py create mode 100644 python/tests/conftest.py create mode 100644 python/tests/test_client.py create mode 100644 python/tests/test_determinism.py create mode 100644 python/tests/test_env.py create mode 100644 python/tests/test_offline.py create mode 100644 python/tests/test_parallel_env.py diff --git a/docs/python-env.md b/docs/python-env.md new file mode 100644 index 00000000..a10f54d7 --- /dev/null +++ b/docs/python-env.md @@ -0,0 +1,432 @@ +# The stdio environment protocol + +`cmd/pokearena-env` exposes the battle engine as a **line-oriented JSON +environment**: one JSON request object per line on stdin, one JSON response +object per line on stdout. No network listener, no database, no data directory +— the dataset and the team library are compiled into the binary. + +It exists so the engine is reachable from outside Go without any infrastructure. +The reference client is the [`pokearena`](../python/README.md) Python package, +which spawns the binary as a subprocess and wraps it in Gymnasium and PettingZoo +shaped APIs. Nothing in this document is Python-specific; any language that can +spawn a process and write a line can drive it. + +- **Protocol version:** `1.0` (reported by `handshake`, also `pokearena-env -protocol-version`) +- **Encoding:** UTF-8, one JSON object per line, `\n`-terminated, no embedded newlines +- **Line limit:** 8 MiB per request +- **Concurrency:** strictly one request in flight. Responses come back in order, + one per request; interleaving two writers corrupts the stream. + +``` +$ pokearena-env +{"cmd":"reset","args":{"seed":42,"team":"Genesis"}} +{"cmd":"step","args":{"action":0}} +{"cmd":"close"} +``` + +--- + +## 1. Envelope + +### Request + +| field | type | required | meaning | +|--------|--------|----------|---------| +| `cmd` | string | yes | command name | +| `args` | object | no | command arguments; absent means `{}` | +| `id` | any | no | opaque; echoed verbatim on the response so a pipelining client can match up | + +Unknown keys inside `args` are **rejected**, not ignored: a typo'd argument is a +bug, and silently defaulting it would produce a run that is not the run you +asked for. + +### Response + +| field | type | meaning | +|----------|---------|---------| +| `ok` | bool | success | +| `result` | object | present when `ok` is true | +| `error` | object | present when `ok` is false | +| `cmd` | string | the command this answers | +| `id` | any | the request's `id`, verbatim | + +### Errors + +Every failure is a response object. A malformed line, an unknown command, an +illegal action, even a panic inside the engine — none of them exits the process, +writes to stdout in another format, or leaves the client to parse a stack trace +off stderr. + +```json +{"cmd":"step","ok":false,"error":{"code":"illegal_action","message":"…","details":{…}}} +``` + +| `code` | meaning | recovery | +|--------|---------|----------| +| `bad_request` | unparseable line, or arguments that do not fit the command | fix the request | +| `unknown_command` | no such `cmd` | fix the request | +| `no_episode` | `step` / `legal_actions` / `observe` before any `reset` | call `reset` | +| `episode_over` | `step` after `terminated` or `truncated` | call `reset` | +| `illegal_action` | the action is not in the legal set | pick a legal action and step again — **the episode is untouched** | +| `internal` | a panic or engine-level failure | the episode is discarded; call `reset` | + +`illegal_action` attaches `details.legal_actions` and `details.action_mask` for +the offending side, so a client can recover without a second round trip. + +The one failure that is *not* a response object is a startup failure (a missing +`-data` directory, say): there is no request to answer yet, so the binary writes +`pokearena-env: ` to stderr and exits 1. + +--- + +## 2. Commands + +### `handshake` (alias `info`) + +No arguments. Returns the provenance record — everything needed to say which +engine, which dataset and which rules produced a trajectory. This is the same +promise the benchmark makes for its runs (see [benchmark.md §8](benchmark.md)), +carried through to environment clients. + +| field | type | meaning | +|-------|------|---------| +| `protocol_version` | string | `"major.minor"`; a minor bump only adds optional fields | +| `engine_revision` | string | VCS revision the binary was built from, `-dirty` if the tree was modified, `"unknown"` if unstamped | +| `level` | int | every Pokémon's level (50) | +| `ruleset` | string | what the format permits — EV/IV bounds, natures, items, Species Clause | +| `dataset` | object | `version`, `sim_version`, `curation_sha`, `source_gen`, `synced_at`, and the counts of `species` / `moves` / `items` | +| `team_library` | object | `version`, `teams` (names), `profile` (what the teams actually use) | +| `action_space` | object | `n`, `move_slots`, `struggle_index`, `switch_base`, `team_size` | +| `agents` | string[] | the controller names `reset` accepts | +| `reward_modes` | string[] | the reward modes `reset` accepts | +| `commands` | string[] | the commands this binary implements | +| `max_turns` | int | the engine's own turn cap (300) | + +### `reset` + +Starts a new battle. Any previous episode is discarded. + +| arg | type | default | meaning | +|-----|------|---------|---------| +| `seed` | uint64 | `0` | the battle seed. Same seed ⇒ same battle, byte for byte | +| `team` | team spec | — | **required.** Side 0's team | +| `opponent_team` | team spec | same as `team` | Side 1's team. The default is the mirror match | +| `agents` | string[2] | `["external","heuristic"]` | who pilots each side | +| `expectimax_depth` | int | `2` (or `-depth`) | search depth for a plain `expectimax` controller | +| `reward` | string | `"win_loss"` | `win_loss` or `hp_delta` | +| `max_turns` | int | `0` | truncate after this many turns; `0` leaves only the engine's 300-turn cap | +| `max_decisions` | int | `20000` | hard cap on decision points before truncation | +| `budget_ms` | int | `0` | per-decision time budget for the built-in agents; `0` = none | +| `battle_id` | string | `"eval-"` | cosmetic battle id. The default matches `cmd/bench` | + +**Team spec.** Three routes, exactly one per team: + +| form | meaning | +|------|---------| +| `{"library": "Genesis"}` or just `"Genesis"` | a curated team from the embedded library | +| `{"dex": [150,149,143]}` or just `[150,149,143]` | ad-hoc: Pokédex numbers, expanded to each species' first four moves — the same expansion `bench -team` uses | +| `{"picks": [{"dex_no":150,"moves":["psystrike",…],"nature":"timid","evs":{…}}]}` | full `engine.TeamPick` control: moves, ability, item, EVs, IVs, nature, gender | + +Every team goes through the engine's legality check (`ValidateTeam`: Species +Clause, 1–4 learnset-legal moves per Pokémon, the EV/IV budget) before a battle +starts. + +**Controllers.** `external` hands the side to the client over stdio; anything +else is a built-in agent played in-process. The names are exactly `cmd/bench`'s +`-agents` vocabulary, so "which opponent did you train against" has the same +answer in both tools. + +| name | behaviour | +|------|-----------| +| `external` | the client supplies this side's actions | +| `random` | uniform over the legal set, seeded from the battle seed | +| `heuristic` | a hand-tuned depth-0 evaluator — the strongest programmatic agent on the board, and the reference opponent | +| `expectimax` | fixed-depth expectimax at `expectimax_depth` | +| `expectimax@N` | expectimax pinned to depth *N* | + +`reset` returns a [step result](#step-result), positioned at the first decision +point where an external side has to act. With **no** external sides, `reset` +plays the whole battle and returns the terminal result — the baseline-vs-baseline +reproduction mode. + +### `step` + +Submits actions for the current decision point and advances. + +| arg | type | meaning | +|-----|------|---------| +| `action` | action | shorthand, valid only when exactly one external side must move | +| `actions` | array[2] of action-or-null | the general form, indexed by side; `null` for a side that is not acting | + +Set one or the other, never both. An action supplied for a side that is not an +external side to move is a `bad_request` — a client cannot reach across and play +the baseline's side. + +After resolving, `step` **auto-advances** through any following decision points +that need no external input (a lone baseline replacing a fainted Pokémon, for +instance), so a client is only ever asked when it actually has a choice. + +Every supplied action is validated *before* anything is resolved. A rejection +therefore leaves the episode exactly where it was. + +### `legal_actions` + +| arg | type | default | meaning | +|-----|------|---------|---------| +| `side` | int | the single external side to move | 0 or 1 | + +| result field | type | meaning | +|--------------|------|---------| +| `side` | int | the side described | +| `turn`, `phase` | int, string | where the battle is | +| `to_move` | int[] | every side that owes an action right now | +| `legal_actions` | LegalAction[] | see [§3](#3-actions) | +| `action_mask` | int[11] | 0/1 over the discrete space | + +### `observe` + +| arg | type | default | meaning | +|-----|------|---------|---------| +| `side` | int | the single external side to move | 0 or 1 | + +| result field | type | meaning | +|--------------|------|---------| +| `side` | int | the side described | +| `turn`, `phase` | int, string | where the battle is | +| `observation` | object | that side's fog-of-war view — see [§5](#5-the-fog-of-war-guarantee) | +| `state_hash` | string | FNV-1a fingerprint of the observation bytes | +| `terminated`, `truncated`, `winner` | bool, bool, int | outcome so far | + +### `close` + +No arguments. Returns `{"closed": true}` and then the process exits cleanly. +Closing stdin has the same effect, so a client that just wants the process gone +can close the pipe. + +--- + +## 3. Actions + +Two encodings are accepted everywhere, and `legal_actions` emits both. + +**Flat integer** — a fixed `Discrete(11)` for RL clients: + +| index | meaning | +|-------|---------| +| `0`–`3` | use move slot 0–3 | +| `4` | Struggle, or the forced move on a charge / recharge / Sky Drop turn | +| `5`–`10` | switch to team slot 0–5 | + +The space is deliberately **fixed-size**, not "however many actions are legal +right now". Renumbering per turn would make the same integer mean different +things at different times, which destroys both learned policies and saved +trajectories. Legality is expressed as a mask instead. + +**Object** — the engine's own form, and the only one that can aim a pivot: + +```json +{"kind": "move", "index": 2} +{"kind": "switch", "index": 3} +{"kind": "move", "index": 1, "switch_target": 4} +``` + +`switch_target` names the bench slot a self-switch move (U-turn, Volt Switch, +Flip Turn, Teleport, Baton Pass) should bring in. Omitted — and always omitted +by the flat encoding — the engine picks deterministically: the lowest-indexed +live teammate. + +**LegalAction** entries carry every encoding at once, so no client has to +convert: + +```json +{"index": 0, "action": {"kind": "move", "index": 0}, "label": "use Psystrike (10/10 PP)"} +{"index": 6, "action": {"kind": "switch", "index": 1}, "label": "switch to Dragonite"} +``` + +`label` is rendered from the viewer's own observation only, so it is safe to +show to an LLM agent verbatim. + +On the wire, only the two encodings above are accepted — a whole LegalAction +record is not an action. The Python client unwraps the record for you (so +`env.step(env.legal_actions()[0])` works), but a client written directly against +this protocol must send `record["index"]` or `record["action"]`. + +--- + +## 4. Step result + +`reset` and `step` return the same object. Every per-side field is a 2-element +array indexed by board side, with `null` for a side this response says nothing +about. + +| field | type | meaning | +|-------|------|---------| +| `turn` | int | the battle's turn counter | +| `phase` | string | `choosing`, `replace`, or `ended` | +| `to_move` | int[] | sides that owe an action; empty once the episode is over | +| `observations` | [obs\|null, obs\|null] | one per external side that must act; at the end, one per external side | +| `legal_actions` | [LegalAction[]\|null, …] | for the same sides, while the episode is live | +| `action_mask` | [int[11]\|null, …] | the same set as a 0/1 mask | +| `rewards` | [float, float] | this step's reward per side; always zero-sum | +| `terminated` | bool | the battle reached a decided end | +| `truncated` | bool | a turn or decision cap stopped it first | +| `winner` | int | `-1` ongoing, `0`/`1` the winning side, `2` draw | +| `events` | LogLine[] | every engine log line produced by this step, including any auto-advanced decision points | +| `info` | object | see below | + +`info`: + +| field | type | meaning | +|-------|------|---------| +| `decision_index` | int | decision points resolved so far | +| `seed` | uint64 | the battle seed | +| `battle_id` | string | the battle id | +| `teams` | string[2] | resolved team labels | +| `agents` | string[2] | resolved controller labels | +| `state_hash` | string[2] | FNV-1a of each returned observation's bytes; `""` for a side with no observation | +| `fallback` | bool[2] | a built-in baseline proposed something illegal and was replaced by the first legal action | +| `turn_limit` | int | the cap that will truncate this episode | + +**`terminated` vs `truncated`** follows the Gymnasium distinction. A battle that +ends because a side is wiped out, or because the engine's own 300-turn cap +decided it on remaining HP, is *terminated* — it reached a real outcome. A +battle stopped by a client-supplied `max_turns` or `max_decisions` is +*truncated*: the outcome is unknown and bootstrapping from the final value is +the correct thing to do. + +**Rewards.** `win_loss` (the default) is `0` every step and `+1` / `−1` / `0` at +the terminal step. `hp_delta` adds the per-step change in (own team HP fraction +− foe team HP fraction). That reads privileged state — the opponent's exact team +HP is deliberately absent from every observation — which is normal for a +training signal and would be dishonest in an observation. That asymmetry is why +it is opt-in and why it lives in `rewards` and nowhere else. + +--- + +## 5. The fog-of-war guarantee + +> **A side's observation contains its own team in full, and of the opponent only +> the active Pokémon, redacted. The opponent's hidden information is not present +> in the bytes.** + +This is enforced by construction rather than by filtering. An observation is +`ai.View` marshaled — the single projection path that the MCP server and the +live PvP WebSocket also serialize through. There is no second implementation +that could drift out of agreement with it. + +What the projection does: + +| aspect | what the viewer gets | +|--------|----------------------| +| own side | everything: full team, exact HP, stats, spreads, abilities, items, PP | +| opponent's bench | **nothing** — only `foe_bench_alive`, a count of unfainted benched Pokémon | +| opponent's active HP | `hp_pct`, a floored 0–100 percentage. No `hp`, no `max_hp` | +| opponent's ability | absent | +| opponent's item | absent, and the Choice-lock / Metronome / Micle / Unburden volatiles that would name one are cleared with it | +| opponent's stats, EVs, IVs, nature | absent — they are a damage calculator, and EVs+nature reconstruct exact Speed | +| opponent's moves | slot count preserved so you can see "revealed 1 of 4"; each slot carries `move_id` once revealed and nothing else. No PP | +| opponent's status, boosts, volatiles | present — these are announced publicly in the games | +| field state | weather, terrain, pseudo-weather, and the opponent's side conditions are public | +| pending foe Wish | the caster and the countdown, never the snapshotted heal amount (which would leak the caster's max HP) | + +In the single-agent shape the guarantee is even simpler: the opponent's +observation is not merely redacted, it is not in the response at all +(`observations[1]` is `null`). + +Two clients on the same battle each get their own projection. Both pass through +one process, so a script holding both can of course read both — that is the +script's own information, not a leak between the agents. + +The audit is `TestFogOfWar_NoHiddenFieldsLeak` in +`cmd/pokearena-env/env_test.go`, which walks a whole battle with deliberately +asymmetric teams (a mirror match would make a leaked bench Pokémon +indistinguishable from one of your own) and checks every observation on both +sides. + +--- + +## 6. The determinism guarantee + +> **Same seed, same teams, same controllers ⇒ byte-identical battle.** + +Every source of randomness in the engine is seeded from the battle seed, which +travels inside the battle state: + +- The engine RNG is `RNGState`, initialised from `seed`. +- Gender rolls draw from a separate stream derived from the same seed, so + introducing them could not shift any existing replay. +- The `random` baseline is seeded from the battle seed; side 1's agent is salted + by `seed ^ 0xA5A5A5A5A5A5A5A5` so two stochastic agents in a mirror do not + move in lockstep while the game stays a pure function of the seed. +- `heuristic` and fixed-depth `expectimax` are deterministic functions of the + view. `expectimax@N` pins the depth so a choice never depends on machine + speed. + +The seeding path is deliberately identical to `internal/eval`'s, which is what +`cmd/bench` runs. `TestMatchesEvalRunGame` plays the same pairing both ways — +through `eval.RunGame` and through this protocol — and requires the same winner, +the same turn count, and the same per-decision state hashes. So a number +measured through this environment is comparable to a number on the published +board, and that comparability is tested rather than asserted. + +**How to check it yourself.** `info.state_hash[side]` is the FNV-1a fingerprint +of that side's observation bytes at that decision point — the same fingerprint +`eval.Decision.StateHash` records. Collect the sequence across an episode and +compare two runs: + +```bash +$ printf '%s\n' \ + '{"cmd":"reset","args":{"seed":4242,"team":"Genesis","agents":["heuristic","random"]}}' \ + | pokearena-env > a.jsonl +$ printf '%s\n' \ + '{"cmd":"reset","args":{"seed":4242,"team":"Genesis","agents":["heuristic","random"]}}' \ + | pokearena-env > b.jsonl +$ diff a.jsonl b.jsonl && echo identical +``` + +What is *not* covered: the timestamp-free parts of the protocol are all that is +promised. The engine revision is a build stamp, not a runtime value, and two +different engine revisions may legitimately produce different battles from the +same seed — which is exactly why `handshake` names the revision. + +--- + +## 7. Running the binary + +``` +pokearena-env [flags] + + -data string dataset directory (default: the dataset embedded in the binary) + -teams string team library JSON (default: the library embedded in the binary) + -data-version string label recorded as the dataset version (default "embedded") + -depth int default expectimax search depth (default 2) + -protocol-version print the protocol version and exit +``` + +The defaults need no files on disk: the dex, the team library and the dataset +provenance record are all compiled in via the module-root `go:embed` +(`dataset.go`). `-data` and `-teams` exist for running against a modified +dataset or a custom team library. + +Install it with: + +```bash +go install github.com/shaumik/PokeArena/cmd/pokearena-env@latest +``` + +One process is one environment instance — the binary holds at most one episode +at a time, which is what keeps each episode's RNG stream trivially isolated. +Run *N* processes for *N* parallel environments. + +--- + +## 8. See also + +- [`python/README.md`](../python/README.md) — the Python client, Gymnasium and + PettingZoo APIs +- [`docs/benchmark.md`](benchmark.md) — the benchmark this environment shares + its seeding and ruleset with +- [`docs/battle-state.md`](battle-state.md) — the battle-state and move schema, + including the hidden-information contract the projection implements +- [`docs/agent-harness.md`](agent-harness.md) — the in-process agent interface + the built-in baselines implement diff --git a/python/LICENSE b/python/LICENSE new file mode 100644 index 00000000..3774f927 --- /dev/null +++ b/python/LICENSE @@ -0,0 +1,28 @@ +MIT License + +Copyright (c) 2026 Shaumik Ashraf + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Pokémon and Pokémon character names are trademarks of Nintendo, Creatures Inc., +and GAME FREAK Inc. This project is an unofficial, non-commercial fan work and +is not affiliated with or endorsed by them. The license above covers the +original code and documentation in this repository only. diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..6681b777 --- /dev/null +++ b/python/README.md @@ -0,0 +1,266 @@ +# PokéArena + +**A deterministic Pokémon battle environment for RL and LLM evaluation.** +Gymnasium and PettingZoo APIs, hidden information, no server. + +```python +from pokearena import PokeArenaEnv + +with PokeArenaEnv(team="Genesis", opponent="heuristic") as env: + obs, info = env.reset(seed=42) # seed 42 is always this battle + terminated = truncated = False + while not (terminated or truncated): + action = info["legal_actions"][0]["index"] # your policy goes here + obs, reward, terminated, truncated, info = env.step(action) + print("winner:", info["winner"], "after", info["turn"], "turns") +``` + +--- + +## Why this one + +Most game environments are stochastic in ways you cannot pin down, hand you +perfect information, and need a server, a container, or an emulator running +somewhere. This one is built the other way round. + +**Determinism is the product.** `reset(seed=k)` plays battle *k* — the same +rosters, the same RNG stream, the same damage rolls, the same outcome — every +time, on every machine. Not "approximately reproducible": the engine emits a +state hash at every decision point, and two runs of the same seed produce the +same sequence of hashes. That is what lets you diff two policies, bisect a +regression, or publish a number someone else can re-derive. + +**Variance is controlled by construction.** The default setup is a **mirror +match**: identical rosters on both sides, so the only free variable left in a +result is the policy. This is the setup the project's own benchmark runs, and +the seeding path here is the same one, so a number you measure is comparable to +a number on its published board. + +**Hidden information is real.** Each side sees its own team in full and only the +opponent's *active* Pokémon — with its exact HP, ability, held item, stats, +EVs/IVs, nature and move PP redacted, exactly as Pokémon Showdown redacts them. +The hidden data is not in the bytes your agent receives, so it cannot be read by +accident. That makes this a genuine imperfect-information game: inference about +the opponent's set is part of playing well, not an afterthought. + +**No server.** No ports, no Docker, no database, no ROM, no dataset download. +The environment is a single Go binary the Python package spawns as a subprocess +and talks to over stdin/stdout in line-delimited JSON. The dataset is compiled +into it. One process is one environment; run *N* for *N* parallel envs. + +**Zero required Python dependencies.** Gymnasium, PettingZoo and NumPy are all +optional. Install any of them and the environments subclass and use them; +install none and the same API works standalone. + +--- + +## Install + +```bash +pip install pokearena +``` + +That gets the Python client. It does **not** get the engine — the engine is a Go +binary, and this package is honest about it rather than shipping a fake +pure-Python fallback: + +```bash +go install github.com/shaumik/PokeArena/cmd/pokearena-env@latest +``` + +Make sure the install directory (`go env GOPATH`/bin, usually `~/go/bin`) is on +your `PATH`. If you would rather not install a Go toolchain, download a prebuilt +`pokearena-env` from the +[releases page](https://github.com/shaumik/PokeArena/releases) and either put it +on your `PATH` or point `POKEARENA_ENV_BIN` at it. + +If the binary is missing, the package raises `BinaryNotFoundError` with both of +those instructions and a list of everywhere it looked. It never fails silently. + +Optional extras: + +```bash +pip install "pokearena[gymnasium]" # subclass gymnasium.Env +pip install "pokearena[pettingzoo]" # subclass pettingzoo.ParallelEnv, enable aec_env() +pip install "pokearena[all]" # both, plus numpy for array action masks +``` + +--- + +## Single agent (Gymnasium style) + +The agent plays one side; a built-in baseline plays the other, in-process inside +the engine binary, seeded from the same battle seed. Current Gymnasium +conventions throughout: `reset(seed=...) -> (obs, info)` and +`step(action) -> (obs, reward, terminated, truncated, info)`. + +```python +from pokearena import PokeArenaEnv + +env = PokeArenaEnv( + team="Genesis", # a curated team, [150, 149, 143], or {"picks": [...]} + opponent="heuristic", # random | heuristic | expectimax | expectimax@3 + reward="win_loss", # or "hp_delta" for dense shaping +) +obs, info = env.reset(seed=0) +obs, reward, terminated, truncated, info = env.step(0) +env.close() +``` + +**Actions** are a fixed `Discrete(11)`: + +| index | meaning | +|-------|---------| +| 0–3 | use move slot 0–3 | +| 4 | Struggle, or the forced move on a charge/recharge turn | +| 5–10 | switch to team slot 0–5 | + +The space is fixed rather than renumbered per turn, because renumbering would +make the same integer mean different things at different times. Legality comes +as a **mask** instead — `info["action_mask"]` on every step, a NumPy `int8` +array when NumPy is installed and a plain list otherwise. `info["legal_actions"]` +carries the same set with a human-readable `label` per entry, which is what you +want when the policy is an LLM: + +```python +[{"index": 0, "action": {"kind": "move", "index": 0}, "label": "use Psystrike (10/10 PP)"}, + {"index": 6, "action": {"kind": "switch", "index": 1}, "label": "switch to Dragonite"}] +``` + +`step()` accepts every shape you might plausibly have in hand — a bare `int`, a +NumPy scalar from `argmax`, a whole legal-action record, or its nested engine +object — so `env.step(env.legal_actions()[0])` just works: + +```python +la = env.legal_actions() +env.step(la[0]) # the record +env.step(la[0]["action"]) # the engine object — the only form that can aim a pivot +env.step(la[0]["index"]) # the flat index +env.step(0) # a bare int +``` + +Anything else raises `TypeError` from Python, naming the shapes that do work, +rather than surfacing as a confusing complaint about a JSON field you never +typed. + +**Observations** are the engine's fog-of-war view, decoded from JSON: `self` +(your whole team, unredacted), `foe` (the opponent's active Pokémon, redacted), +`foe_bench_alive`, `turn`, `phase`, weather, terrain, and the opponent's public +side conditions. It is a nested dict, not a fixed-width vector — flattening it +is a modelling decision that belongs to you, and every reasonable encoding (a +text prompt, a hand-built feature vector, a set encoder) wants something +different. + +**Rewards.** `win_loss` is the default and the honest one: 0 every step, ±1 at +the end. `hp_delta` adds per-step shaping from the change in team-HP difference; +it is opt-in because it reads privileged state — the opponent's exact team HP is +deliberately not in any observation. That is normal for a training signal and +would be dishonest in an observation, which is why the two are kept apart. + +An illegal action raises `IllegalActionError` and leaves the episode exactly +where it was, so recovering is just picking a legal one and stepping again. + +--- + +## Agent versus agent (PettingZoo style) + +A PokéArena turn is **simultaneous**, so the parallel API is the faithful model: + +```python +from pokearena import PokeArenaParallelEnv + +with PokeArenaParallelEnv(team="Blitz") as env: + obs, infos = env.reset(seed=0) + while env.agents: + actions = {a: my_policy(obs[a], infos[a]) for a in env.agents_to_move} + obs, rewards, terminations, truncations, infos = env.step(actions) +``` + +Agents are `player_0` and `player_1`. Most steps ask both sides; after a faint, +only the side with the fainted Pokémon chooses a replacement — `env.agents_to_move` +and `infos[agent]["to_move"]` name whose action is actually consumed, and an +action supplied for anyone else is ignored, so the usual +`{a: policy(a) for a in env.agents}` loop is safe. Every agent always receives +its own observation, projected independently. + +`pokearena.aec_env()` returns an AEC view via PettingZoo's own conversion, for +tooling that requires one. Prefer the parallel env: AEC imposes a turn order the +game does not actually have. + +--- + +## Provenance + +Every environment exposes the engine's handshake, which is the record you quote +when you publish a number: + +```python +>>> env.engine_info +{'protocol_version': '1.0', + 'engine_revision': '5caa7fb4317c61a08991a4950d8329beafb082ce', + 'level': 50, + 'ruleset': 'L50, IVs 0-31, EVs 252 per stat / 510 total, 25 natures, held items, Species Clause, mirror match', + 'dataset': {'sim_version': '0.10.9', 'curation_sha': '55ded32e...', 'source_gen': 9, ...}, + 'team_library': {'version': 'v2', 'teams': ['Bastion', 'Blitz', 'Bruiser', 'Genesis', 'Keystone', 'Spectrum'], ...}, + ...} +``` + +Engine revision plus dataset SHA plus ruleset plus seed pins a result +completely. A trajectory can never be silently reattributed to a different +engine or a different dataset. + +--- + +## Teams + +Three ways to specify one, all accepted by `team=` and `opponent_team=`: + +```python +PokeArenaEnv(team="Genesis") # a curated, legality-checked library team +PokeArenaEnv(team=[150, 149, 143]) # ad-hoc: Pokedex numbers, first 4 moves each +PokeArenaEnv(team={"picks": [ # full control, validated before play + {"dex_no": 150, "moves": ["psystrike", "ice-beam", "recover", "aura-sphere"], + "nature": "timid", "evs": {"spatk": 252, "speed": 252, "hp": 4}}, + # ... +]}) +``` + +`opponent_team` defaults to `team` — the mirror match. Set it to make the +matchup asymmetric. Every team passes the engine's legality check (six or fewer +Pokémon, Species Clause, learnset-legal moves, the EV/IV budget) before a +battle starts; an illegal team is a clear error, never a silently corrected one. + +`env.team_library` lists the curated teams the binary carries. + +--- + +## Low-level protocol access + +`EngineClient` is the raw JSONL client if you want to build something the two +env classes do not cover: + +```python +from pokearena import EngineClient + +with EngineClient() as engine: + engine.request("reset", {"seed": 0, "team": "Genesis", "agents": ["external", "external"]}) + result = engine.request("step", {"actions": [0, 0]}) +``` + +The full wire contract — every command, every field, the determinism guarantee +and the fog-of-war guarantee — is in +[`docs/python-env.md`](https://github.com/shaumik/PokeArena/blob/main/docs/python-env.md). + +--- + +## Requirements & caveats + +- Python 3.9+. +- **A Go toolchain (1.22+) or a release binary is required** for the engine. + There is no pure-Python wheel and this package does not pretend otherwise. +- One engine process per environment instance. Vectorise by running several. +- LLM opponents are not part of this package; the built-in baselines are + deterministic Go agents (`random`, `heuristic`, `expectimax`). + +MIT licensed. Issues and contributions: +. diff --git a/python/pokearena/__init__.py b/python/pokearena/__init__.py new file mode 100644 index 00000000..41ec3dd8 --- /dev/null +++ b/python/pokearena/__init__.py @@ -0,0 +1,130 @@ +"""PokéArena — a deterministic Pokémon battle environment for Python. + +The engine is a pure function ``(state, action_p1, action_p2) -> (state, +events)`` written in Go. This package drives it as a subprocess over a +line-oriented JSON protocol on stdin/stdout: no server, no ports, no database, +no data files to download. + +What that buys you: + +* **Determinism.** ``reset(seed=k)`` plays battle ``k``, byte for byte, every + time — same events, same damage rolls, same outcome. The seeding path is the + one the project's own benchmark uses, so a number measured here is comparable + to a number on its published board. +* **Fog of war.** Each side sees its own team in full and only the opponent's + *active* Pokémon — with its exact HP, ability, item, stats, EVs/IVs, nature + and move PP redacted. The hidden information is not in the bytes at all, so + an agent cannot accidentally read it. +* **Variance control.** The default is a mirror match: identical rosters on both + sides, so the only free variable in a result is the policy. + +Quick start:: + + from pokearena import PokeArenaEnv + + with PokeArenaEnv(team="Genesis", opponent="heuristic") as env: + obs, info = env.reset(seed=0) + done = False + while not done: + action = info["legal_actions"][0]["index"] # your policy here + obs, reward, terminated, truncated, info = env.step(action) + done = terminated or truncated + print("winner:", info["winner"], "after", info["turn"], "turns") + +The engine binary is a prerequisite; see :func:`pokearena.find_binary` for how +it is located and what the error tells you if it is missing. +""" + +from ._binary import BINARY_NAME, ENV_VAR, GO_INSTALL_COMMAND, binary_version, find_binary +from ._client import DEFAULT_TIMEOUT, EngineClient +from .env import GYMNASIUM_ENV_BASE, PokeArenaEnv +from .errors import ( + BinaryNotFoundError, + EngineClosedError, + EngineError, + EngineTimeoutError, + IllegalActionError, + PokeArenaError, + ProtocolError, +) +from .parallel_env import ( + AGENTS, + PETTINGZOO_ENV_BASE, + PokeArenaParallelEnv, + aec_env, + parallel_env, +) +from .spaces import ( + ACTION_SPACE_SIZE, + GYMNASIUM_AVAILABLE, + MOVE_SLOTS, + STRUGGLE_INDEX, + SWITCH_BASE, + TEAM_SIZE, + describe_action, +) + +__version__ = "0.1.0" + +#: The stdio protocol version this client is written against. The engine +#: reports its own in the handshake; a differing major number means the two are +#: not compatible. +PROTOCOL_VERSION = "1.0" + +__all__ = [ + "__version__", + "PROTOCOL_VERSION", + # environments + "PokeArenaEnv", + "PokeArenaParallelEnv", + "parallel_env", + "aec_env", + "AGENTS", + # low level + "EngineClient", + "DEFAULT_TIMEOUT", + # binary discovery + "find_binary", + "binary_version", + "BINARY_NAME", + "ENV_VAR", + "GO_INSTALL_COMMAND", + # action space + "ACTION_SPACE_SIZE", + "MOVE_SLOTS", + "STRUGGLE_INDEX", + "SWITCH_BASE", + "TEAM_SIZE", + "describe_action", + # capability flags + "GYMNASIUM_AVAILABLE", + "GYMNASIUM_ENV_BASE", + "PETTINGZOO_ENV_BASE", + # errors + "PokeArenaError", + "BinaryNotFoundError", + "EngineError", + "IllegalActionError", + "ProtocolError", + "EngineTimeoutError", + "EngineClosedError", +] + + +def register_gymnasium_envs() -> bool: + """Register ``PokeArena-v0`` with Gymnasium's registry, if it is installed. + + Returns ``True`` when registration happened, ``False`` when Gymnasium is not + available. Calling it twice is harmless. + + This is opt-in rather than an import side effect: a library that mutates a + global registry the moment it is imported is a library that is hard to + reason about, and ``PokeArenaEnv(...)`` works perfectly well without it. + """ + try: + from gymnasium.envs.registration import register, registry # type: ignore + except Exception: + return False + if "PokeArena-v0" not in registry: + register(id="PokeArena-v0", entry_point="pokearena.env:PokeArenaEnv") + return True diff --git a/python/pokearena/_binary.py b/python/pokearena/_binary.py new file mode 100644 index 00000000..d9eeb3cc --- /dev/null +++ b/python/pokearena/_binary.py @@ -0,0 +1,133 @@ +"""Locating the ``pokearena-env`` engine binary. + +This package is a thin client; the engine itself is a Go binary. That is an +honest constraint rather than something to paper over — there is no pure-Python +Pokémon engine hiding in this wheel, and pretending otherwise would only move +the failure from install time to the middle of someone's training run. + +So: find the binary, and if it is not there, say exactly how to get it. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional + +from .errors import BinaryNotFoundError + +__all__ = ["BINARY_NAME", "ENV_VAR", "GO_INSTALL_COMMAND", "find_binary", "binary_version"] + +#: The executable this package drives. +BINARY_NAME = "pokearena-env" + +#: Environment variable naming an explicit path to it. +ENV_VAR = "POKEARENA_ENV_BIN" + +#: The one-liner that builds it. +GO_INSTALL_COMMAND = "go install github.com/shaumik/PokeArena/cmd/pokearena-env@latest" + +_REPO = "https://github.com/shaumik/PokeArena" + + +def _candidate_paths() -> List[Path]: + """Every place we look, in order. + + PATH comes first so a system-installed binary wins by default; the + environment variable is the explicit override for a build that is not on + PATH; and ``go install``'s default output directory is checked last, + because that is where the recommended install command actually puts it and + plenty of people never add it to PATH. + """ + out: List[Path] = [] + + on_path = shutil.which(BINARY_NAME) + if on_path: + out.append(Path(on_path)) + + env = os.environ.get(ENV_VAR) + if env: + out.append(Path(env).expanduser()) + + gobin = os.environ.get("GOBIN") + if gobin: + out.append(Path(gobin).expanduser() / BINARY_NAME) + + gopath = os.environ.get("GOPATH") + roots = [Path(p).expanduser() for p in gopath.split(os.pathsep)] if gopath else [] + roots.append(Path.home() / "go") + for root in roots: + out.append(root / "bin" / BINARY_NAME) + + return out + + +def _usable(path: Path) -> bool: + return path.is_file() and os.access(str(path), os.X_OK) + + +def find_binary(explicit: Optional[str] = None) -> str: + """Return the path to the engine binary. + + Args: + explicit: a path supplied by the caller, which wins over everything. + + Raises: + BinaryNotFoundError: with an actionable message naming every place that + was searched and both ways to obtain the binary. + """ + if explicit: + p = Path(explicit).expanduser() + if _usable(p): + return str(p) + raise BinaryNotFoundError( + f"{BINARY_NAME} was not found at the path you gave ({explicit!r}), " + "or it is not executable." + ) + + searched = _candidate_paths() + for p in searched: + if _usable(p): + return str(p) + + looked = "\n".join(f" {p}" for p in searched) or " (nothing on PATH)" + raise BinaryNotFoundError( + f"the PokéArena engine binary {BINARY_NAME!r} was not found.\n" + f"\n" + f"This package is a client for a Go binary — it does not contain the\n" + f"engine itself. Get the binary one of two ways:\n" + f"\n" + f" 1. With a Go toolchain (1.22+):\n" + f" {GO_INSTALL_COMMAND}\n" + f" then make sure the install directory is on your PATH\n" + f" (it is `go env GOPATH`/bin, usually ~/go/bin).\n" + f"\n" + f" 2. Download a prebuilt binary for your platform from\n" + f" {_REPO}/releases\n" + f" and either put it on your PATH or point {ENV_VAR} at it:\n" + f" export {ENV_VAR}=/path/to/{BINARY_NAME}\n" + f"\n" + f"Looked in:\n{looked}" + ) + + +def binary_version(path: Optional[str] = None, timeout: float = 10.0) -> str: + """Return the stdio protocol version the binary implements. + + Useful as a cheap liveness/compatibility check that does not start an + episode. + """ + exe = find_binary(path) + try: + out = subprocess.run( + [exe, "-protocol-version"], + capture_output=True, + text=True, + timeout=timeout, + check=True, + ) + except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover - environment specific + raise BinaryNotFoundError(f"could not run {exe}: {exc}") from exc + return out.stdout.strip() diff --git a/python/pokearena/_client.py b/python/pokearena/_client.py new file mode 100644 index 00000000..81ddaa93 --- /dev/null +++ b/python/pokearena/_client.py @@ -0,0 +1,297 @@ +"""The stdio client: one subprocess, one JSON object per line, each way. + +Everything about this module is about the subprocess never becoming a problem +of its own. A training run that spawns thousands of episodes cannot afford a +leaked process, a deadlocked read, or a stack trace on stderr swallowed into +nothing — so reads are bounded by a timeout, stderr is drained continuously +into a ring buffer that error messages can quote, and shutdown is layered +(``close`` → ``terminate`` → ``kill``) with a finalizer behind it. +""" + +from __future__ import annotations + +import atexit +import json +import os +import queue +import subprocess +import threading +import weakref +from collections import deque +from typing import Any, Dict, List, Optional + +from ._binary import find_binary +from .errors import ( + EngineClosedError, + EngineError, + EngineTimeoutError, + IllegalActionError, + ProtocolError, +) + +__all__ = ["EngineClient", "DEFAULT_TIMEOUT"] + +#: Default per-request read timeout, in seconds. Generous: an expectimax +#: opponent at depth 3 on a bad turn is the slow case, and a spurious timeout +#: is worse than a slow step. +DEFAULT_TIMEOUT = 120.0 + +#: How long to wait for the process to exit at each shutdown stage. +_SHUTDOWN_GRACE = 5.0 + +#: How many stderr lines to keep for error messages. +_STDERR_LINES = 40 + +# Live clients, so an interpreter exit cannot leave engine processes behind +# even if a caller forgot to close one. +_LIVE: "weakref.WeakSet[EngineClient]" = weakref.WeakSet() + + +@atexit.register +def _close_all() -> None: # pragma: no cover - exercised only at interpreter exit + for client in list(_LIVE): + try: + client.close() + except Exception: + pass + + +class EngineClient: + """A running ``pokearena-env`` process. + + One client is one environment instance: the binary holds at most one + episode at a time, which is what keeps each episode's RNG stream trivially + isolated. Run N clients for N parallel environments. + + Use it as a context manager, or call :meth:`close` when done:: + + with EngineClient() as engine: + engine.request("reset", {"seed": 0, "team": "Genesis"}) + """ + + def __init__( + self, + binary: Optional[str] = None, + *, + data_dir: Optional[str] = None, + teams: Optional[str] = None, + expectimax_depth: Optional[int] = None, + timeout: float = DEFAULT_TIMEOUT, + extra_args: Optional[List[str]] = None, + ): + self._timeout = float(timeout) + self._binary = find_binary(binary) + + argv = [self._binary] + if data_dir: + argv += ["-data", str(data_dir)] + if teams: + argv += ["-teams", str(teams)] + if expectimax_depth: + argv += ["-depth", str(int(expectimax_depth))] + if extra_args: + argv += list(extra_args) + self.argv = argv + + try: + self._proc: Optional[subprocess.Popen] = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line buffered: the protocol is line oriented + # A new process group would orphan the child if we were killed + # mid-run; staying in ours means a Ctrl-C reaches it too. + env=os.environ.copy(), + ) + except OSError as exc: + raise EngineClosedError(f"could not start {self._binary}: {exc}") from exc + + self._closed = False + self._lock = threading.Lock() + self._stdout_q: "queue.Queue[Optional[str]]" = queue.Queue() + self._stderr: "deque[str]" = deque(maxlen=_STDERR_LINES) + + # Reader threads. Both are daemons: they must never keep the + # interpreter alive, and both terminate on EOF when the child exits. + self._readers = [ + threading.Thread(target=self._pump_stdout, name="pokearena-stdout", daemon=True), + threading.Thread(target=self._pump_stderr, name="pokearena-stderr", daemon=True), + ] + for t in self._readers: + t.start() + + _LIVE.add(self) + + # Fail fast and loudly if the binary cannot even introduce itself: + # a startup error (a missing dataset directory, say) is otherwise only + # visible as a mysterious timeout on the first reset. + self.handshake: Dict[str, Any] = self.request("handshake") + + # -- plumbing --------------------------------------------------------- + + def _pump_stdout(self) -> None: + assert self._proc is not None and self._proc.stdout is not None + try: + for line in self._proc.stdout: + self._stdout_q.put(line) + except (ValueError, OSError): # pipe closed under us + pass + finally: + self._stdout_q.put(None) # sentinel: EOF + + def _pump_stderr(self) -> None: + assert self._proc is not None and self._proc.stderr is not None + try: + for line in self._proc.stderr: + self._stderr.append(line.rstrip("\n")) + except (ValueError, OSError): + pass + + def _stderr_tail(self) -> str: + if not self._stderr: + return "" + return "\n stderr: " + "\n ".join(self._stderr) + + @property + def closed(self) -> bool: + return self._closed or self._proc is None + + def _ensure_open(self) -> subprocess.Popen: + if self._closed or self._proc is None: + raise EngineClosedError("the engine process is closed") + if self._proc.poll() is not None: + code = self._proc.returncode + raise EngineClosedError(f"the engine process exited with code {code}{self._stderr_tail()}") + return self._proc + + # -- protocol --------------------------------------------------------- + + def request( + self, + cmd: str, + args: Optional[Dict[str, Any]] = None, + *, + timeout: Optional[float] = None, + ) -> Dict[str, Any]: + """Send one command and return its ``result``. + + Raises: + IllegalActionError: the action was rejected (episode untouched). + EngineError: any other engine-side rejection. + EngineTimeoutError: no reply within the timeout. + EngineClosedError: the process is gone. + ProtocolError: the reply was not a valid protocol message. + """ + payload: Dict[str, Any] = {"cmd": cmd} + if args: + payload["args"] = args + line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + + wait = self._timeout if timeout is None else float(timeout) + + # One request at a time. The protocol is strictly request/response over + # a single pipe pair, so two concurrent callers would interleave lines + # and each read the other's answer. + with self._lock: + proc = self._ensure_open() + assert proc.stdin is not None + try: + proc.stdin.write(line + "\n") + proc.stdin.flush() + except (BrokenPipeError, ValueError, OSError) as exc: + raise EngineClosedError( + f"the engine process closed its input while sending {cmd!r}: {exc}{self._stderr_tail()}" + ) from exc + + try: + reply = self._stdout_q.get(timeout=wait) + except queue.Empty: + raise EngineTimeoutError( + f"the engine did not answer {cmd!r} within {wait:g}s{self._stderr_tail()}" + ) from None + + if reply is None: + raise EngineClosedError( + f"the engine process exited while handling {cmd!r}{self._stderr_tail()}" + ) + + try: + resp = json.loads(reply) + except json.JSONDecodeError as exc: + raise ProtocolError(f"engine wrote a non-JSON line: {reply!r}") from exc + if not isinstance(resp, dict): + raise ProtocolError(f"engine wrote a non-object response: {reply!r}") + + if resp.get("ok"): + result = resp.get("result") + return result if isinstance(result, dict) else {} + + err = resp.get("error") or {} + code = err.get("code", "unknown") + message = err.get("message", "(no message)") + details = err.get("details") or {} + if code == "illegal_action": + raise IllegalActionError(code, message, details) + raise EngineError(code, message, details) + + # -- lifecycle -------------------------------------------------------- + + def close(self, timeout: float = _SHUTDOWN_GRACE) -> None: + """Shut the engine down. Safe to call more than once. + + Escalates politely: a ``close`` command, then closing stdin (the binary + treats EOF as a clean exit), then SIGTERM, then SIGKILL. Each stage + exists because the one before it can fail — a wedged process must not + survive this call. + """ + if self._closed: + return + self._closed = True + proc, self._proc = self._proc, None + _LIVE.discard(self) + if proc is None: + return + + try: + if proc.poll() is None and proc.stdin is not None: + try: + proc.stdin.write('{"cmd":"close"}\n') + proc.stdin.flush() + except (BrokenPipeError, ValueError, OSError): + pass + for stream in (proc.stdin,): + if stream is not None: + try: + stream.close() + except (BrokenPipeError, ValueError, OSError): + pass + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.terminate() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: # pragma: no cover - last resort + proc.kill() + proc.wait(timeout=timeout) + finally: + for stream in (proc.stdout, proc.stderr): + if stream is not None: + try: + stream.close() + except (BrokenPipeError, ValueError, OSError): + pass + + def __enter__(self) -> "EngineClient": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + def __del__(self) -> None: # pragma: no cover - GC timing + try: + self.close() + except Exception: + pass diff --git a/python/pokearena/_util.py b/python/pokearena/_util.py new file mode 100644 index 00000000..136ebcb0 --- /dev/null +++ b/python/pokearena/_util.py @@ -0,0 +1,141 @@ +"""Small shared helpers for the environment wrappers.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence + +from .spaces import ACTION_SPACE_SIZE + +try: # pragma: no cover - depends on whether numpy happens to be installed + import numpy as _np +except Exception: # pragma: no cover + _np = None + +__all__ = ["as_mask", "extract_action", "info_from", "UINT64_MASK"] + +UINT64_MASK = (1 << 64) - 1 + + +def as_mask(mask: Optional[Sequence[int]]): + """Return an action mask in the most useful type available. + + NumPy's ``int8`` array is what PettingZoo's action-mask convention and most + RL libraries expect. NumPy is not a dependency of this package, so a plain + list is returned when it is absent — both are truthy, both index the same + way, and ``np.asarray`` on the list gives the array if a caller wants one. + """ + values = list(mask) if mask is not None else [0] * ACTION_SPACE_SIZE + if _np is not None: + return _np.asarray(values, dtype=_np.int8) + return values + + +def extract_action(action: Any) -> Any: + """Normalise a submitted action into something the protocol accepts. + + Every shape a caller plausibly has in hand is accepted, because the first + line a new user writes is ``env.step(env.legal_actions()[0])`` and it has to + work: + + * a plain ``int`` — the flat discrete index; + * a NumPy scalar or anything with ``.item()``, so ``np.argmax(...)`` needs + no cast; + * a **legal-action record** as returned by ``legal_actions()`` / + ``info["legal_actions"]``, i.e. ``{"index": 0, "action": {...}, + "label": "..."}`` — the envelope is unwrapped for you; + * the engine's own action object ``{"kind": "move", "index": 2}``, which is + the only encoding that can name a self-switch pivot target. + + Anything else raises :class:`TypeError` here, on the Python side, naming the + shapes that do work. Passing it through to the engine instead would surface + as a message about a JSON field the caller never typed. + """ + if isinstance(action, dict): + return _unwrap_action_dict(action) + if isinstance(action, bool): # bool is an int subclass; almost certainly a bug + raise TypeError("action must be an int or an action dict, not a bool") + if isinstance(action, int): + return action + item = getattr(action, "item", None) + if callable(item): + try: + return int(item()) + except (TypeError, ValueError): + pass + try: + return int(action) + except (TypeError, ValueError) as exc: + raise _action_shape_error(type(action).__name__) from exc + + +def _action_shape_error(got: str) -> TypeError: + """The one message that lists every shape that does work. + + Built with %-formatting rather than str.format because the message itself is + full of literal braces — the example dicts are the whole point of it. + """ + return TypeError( + "action must be one of: an int in [0,%d); a legal-action record from " + "legal_actions() such as {'index': 0, 'action': {...}, 'label': ...}; " + "or an engine action object such as {'kind': 'move', 'index': 0}. Got %s." + % (ACTION_SPACE_SIZE, got) + ) + + +def _unwrap_action_dict(d: Dict[str, Any]) -> Any: + """Reduce a dict-shaped action to what the protocol accepts.""" + inner = d.get("action") + if isinstance(inner, dict): + # A legal-action record: {"index": ..., "action": {...}, "label": ...}. + # Recurse into the engine object rather than the flat index so a + # self-switch pivot target survives. + return _unwrap_action_dict(inner) + + kind = d.get("kind") + if kind is not None: + if kind not in ("move", "switch"): + raise TypeError( + f"action kind must be 'move' or 'switch', got {kind!r}" + ) + if not isinstance(d.get("index"), int): + raise TypeError( + f"an action object needs an integer 'index', got {d.get('index')!r}" + ) + return d + + index = d.get("index") + if isinstance(index, int) and not isinstance(index, bool): + return index + + raise _action_shape_error(f"a dict with keys {sorted(d)}") + + +def info_from(result: Dict[str, Any], side: int) -> Dict[str, Any]: + """Build the per-side ``info`` dict from an engine step result. + + Everything in here is either public battle information (the event log, the + turn number, the outcome) or the side's own legal-action set. No part of it + is derived from the opponent's hidden state. + """ + engine_info = result.get("info") or {} + legal: List[Dict[str, Any]] = (result.get("legal_actions") or [None, None])[side] or [] + mask = (result.get("action_mask") or [None, None])[side] + state_hash = (engine_info.get("state_hash") or ["", ""])[side] + + return { + "turn": result.get("turn"), + "phase": result.get("phase"), + "to_move": result.get("to_move") or [], + "legal_actions": legal, + "action_mask": as_mask(mask), + "events": result.get("events") or [], + "winner": result.get("winner"), + "state_hash": state_hash, + "decision_index": engine_info.get("decision_index"), + "seed": engine_info.get("seed"), + "battle_id": engine_info.get("battle_id"), + "teams": engine_info.get("teams"), + "agents": engine_info.get("agents"), + "turn_limit": engine_info.get("turn_limit"), + "side": side, + } diff --git a/python/pokearena/env.py b/python/pokearena/env.py new file mode 100644 index 00000000..9af2d668 --- /dev/null +++ b/python/pokearena/env.py @@ -0,0 +1,305 @@ +"""Single-agent Gymnasium-style environment. + +The agent plays one side of a PokéArena battle; a built-in baseline plays the +other, in-process inside the Go binary. That placement matters: the opponent is +part of the environment, seeded from the same battle seed, so the whole episode +stays a pure function of ``(seed, teams, opponent)`` and two runs of the same +seed produce the same game down to the byte. +""" + +from __future__ import annotations + +import random +from typing import Any, Dict, List, Optional, Tuple + +from ._client import DEFAULT_TIMEOUT, EngineClient +from ._util import UINT64_MASK, extract_action, info_from +from .errors import EngineError +from .spaces import ACTION_SPACE_SIZE, action_space, describe_action, observation_space + +__all__ = ["PokeArenaEnv", "GYMNASIUM_ENV_BASE"] + +try: # pragma: no cover - depends on the installed extras + import gymnasium as _gym # type: ignore + + _EnvBase = _gym.Env + GYMNASIUM_ENV_BASE = True +except Exception: # pragma: no cover - the dependency-free path + _EnvBase = object # type: ignore[assignment,misc] + GYMNASIUM_ENV_BASE = False + + +class PokeArenaEnv(_EnvBase): # type: ignore[misc,valid-type] + """A PokéArena battle as a single-agent environment. + + Follows the current Gymnasium convention: ``reset`` returns ``(obs, info)`` + and ``step`` returns the 5-tuple ``(obs, reward, terminated, truncated, + info)``. It subclasses ``gymnasium.Env`` when Gymnasium is installed and + stands alone when it is not — the API is identical either way. + + Example:: + + from pokearena import PokeArenaEnv + + with PokeArenaEnv(team="Genesis", opponent="heuristic") as env: + obs, info = env.reset(seed=0) + terminated = truncated = False + while not (terminated or truncated): + action = info["legal_actions"][0]["index"] + obs, reward, terminated, truncated, info = env.step(action) + print("winner:", info["winner"]) + + Args: + team: the agent's team — a library name (``"Genesis"``), a list of + Pokédex numbers (``[150, 149, 143]``), or ``{"picks": [...]}`` for + full control over moves, EVs, natures and items. + opponent: the built-in baseline on the other side: ``"heuristic"`` + (strongest, and the benchmark's reference opponent), ``"random"``, + ``"expectimax"``, or ``"expectimax@N"`` for a pinned search depth. + opponent_team: the opponent's team. Defaults to ``team``, which is the + variance-controlled **mirror match**: identical rosters on both + sides, so the only free variable left is the policy. + agent_side: which board side the agent occupies, 0 or 1. + reward: ``"win_loss"`` (default: 0 every step, ±1 at the end) or + ``"hp_delta"`` (dense shaping on team-HP difference; see the note + in :ref:`rewards ` below). + max_turns: truncate the episode after this many turns. 0 leaves only + the engine's own 300-turn cap, which *terminates* with a winner + decided on remaining HP rather than truncating. + binary: explicit path to ``pokearena-env``. Normally unnecessary. + data_dir: read the dataset from this directory instead of the copy + embedded in the binary. + teams_file: use this team library instead of the embedded one. + expectimax_depth: default search depth for expectimax opponents. + timeout: per-request read timeout in seconds. + seed: seeds the sequence of battle seeds used by ``reset()`` calls that + do not pass one of their own. + + .. _rewards: + + **Rewards.** ``win_loss`` is the honest default: the battle's only real + objective, ±1 at the terminal step. ``hp_delta`` adds per-step shaping from + the change in (own team HP fraction − opponent team HP fraction). That + reads privileged state — the opponent's exact team HP is deliberately *not* + in any observation — which is normal for a training signal and would be + dishonest in an observation. It is opt-in for exactly that reason. + """ + + metadata = {"render_modes": ["ansi"], "name": "pokearena-v0"} + + def __init__( + self, + team: Any = "Genesis", + opponent: str = "heuristic", + *, + opponent_team: Any = None, + agent_side: int = 0, + reward: str = "win_loss", + max_turns: int = 0, + binary: Optional[str] = None, + data_dir: Optional[str] = None, + teams_file: Optional[str] = None, + expectimax_depth: Optional[int] = None, + timeout: float = DEFAULT_TIMEOUT, + render_mode: Optional[str] = None, + seed: Optional[int] = None, + ): + if agent_side not in (0, 1): + raise ValueError(f"agent_side must be 0 or 1, got {agent_side!r}") + if render_mode not in (None, "ansi"): + raise ValueError(f"render_mode must be None or 'ansi', got {render_mode!r}") + + self.team = team + self.opponent = opponent + self.opponent_team = opponent_team + self.agent_side = agent_side + self.reward_mode = reward + self.max_turns = int(max_turns) + self.render_mode = render_mode + + self.action_space = action_space() + self.observation_space = observation_space() + + self._engine = EngineClient( + binary, + data_dir=data_dir, + teams=teams_file, + expectimax_depth=expectimax_depth, + timeout=timeout, + ) + #: The engine's handshake: protocol version, engine revision, dataset + #: sim-version and curation SHA, ruleset, team library. This is the + #: provenance record — quote it when you publish a number. + self.engine_info: Dict[str, Any] = self._engine.handshake + + self._seed_rng = random.Random(seed) + self._last: Dict[str, Any] = {} + self._events: List[Dict[str, Any]] = [] + self._started = False + + # -- introspection ---------------------------------------------------- + + @property + def unwrapped(self) -> "PokeArenaEnv": + return self + + @property + def team_library(self) -> List[str]: + """Names of the curated teams this binary can play.""" + return list((self.engine_info.get("team_library") or {}).get("teams") or []) + + def legal_actions(self) -> List[Dict[str, Any]]: + """The agent's legal actions right now, each with ``index``, ``action`` + and a human-readable ``label``.""" + self._require_episode() + out = self._engine.request("legal_actions", {"side": self.agent_side}) + return out.get("legal_actions") or [] + + def action_mask(self): + """A 0/1 mask over the discrete action space (NumPy array if NumPy is + installed, otherwise a list).""" + from ._util import as_mask + + self._require_episode() + out = self._engine.request("legal_actions", {"side": self.agent_side}) + return as_mask(out.get("action_mask")) + + def observe(self) -> Dict[str, Any]: + """Re-fetch the agent's current fog-of-war observation.""" + self._require_episode() + out = self._engine.request("observe", {"side": self.agent_side}) + return out.get("observation") or {} + + # -- gym API ---------------------------------------------------------- + + def reset( + self, + *, + seed: Optional[int] = None, + options: Optional[Dict[str, Any]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Start a new battle. + + ``reset(seed=k)`` always plays battle ``k`` — the same rosters, the same + RNG stream, the same game. ``reset()`` without a seed draws the next one + from the generator seeded at construction (or at the last seeded reset), + so an unseeded sequence is still reproducible from its starting point. + """ + if GYMNASIUM_ENV_BASE: # keep gymnasium's own np_random in step + try: + super().reset(seed=seed) # type: ignore[misc] + except TypeError: # pragma: no cover - older signatures + pass + + if seed is not None: + self._seed_rng = random.Random(seed) + battle_seed = int(seed) & UINT64_MASK + else: + battle_seed = self._seed_rng.getrandbits(64) + + options = dict(options or {}) + team = options.pop("team", self.team) + opponent = options.pop("opponent", self.opponent) + opponent_team = options.pop("opponent_team", self.opponent_team) + reward = options.pop("reward", self.reward_mode) + max_turns = int(options.pop("max_turns", self.max_turns)) + if options: + raise ValueError(f"unknown reset options: {sorted(options)}") + + # The protocol indexes teams by board side; the agent may sit on + # either. Default the opponent to the agent's own roster, which is the + # mirror match the benchmark runs. + sides: List[Any] = [None, None] + sides[self.agent_side] = team + sides[1 - self.agent_side] = team if opponent_team is None else opponent_team + + agents = ["external", "external"] + agents[1 - self.agent_side] = opponent + + args: Dict[str, Any] = { + "seed": battle_seed, + "team": sides[0], + "opponent_team": sides[1], + "agents": agents, + "reward": reward, + } + if max_turns > 0: + args["max_turns"] = max_turns + + result = self._engine.request("reset", args) + self._started = True + obs, _reward, _terminated, _truncated = self._unpack(result) + return obs, self._info(result) + + def step(self, action: Any) -> Tuple[Dict[str, Any], float, bool, bool, Dict[str, Any]]: + """Submit the agent's action and advance to its next decision point. + + The opponent's move, and any forced replacement it has to make on its + own, are resolved inside this call — the agent is only ever asked when + it actually has a choice. + + Raises: + IllegalActionError: the action was not legal. The episode is + untouched, so picking a legal action and stepping again is a + complete recovery. + """ + self._require_episode() + payload = extract_action(action) + if isinstance(payload, int) and not 0 <= payload < ACTION_SPACE_SIZE: + raise ValueError( + f"action {payload} is outside the discrete space [0,{ACTION_SPACE_SIZE}); " + f"see pokearena.spaces for the layout" + ) + result = self._engine.request("step", {"action": payload}) + obs, reward, terminated, truncated = self._unpack(result) + return obs, reward, terminated, truncated, self._info(result) + + def render(self) -> Optional[str]: + """Return the most recent turn's event log as text (``ansi`` mode).""" + if self.render_mode != "ansi": + return None + return self.text_log() + + def text_log(self) -> str: + """The most recent step's battle log, one event per line.""" + return "\n".join(e.get("text", "") for e in self._events) + + def close(self) -> None: + """Shut the engine subprocess down. Idempotent.""" + self._started = False + self._engine.close() + + def __enter__(self) -> "PokeArenaEnv": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + # -- internals -------------------------------------------------------- + + def _require_episode(self) -> None: + if not self._started: + raise EngineError("no_episode", "call reset() before stepping this environment") + + def _unpack(self, result: Dict[str, Any]) -> Tuple[Dict[str, Any], float, bool, bool]: + self._last = result + self._events = result.get("events") or [] + observations = result.get("observations") or [None, None] + obs = observations[self.agent_side] or {} + rewards = result.get("rewards") or [0.0, 0.0] + reward = float(rewards[self.agent_side]) + terminated = bool(result.get("terminated")) + truncated = bool(result.get("truncated")) + if terminated or truncated: + self._started = False + return obs, reward, terminated, truncated + + def _info(self, result: Dict[str, Any]) -> Dict[str, Any]: + info = info_from(result, self.agent_side) + info["opponent"] = self.opponent + info["action_labels"] = { + la["index"]: la.get("label", describe_action(la["index"])) + for la in info["legal_actions"] + if isinstance(la, dict) and isinstance(la.get("index"), int) + } + return info diff --git a/python/pokearena/errors.py b/python/pokearena/errors.py new file mode 100644 index 00000000..7869fe39 --- /dev/null +++ b/python/pokearena/errors.py @@ -0,0 +1,84 @@ +"""Exception types for the PokéArena environment. + +Every failure mode gets its own class so a caller can react to it rather than +matching on a message. The one that matters most is +:class:`BinaryNotFoundError`: the Go engine binary is a real prerequisite, and +a wrapper that failed silently — or fell back to some fake environment — would +be worse than one that refuses to start. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +__all__ = [ + "PokeArenaError", + "BinaryNotFoundError", + "EngineError", + "IllegalActionError", + "ProtocolError", + "EngineTimeoutError", + "EngineClosedError", +] + + +class PokeArenaError(Exception): + """Base class for everything this package raises.""" + + +class BinaryNotFoundError(PokeArenaError): + """The ``pokearena-env`` engine binary could not be located.""" + + +class ProtocolError(PokeArenaError): + """The engine said something that is not a valid protocol message. + + This is a bug on one side of the wire — a version mismatch, a corrupted + pipe, or a stray write to stdout — never something a caller can fix by + retrying. + """ + + +class EngineTimeoutError(PokeArenaError): + """The engine did not answer within the configured timeout.""" + + +class EngineClosedError(PokeArenaError): + """The engine subprocess is gone (closed, crashed, or never started).""" + + +class EngineError(PokeArenaError): + """The engine rejected a request and said why. + + Attributes: + code: the stable machine-readable code (``bad_request``, + ``illegal_action``, ``no_episode``, ``episode_over``, + ``unknown_command``, ``internal``). + details: structured context, when the engine attached any. An + ``illegal_action`` carries ``legal_actions`` and ``action_mask``. + """ + + def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(f"{code}: {message}") + self.code = code + self.message = message + self.details: Dict[str, Any] = details or {} + + +class IllegalActionError(EngineError): + """The submitted action was not in the legal set. + + The episode is untouched — the engine validates before it resolves — so a + caller may simply pick a legal action and step again. ``details`` carries + ``legal_actions`` and ``action_mask`` for that side. + """ + + @property + def legal_actions(self): + """The legal action records the engine offered instead.""" + return self.details.get("legal_actions", []) + + @property + def action_mask(self): + """A 0/1 mask over the discrete action space.""" + return self.details.get("action_mask", []) diff --git a/python/pokearena/parallel_env.py b/python/pokearena/parallel_env.py new file mode 100644 index 00000000..59a495e1 --- /dev/null +++ b/python/pokearena/parallel_env.py @@ -0,0 +1,282 @@ +"""Two-agent PettingZoo-style environment. + +A PokéArena turn is **simultaneous** — both sides commit before either sees the +other's choice — so the parallel API is the faithful model, not a convenience. +An AEC view is available via :func:`aec_env` for tooling that wants one; it is +PettingZoo's own ``parallel_to_aec`` conversion, which imposes a turn order the +underlying game does not have. + +Fog of war is per agent and enforced by the engine, not by this wrapper: each +agent's observation is projected separately and the other side's bench is never +in the bytes. Both observations pass through this process, so a single script +holding both can of course see both — that is the script's own information, not +a leak between the agents. +""" + +from __future__ import annotations + +import functools +import random +from typing import Any, Dict, List, Optional, Tuple + +from ._client import DEFAULT_TIMEOUT, EngineClient +from ._util import UINT64_MASK, extract_action, info_from +from .errors import EngineError +from .spaces import ACTION_SPACE_SIZE, action_space, observation_space + +__all__ = ["PokeArenaParallelEnv", "parallel_env", "aec_env", "AGENTS", "PETTINGZOO_ENV_BASE"] + +#: The two agent ids, in board-side order. +AGENTS: Tuple[str, str] = ("player_0", "player_1") + +try: # pragma: no cover - depends on the installed extras + from pettingzoo import ParallelEnv as _ParallelBase # type: ignore + + PETTINGZOO_ENV_BASE = True +except Exception: # pragma: no cover - the dependency-free path + _ParallelBase = object # type: ignore[assignment,misc] + PETTINGZOO_ENV_BASE = False + + +def _side_of(agent: str) -> int: + try: + return AGENTS.index(agent) + except ValueError: + raise KeyError(f"unknown agent {agent!r}; expected one of {list(AGENTS)}") from None + + +class PokeArenaParallelEnv(_ParallelBase): # type: ignore[misc,valid-type] + """Agent versus agent, one action per side per step. + + Example:: + + from pokearena import PokeArenaParallelEnv + + with PokeArenaParallelEnv(team="Blitz") as env: + obs, infos = env.reset(seed=0) + while env.agents: + actions = { + a: infos[a]["legal_actions"][0]["index"] + for a in env.agents_to_move + } + obs, rewards, terms, truncs, infos = env.step(actions) + + **Who has to act.** Most steps are a simultaneous turn and both agents act. + After a faint, though, only the side with the fainted Pokémon chooses a + replacement. ``env.agents_to_move`` and ``infos[agent]["to_move"]`` name the + agents whose action is actually consumed this step; an action supplied for + any other agent is ignored rather than rejected, so the usual + ``{a: policy(a) for a in env.agents}`` loop is safe. Both agents stay in + ``env.agents`` until the episode ends, per the PettingZoo contract. + + Args: + team: the team for side 0. + opponent_team: the team for side 1. Defaults to ``team`` — the + variance-controlled mirror match. + max_turns: truncate after this many turns (0 = engine cap only). + binary, data_dir, teams_file, expectimax_depth, timeout: as + :class:`~pokearena.env.PokeArenaEnv`. + seed: seeds the sequence of battle seeds for unseeded resets. + """ + + metadata = {"render_modes": ["ansi"], "name": "pokearena_parallel_v0", "is_parallelizable": True} + + def __init__( + self, + team: Any = "Genesis", + *, + opponent_team: Any = None, + reward: str = "win_loss", + max_turns: int = 0, + binary: Optional[str] = None, + data_dir: Optional[str] = None, + teams_file: Optional[str] = None, + expectimax_depth: Optional[int] = None, + timeout: float = DEFAULT_TIMEOUT, + render_mode: Optional[str] = None, + seed: Optional[int] = None, + ): + if render_mode not in (None, "ansi"): + raise ValueError(f"render_mode must be None or 'ansi', got {render_mode!r}") + + self.team = team + self.opponent_team = opponent_team + self.reward_mode = reward + self.max_turns = int(max_turns) + self.render_mode = render_mode + + self.possible_agents: List[str] = list(AGENTS) + self.agents: List[str] = [] + self.agents_to_move: List[str] = [] + + self._engine = EngineClient( + binary, + data_dir=data_dir, + teams=teams_file, + expectimax_depth=expectimax_depth, + timeout=timeout, + ) + #: Provenance: protocol version, engine revision, dataset identity. + self.engine_info: Dict[str, Any] = self._engine.handshake + + self._seed_rng = random.Random(seed) + self._events: List[Dict[str, Any]] = [] + self._obs_cache: Dict[str, Dict[str, Any]] = {} + + # -- spaces ----------------------------------------------------------- + + @functools.lru_cache(maxsize=None) # noqa: B019 - PettingZoo's own idiom + def observation_space(self, agent: str): + _side_of(agent) + return observation_space() + + @functools.lru_cache(maxsize=None) # noqa: B019 + def action_space(self, agent: str): + _side_of(agent) + return action_space() + + # -- pettingzoo API --------------------------------------------------- + + def reset( + self, + seed: Optional[int] = None, + options: Optional[Dict[str, Any]] = None, + ) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]: + """Start a new battle and return ``(observations, infos)``.""" + if seed is not None: + self._seed_rng = random.Random(seed) + battle_seed = int(seed) & UINT64_MASK + else: + battle_seed = self._seed_rng.getrandbits(64) + + options = dict(options or {}) + team = options.pop("team", self.team) + opponent_team = options.pop("opponent_team", self.opponent_team) + reward = options.pop("reward", self.reward_mode) + max_turns = int(options.pop("max_turns", self.max_turns)) + if options: + raise ValueError(f"unknown reset options: {sorted(options)}") + + args: Dict[str, Any] = { + "seed": battle_seed, + "team": team, + "opponent_team": team if opponent_team is None else opponent_team, + "agents": ["external", "external"], + "reward": reward, + } + if max_turns > 0: + args["max_turns"] = max_turns + + result = self._engine.request("reset", args) + self.agents = list(self.possible_agents) + obs, _rewards, _terms, _truncs, infos = self._unpack(result) + return obs, infos + + def step(self, actions: Dict[str, Any]): + """Submit both sides' actions and resolve one decision point. + + Returns the PettingZoo 5-tuple + ``(observations, rewards, terminations, truncations, infos)``. + """ + if not self.agents: + raise EngineError("no_episode", "call reset() before stepping this environment") + + payload: List[Any] = [None, None] + for agent in self.agents_to_move: + if agent not in actions: + raise KeyError( + f"{agent!r} must act this step (to_move={self.agents_to_move}) " + f"but no action was supplied" + ) + value = extract_action(actions[agent]) + if isinstance(value, int) and not 0 <= value < ACTION_SPACE_SIZE: + raise ValueError( + f"action {value} for {agent!r} is outside the discrete space [0,{ACTION_SPACE_SIZE})" + ) + payload[_side_of(agent)] = value + + result = self._engine.request("step", {"actions": payload}) + return self._unpack(result) + + def render(self) -> Optional[str]: + if self.render_mode != "ansi": + return None + return self.text_log() + + def text_log(self) -> str: + """The most recent step's battle log, one event per line.""" + return "\n".join(e.get("text", "") for e in self._events) + + def close(self) -> None: + self.agents = [] + self.agents_to_move = [] + self._engine.close() + + def __enter__(self) -> "PokeArenaParallelEnv": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + # -- internals -------------------------------------------------------- + + def _unpack(self, result: Dict[str, Any]): + self._events = result.get("events") or [] + to_move = result.get("to_move") or [] + self.agents_to_move = [AGENTS[s] for s in to_move if 0 <= s < len(AGENTS)] + + terminated = bool(result.get("terminated")) + truncated = bool(result.get("truncated")) + rewards_arr = result.get("rewards") or [0.0, 0.0] + raw_obs = result.get("observations") or [None, None] + + observations: Dict[str, Dict[str, Any]] = {} + infos: Dict[str, Dict[str, Any]] = {} + for agent in self.possible_agents: + side = _side_of(agent) + obs = raw_obs[side] + if obs is None and not (terminated or truncated): + # Only one side replaces after a single faint, so the other has + # no observation attached to this step. Fetch its current view + # so the returned dict is complete — every agent always gets an + # observation, which is what the PettingZoo API promises. + obs = self._engine.request("observe", {"side": side}).get("observation") + observations[agent] = obs if obs is not None else self._obs_cache.get(agent, {}) + self._obs_cache[agent] = observations[agent] + infos[agent] = info_from(result, side) + infos[agent]["to_move"] = list(self.agents_to_move) + + rewards = {a: float(rewards_arr[_side_of(a)]) for a in self.possible_agents} + terminations = {a: terminated for a in self.possible_agents} + truncations = {a: truncated for a in self.possible_agents} + + if terminated or truncated: + # PettingZoo's contract: an agent that is done leaves `agents`, and + # `while env.agents:` is the standard loop condition. + self.agents = [] + self.agents_to_move = [] + + return observations, rewards, terminations, truncations, infos + + +def parallel_env(**kwargs) -> PokeArenaParallelEnv: + """PettingZoo's conventional constructor name.""" + return PokeArenaParallelEnv(**kwargs) + + +def aec_env(**kwargs): + """An AEC view of the parallel environment. + + Requires PettingZoo (``pip install "pokearena[pettingzoo]"``), because the + conversion is theirs. Note what the conversion costs: PokéArena turns are + simultaneous, and AEC imposes a sequential order on them, so the AEC view is + a re-presentation of the parallel one rather than a truer picture of the + game. Prefer :class:`PokeArenaParallelEnv` unless a tool requires AEC. + """ + try: + from pettingzoo.utils.conversions import parallel_to_aec # type: ignore + except Exception as exc: # pragma: no cover - depends on extras + raise ImportError( + "aec_env() needs PettingZoo: pip install 'pokearena[pettingzoo]'" + ) from exc + return parallel_to_aec(PokeArenaParallelEnv(**kwargs)) diff --git a/python/pokearena/py.typed b/python/pokearena/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/python/pokearena/spaces.py b/python/pokearena/spaces.py new file mode 100644 index 00000000..cafe8615 --- /dev/null +++ b/python/pokearena/spaces.py @@ -0,0 +1,158 @@ +"""Action and observation spaces, with or without Gymnasium installed. + +``pokearena`` has zero required runtime dependencies, so the spaces are defined +against a duck type rather than a hard import. When Gymnasium *is* present the +real ``gymnasium.spaces`` classes are used, so wrappers and vector envs that +``isinstance``-check a space keep working; when it is not, a small local +stand-in with the same surface (``n``, ``sample``, ``contains``, ``seed``) takes +its place and nothing else in the package changes. + +The action space is a fixed 11-way ``Discrete``, laid out by the engine and +reported in its handshake: + +=========== ====================================================== +index meaning +=========== ====================================================== +``0``–``3`` use move slot 0–3 +``4`` Struggle / the forced move on a recharge or charge turn +``5``–``10`` switch to team slot 0–5 +=========== ====================================================== + +It is deliberately fixed-size rather than "however many actions are legal right +now". Renumbering per turn would make the same integer mean different things at +different times, which quietly destroys any learned policy and any saved +trajectory. Legality is expressed as a **mask** instead — ``info["action_mask"]`` +on every step — which is the convention RL libraries already understand. +""" + +from __future__ import annotations + +import random +from typing import Any, Optional, Sequence + +__all__ = [ + "GYMNASIUM_AVAILABLE", + "Discrete", + "Space", + "BattleObservationSpace", + "ACTION_SPACE_SIZE", + "MOVE_SLOTS", + "STRUGGLE_INDEX", + "SWITCH_BASE", + "TEAM_SIZE", + "action_space", + "observation_space", + "describe_action", +] + +MOVE_SLOTS = 4 +STRUGGLE_INDEX = 4 +SWITCH_BASE = 5 +TEAM_SIZE = 6 +ACTION_SPACE_SIZE = MOVE_SLOTS + 1 + TEAM_SIZE # 11 + +try: # pragma: no cover - depends on the installed extras + from gymnasium.spaces import Discrete, Space # type: ignore + + GYMNASIUM_AVAILABLE = True +except Exception: # pragma: no cover - the dependency-free path + GYMNASIUM_AVAILABLE = False + + class Space: # type: ignore[no-redef] + """Minimal stand-in for ``gymnasium.spaces.Space``.""" + + def __init__(self, shape=None, dtype=None, seed=None): + self.shape = shape + self.dtype = dtype + self._rng = random.Random(seed) + + def seed(self, seed=None): + self._rng = random.Random(seed) + return [seed] + + def sample(self): + raise NotImplementedError + + def contains(self, x: Any) -> bool: + raise NotImplementedError + + def __contains__(self, x: Any) -> bool: + return self.contains(x) + + class Discrete(Space): # type: ignore[no-redef] + """Minimal stand-in for ``gymnasium.spaces.Discrete``.""" + + def __init__(self, n: int, seed=None, start: int = 0): + super().__init__(shape=(), dtype="int64", seed=seed) + self.n = int(n) + self.start = int(start) + + def sample(self, mask: Optional[Sequence[int]] = None) -> int: + choices = range(self.start, self.start + self.n) + if mask is not None: + choices = [i for i, m in zip(choices, mask) if m] + if not choices: + return self.start + return self._rng.choice(list(choices)) + + def contains(self, x: Any) -> bool: + try: + i = int(x) + except (TypeError, ValueError): + return False + return self.start <= i < self.start + self.n + + def __repr__(self) -> str: + return f"Discrete({self.n})" + + +class BattleObservationSpace(Space): + """The space of battle observations. + + An observation is the engine's fog-of-war ``View``, decoded from JSON: a + nested dict with the viewer's own team in full and the opponent's *active* + Pokémon only, already redacted. It is not a fixed-width vector and this + package does not pretend it is one — flattening it is a modelling decision + that belongs to the user, not to the environment, and every reasonable + encoding (a text prompt for an LLM, a hand-built feature vector, a set + encoder) wants different things. + + So the space's only claim is the true one: observations are JSON objects. + """ + + def __init__(self, seed=None): + super().__init__(shape=None, dtype=None, seed=seed) + + def contains(self, x: Any) -> bool: + return isinstance(x, dict) + + def sample(self): + raise NotImplementedError( + "battle observations cannot be sampled independently of a battle; " + "call reset() to obtain one" + ) + + def __repr__(self) -> str: + return "BattleObservationSpace()" + + +def action_space(seed=None) -> Discrete: + """The fixed 11-way discrete action space.""" + return Discrete(ACTION_SPACE_SIZE, seed=seed) + + +def observation_space(seed=None) -> BattleObservationSpace: + """The battle observation space.""" + return BattleObservationSpace(seed=seed) + + +def describe_action(index: int) -> str: + """Render a flat action index in words, for logs and error messages.""" + i = int(index) + if 0 <= i < MOVE_SLOTS: + return f"use move slot {i}" + if i == STRUGGLE_INDEX: + return "Struggle / forced move" + if SWITCH_BASE <= i < ACTION_SPACE_SIZE: + return f"switch to team slot {i - SWITCH_BASE}" + return f"invalid action {i}" diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..befd5a53 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pokearena" +version = "0.1.0" +description = "Deterministic, fog-of-war Pokemon battle environments for RL and LLM evaluation — Gymnasium and PettingZoo APIs over a self-contained Go engine, no server required." +readme = "README.md" +requires-python = ">=3.9" +license = { file = "LICENSE" } +authors = [{ name = "Shaumik Ashraf" }] +keywords = [ + "reinforcement-learning", + "gymnasium", + "pettingzoo", + "multi-agent", + "pokemon", + "game-environment", + "benchmark", + "llm-evaluation", + "imperfect-information", + "self-play", + "deterministic", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Go", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Games/Entertainment :: Turn Based Strategy", + "Typing :: Typed", +] + +# Zero required runtime dependencies. The Gymnasium and PettingZoo integrations +# are duck-typed: install either one and the environments subclass it, install +# neither and the same API works standalone. The one real prerequisite is the +# `pokearena-env` Go binary, which is not a Python package — see the README. +dependencies = [] + +[project.optional-dependencies] +gymnasium = ["gymnasium>=0.29"] +pettingzoo = ["pettingzoo>=1.24"] +numpy = ["numpy>=1.21"] +all = ["gymnasium>=0.29", "pettingzoo>=1.24", "numpy>=1.21"] +dev = ["pytest>=7", "gymnasium>=0.29", "pettingzoo>=1.24", "numpy>=1.21"] + +[project.urls] +Homepage = "https://github.com/shaumik/PokeArena" +Repository = "https://github.com/shaumik/PokeArena" +Documentation = "https://github.com/shaumik/PokeArena/blob/main/docs/python-env.md" +"Protocol reference" = "https://github.com/shaumik/PokeArena/blob/main/docs/python-env.md" +"Benchmark methodology" = "https://github.com/shaumik/PokeArena/blob/main/docs/benchmark.md" +"Bug Tracker" = "https://github.com/shaumik/PokeArena/issues" +Releases = "https://github.com/shaumik/PokeArena/releases" + +[tool.setuptools.packages.find] +include = ["pokearena*"] + +[tool.setuptools.package-data] +pokearena = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/python/tests/_support.py b/python/tests/_support.py new file mode 100644 index 00000000..45c578b9 --- /dev/null +++ b/python/tests/_support.py @@ -0,0 +1,69 @@ +"""Shared test helpers. + +The tests are written with :mod:`unittest` so they run under a bare +interpreter (``python -m unittest discover -s tests``) as well as under pytest. +That matters here: the package has no required dependencies, and a test suite +that needed one to run would be quietly asserting the opposite of what the +package claims. + +Everything that touches the engine skips — loudly, with an actionable reason — +when the ``pokearena-env`` binary is not installed. +""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +# Make the package importable when the suite is run from anywhere. +_ROOT = Path(__file__).resolve().parents[1] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + +from pokearena import GO_INSTALL_COMMAND, BinaryNotFoundError, find_binary # noqa: E402 + +try: + ENGINE_BINARY = find_binary(os.environ.get("POKEARENA_ENV_BIN")) + SKIP_REASON = "" +except BinaryNotFoundError: + ENGINE_BINARY = None + SKIP_REASON = ( + "the pokearena-env engine binary is not installed, so the engine-backed " + "tests cannot run. Install it with:\n" + f" {GO_INSTALL_COMMAND}\n" + "or download a release binary and point POKEARENA_ENV_BIN at it. " + "This is a skip, not a pass: nothing about the engine was verified." + ) + +#: Decorator for every test that needs a live engine. +requires_engine = unittest.skipIf(ENGINE_BINARY is None, SKIP_REASON) + +#: A small ad-hoc team, so tests do not depend on the curated library's contents. +SMALL_TEAM = [150, 149, 143] +OTHER_TEAM = [6, 9, 3] + + +def rollout(env, seed=0, policy=None, max_steps=1000): + """Play one full single-agent episode and return ``(steps, info)``. + + The default policy is "first legal action", which is deterministic and + therefore usable as a reproducibility probe. + """ + obs, info = env.reset(seed=seed) + steps = 0 + terminated = truncated = False + while not (terminated or truncated): + if steps >= max_steps: + raise AssertionError(f"episode did not finish within {max_steps} steps") + action = (policy or _first_legal)(obs, info) + obs, _reward, terminated, truncated, info = env.step(action) + steps += 1 + return steps, info + + +def _first_legal(_obs, info): + legal = info["legal_actions"] + assert legal, "no legal actions at a decision point" + return legal[0]["index"] diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 00000000..d41cd832 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,15 @@ +"""Make the package and the shared helpers importable under pytest. + +The suite is written against :mod:`unittest` so it also runs with a bare +interpreter (``python -m unittest discover -s tests``), which is the honest way +to test a package that claims zero required dependencies. This file only exists +so pytest finds the same modules. +""" + +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +for path in (_HERE, _HERE.parent): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) diff --git a/python/tests/test_client.py b/python/tests/test_client.py new file mode 100644 index 00000000..e55c1ebc --- /dev/null +++ b/python/tests/test_client.py @@ -0,0 +1,130 @@ +"""Protocol-level tests against a live engine subprocess.""" + +from __future__ import annotations + +import unittest + +from _support import OTHER_TEAM, SMALL_TEAM, requires_engine + +from pokearena import ( + ACTION_SPACE_SIZE, + PROTOCOL_VERSION, + EngineClient, + EngineError, + IllegalActionError, +) + + +@requires_engine +class HandshakeTest(unittest.TestCase): + def test_reports_provenance(self): + with EngineClient() as engine: + hs = engine.handshake + self.assertEqual( + hs["protocol_version"].split(".")[0], + PROTOCOL_VERSION.split(".")[0], + "engine and client disagree on the protocol major version", + ) + for key in ("engine_revision", "ruleset", "dataset", "team_library", "action_space"): + self.assertIn(key, hs) + self.assertEqual(hs["action_space"]["n"], ACTION_SPACE_SIZE) + self.assertEqual(hs["level"], 50) + data = hs["dataset"] + self.assertTrue(data["sim_version"], "dataset sim_version is empty") + self.assertTrue(data["curation_sha"], "dataset curation_sha is empty") + self.assertGreater(data["species"], 0) + self.assertTrue(hs["team_library"]["teams"], "no curated teams reported") + + +@requires_engine +class ProtocolTest(unittest.TestCase): + def setUp(self): + self.engine = EngineClient() + self.addCleanup(self.engine.close) + + def test_reset_returns_observation_and_legal_actions(self): + result = self.engine.request( + "reset", {"seed": 1, "team": SMALL_TEAM, "agents": ["external", "heuristic"]} + ) + self.assertEqual(result["turn"], 0) + self.assertEqual(result["phase"], "choosing") + self.assertFalse(result["terminated"]) + self.assertIsNotNone(result["observations"][0]) + self.assertIsNone(result["observations"][1], "the baseline side's view was returned") + self.assertTrue(result["legal_actions"][0]) + self.assertEqual(len(result["action_mask"][0]), ACTION_SPACE_SIZE) + + def test_illegal_action_is_recoverable(self): + self.engine.request("reset", {"seed": 1, "team": SMALL_TEAM}) + with self.assertRaises(IllegalActionError) as ctx: + # Flat index 5 switches to team slot 0, which is already active. + self.engine.request("step", {"action": 5}) + self.assertTrue(ctx.exception.legal_actions) + self.assertEqual(len(ctx.exception.action_mask), ACTION_SPACE_SIZE) + + # The rejection left the episode alone, so a legal action still works + # and the battle is still on turn 0. + after = self.engine.request("step", {"action": 0}) + self.assertEqual(after["turn"], 1) + self.assertEqual(after["info"]["decision_index"], 1) + + def test_errors_have_codes(self): + fresh = EngineClient() + self.addCleanup(fresh.close) + with self.assertRaises(EngineError) as ctx: + fresh.request("step", {"action": 0}) + self.assertEqual(ctx.exception.code, "no_episode") + + with self.assertRaises(EngineError) as ctx: + fresh.request("teleport") + self.assertEqual(ctx.exception.code, "unknown_command") + + with self.assertRaises(EngineError) as ctx: + fresh.request("reset", {"seed": 1, "team": "NoSuchTeam"}) + self.assertEqual(ctx.exception.code, "bad_request") + + def test_object_action_form_is_accepted(self): + self.engine.request("reset", {"seed": 3, "team": SMALL_TEAM}) + result = self.engine.request("step", {"action": {"kind": "move", "index": 0}}) + self.assertEqual(result["turn"], 1) + + def test_asymmetric_teams(self): + result = self.engine.request( + "reset", + { + "seed": 5, + "team": SMALL_TEAM, + "opponent_team": OTHER_TEAM, + "agents": ["external", "heuristic"], + }, + ) + own = [p["name"] for p in result["observations"][0]["self"]["team"]] + self.assertEqual(len(own), len(SMALL_TEAM)) + self.assertNotIn(result["observations"][0]["foe"]["name"], own) + + +@requires_engine +class LifecycleTest(unittest.TestCase): + def test_close_is_idempotent_and_terminates_the_process(self): + engine = EngineClient() + proc = engine._proc # noqa: SLF001 - asserting on the subprocess is the point + self.assertIsNotNone(proc) + engine.close() + engine.close() + self.assertIsNotNone(proc.poll(), "the engine subprocess is still running after close()") + self.assertTrue(engine.closed) + + def test_context_manager_closes(self): + with EngineClient() as engine: + proc = engine._proc # noqa: SLF001 + self.assertIsNotNone(proc.poll(), "the engine subprocess outlived its context manager") + + def test_request_after_close_raises(self): + engine = EngineClient() + engine.close() + with self.assertRaises(Exception): + engine.request("handshake") + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_determinism.py b/python/tests/test_determinism.py new file mode 100644 index 00000000..93d8a5a0 --- /dev/null +++ b/python/tests/test_determinism.py @@ -0,0 +1,94 @@ +"""Determinism is the product; this is the test that says so. + +Two independent engine processes, the same seed, the same policy — the +trajectories must match event for event and hash for hash. The state hashes are +the strongest part: each one fingerprints the exact observation bytes the agent +received at that decision point, so an equal sequence means the two runs saw +byte-identical states all the way down. +""" + +from __future__ import annotations + +import unittest + +from _support import SMALL_TEAM, requires_engine + +from pokearena import PokeArenaEnv, PokeArenaParallelEnv + + +def _trajectory(seed, team=SMALL_TEAM, opponent="heuristic"): + """Play one episode in a fresh process and return a comparable record.""" + with PokeArenaEnv(team=team, opponent=opponent) as env: + obs, info = env.reset(seed=seed) + record = [(info["state_hash"], tuple(e["text"] for e in info["events"]))] + rewards = [] + terminated = truncated = False + while not (terminated or truncated): + action = info["legal_actions"][0]["index"] + obs, reward, terminated, truncated, info = env.step(action) + record.append((info["state_hash"], tuple(e["text"] for e in info["events"]))) + rewards.append(reward) + return { + "record": record, + "rewards": rewards, + "winner": info["winner"], + "turns": info["turn"], + } + + +@requires_engine +class DeterminismTest(unittest.TestCase): + def test_same_seed_same_trajectory(self): + a = _trajectory(2026) + b = _trajectory(2026) + self.assertEqual(a["winner"], b["winner"]) + self.assertEqual(a["turns"], b["turns"]) + self.assertEqual(a["rewards"], b["rewards"]) + self.assertEqual(len(a["record"]), len(b["record"])) + for i, (left, right) in enumerate(zip(a["record"], b["record"])): + self.assertEqual(left, right, f"trajectories diverge at step {i}") + self.assertGreater(len(a["record"]), 3, "the battle was too short to be evidence") + + def test_different_seeds_diverge(self): + a = _trajectory(1) + b = _trajectory(2) + self.assertNotEqual( + [h for h, _ in a["record"]], + [h for h, _ in b["record"]], + "two different seeds produced the same trajectory; the seed is not reaching the engine", + ) + + def test_reset_is_independent_of_history(self): + """A reset after a partial episode must equal a reset in a fresh process.""" + with PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic") as env: + _obs, info = env.reset(seed=5) + for _ in range(4): + _obs, _r, terminated, truncated, info = env.step(info["legal_actions"][0]["index"]) + if terminated or truncated: + break + _obs, restarted = env.reset(seed=77) + + with PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic") as fresh_env: + _obs, fresh = fresh_env.reset(seed=77) + + self.assertEqual(restarted["state_hash"], fresh["state_hash"]) + self.assertEqual(restarted["turn"], fresh["turn"]) + + def test_parallel_env_is_deterministic_too(self): + def run(): + with PokeArenaParallelEnv(team=SMALL_TEAM) as env: + obs, infos = env.reset(seed=808) + hashes = [tuple(infos[a]["state_hash"] for a in sorted(infos))] + while env.agents: + actions = { + a: infos[a]["legal_actions"][0]["index"] for a in env.agents_to_move + } + obs, _r, _t, _tr, infos = env.step(actions) + hashes.append(tuple(infos[a]["state_hash"] for a in sorted(infos))) + return hashes + + self.assertEqual(run(), run()) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_env.py b/python/tests/test_env.py new file mode 100644 index 00000000..944a07f8 --- /dev/null +++ b/python/tests/test_env.py @@ -0,0 +1,209 @@ +"""Tests for the single-agent Gymnasium-style environment.""" + +from __future__ import annotations + +import unittest + +from _support import OTHER_TEAM, SMALL_TEAM, requires_engine, rollout + +from pokearena import ACTION_SPACE_SIZE, IllegalActionError, PokeArenaEnv + + +@requires_engine +class ResetStepTest(unittest.TestCase): + def setUp(self): + self.env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic") + self.addCleanup(self.env.close) + + def test_reset_returns_obs_and_info(self): + obs, info = self.env.reset(seed=0) + self.assertIsInstance(obs, dict) + self.assertIn("self", obs) + self.assertIn("foe", obs) + self.assertEqual(info["turn"], 0) + self.assertTrue(info["legal_actions"]) + self.assertEqual(len(info["action_mask"]), ACTION_SPACE_SIZE) + self.assertEqual(obs["me"], 0) + + def test_step_returns_the_five_tuple(self): + _obs, info = self.env.reset(seed=0) + out = self.env.step(info["legal_actions"][0]["index"]) + self.assertEqual(len(out), 5, "Gymnasium's step returns 5 values") + obs, reward, terminated, truncated, info = out + self.assertIsInstance(obs, dict) + self.assertIsInstance(reward, float) + self.assertIsInstance(terminated, bool) + self.assertIsInstance(truncated, bool) + self.assertIsInstance(info, dict) + self.assertEqual(info["turn"], 1) + self.assertTrue(info["events"], "a resolved turn produced no events") + + def test_full_episode_terminates_with_a_winner(self): + steps, info = rollout(self.env, seed=2024) + self.assertGreater(steps, 0) + self.assertIn(info["winner"], (0, 1, 2)) + self.assertEqual(info["to_move"], []) + + def test_terminal_reward_agrees_with_the_winner(self): + _obs, info = self.env.reset(seed=7) + reward = 0.0 + terminated = truncated = False + while not (terminated or truncated): + _obs, reward, terminated, truncated, info = self.env.step( + info["legal_actions"][0]["index"] + ) + self.assertTrue(terminated, "expected a decided battle, not a truncation") + self.assertEqual(reward, {0: 1.0, 1: -1.0, 2: 0.0}[info["winner"]]) + + def test_illegal_action_raises_and_leaves_the_episode_alive(self): + _obs, info = self.env.reset(seed=1) + mask = list(info["action_mask"]) + illegal = next(i for i, m in enumerate(mask) if not m) + with self.assertRaises(IllegalActionError): + self.env.step(illegal) + # Still on turn 0 and still playable. + obs, _r, terminated, truncated, info = self.env.step(info["legal_actions"][0]["index"]) + self.assertFalse(terminated or truncated) + self.assertEqual(info["turn"], 1) + + def test_out_of_range_action_raises_value_error(self): + _obs, _info = self.env.reset(seed=1) + with self.assertRaises(ValueError): + self.env.step(ACTION_SPACE_SIZE + 3) + + def test_every_action_shape_a_caller_might_hold(self): + """``env.step(env.legal_actions()[0])`` is the first line people write. + + All four shapes must work: the whole legal-action record, its nested + engine object, its flat index, and a bare int. Passing the record used + to reach the engine intact and come back as a confusing complaint about + a missing JSON field the caller never typed. + """ + for pick in ( + lambda la: la[0], # the record itself + lambda la: la[0]["action"], # the engine object + lambda la: la[0]["index"], # the flat index + lambda _la: 0, # a bare int + ): + with self.subTest(shape=pick.__doc__ or pick): + env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic") + self.addCleanup(env.close) + _obs, info = env.reset(seed=13) + legal = env.legal_actions() + self.assertTrue(legal) + _obs, _r, terminated, truncated, info = env.step(pick(legal)) + self.assertFalse(terminated or truncated) + self.assertEqual(info["turn"], 1) + + def test_unusable_action_shape_raises_a_python_side_type_error(self): + _obs, _info = self.env.reset(seed=1) + with self.assertRaises(TypeError) as ctx: + self.env.step({"label": "use Psystrike"}) + self.assertIn("legal_actions()", str(ctx.exception)) + + def test_step_before_reset_raises(self): + env = PokeArenaEnv(team=SMALL_TEAM) + self.addCleanup(env.close) + with self.assertRaises(Exception): + env.step(0) + + +@requires_engine +class FogOfWarTest(unittest.TestCase): + """The observation must not carry the opponent's hidden information. + + The teams are asymmetric on purpose: in a mirror match every species on the + board is on both teams, so a leaked bench Pokémon would be indistinguishable + from one of your own and the test would pass for the wrong reason. + """ + + FORBIDDEN = ("hp", "max_hp", "stats", "evs", "ivs", "nature", "ability", "item") + + def test_no_hidden_foe_fields_and_no_foe_bench(self): + env = PokeArenaEnv(team=SMALL_TEAM, opponent_team=OTHER_TEAM, opponent="heuristic") + self.addCleanup(env.close) + + obs, info = env.reset(seed=9) + checked = 0 + terminated = truncated = False + while not (terminated or truncated): + foe = obs["foe"] + for key in self.FORBIDDEN: + self.assertNotIn(key, foe, f"observation leaks foe.{key}") + self.assertIn("hp_pct", foe, "the public HP percentage is missing") + # Only the active opponent is present at all: there is no bench. + self.assertNotIn("team", foe) + self.assertIsInstance(obs["foe_bench_alive"], int) + for slot in foe["moves"]: + self.assertEqual(set(slot), {"move_id"}, "foe move slots carry more than an id") + checked += 1 + obs, _r, terminated, truncated, info = env.step(info["legal_actions"][0]["index"]) + self.assertGreater(checked, 5, "the battle was too short to be evidence") + + def test_own_team_is_fully_visible(self): + env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic") + self.addCleanup(env.close) + obs, _info = env.reset(seed=4) + own = obs["self"]["team"] + self.assertEqual(len(own), len(SMALL_TEAM)) + for mon in own: + for key in ("hp", "max_hp", "stats", "moves", "ability"): + self.assertIn(key, mon, f"own Pokemon is missing {key}") + + +@requires_engine +class ConfigurationTest(unittest.TestCase): + def test_agent_can_play_side_one(self): + env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic", agent_side=1) + self.addCleanup(env.close) + obs, info = env.reset(seed=3) + self.assertEqual(obs["me"], 1) + self.assertEqual(info["agents"], ["heuristic", "external"]) + + def test_max_turns_truncates_rather_than_terminates(self): + env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic", max_turns=3) + self.addCleanup(env.close) + _obs, info = env.reset(seed=6) + terminated = truncated = False + while not (terminated or truncated): + _obs, _r, terminated, truncated, info = env.step(info["legal_actions"][0]["index"]) + self.assertTrue(truncated) + self.assertFalse(terminated) + + def test_library_team_by_name(self): + env = PokeArenaEnv(team="Genesis", opponent="random") + self.addCleanup(env.close) + self.assertIn("Genesis", env.team_library) + _obs, info = env.reset(seed=1) + self.assertEqual(info["teams"], ["Genesis", "Genesis"]) + + def test_hp_delta_reward_is_dense(self): + env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic", reward="hp_delta") + self.addCleanup(env.close) + _obs, info = env.reset(seed=31) + saw_nonzero = False + terminated = truncated = False + while not (terminated or truncated): + _obs, reward, terminated, truncated, info = env.step(info["legal_actions"][0]["index"]) + if reward != 0 and not (terminated or truncated): + saw_nonzero = True + self.assertTrue(saw_nonzero, "hp_delta produced no mid-episode reward") + + def test_helpers(self): + env = PokeArenaEnv(team=SMALL_TEAM, opponent="heuristic", render_mode="ansi") + self.addCleanup(env.close) + env.reset(seed=1) + self.assertTrue(env.legal_actions()) + self.assertEqual(len(env.action_mask()), ACTION_SPACE_SIZE) + self.assertIn("self", env.observe()) + env.step(0) + self.assertTrue(env.render(), "ansi render produced no text") + + def test_context_manager(self): + with PokeArenaEnv(team=SMALL_TEAM) as env: + env.reset(seed=0) + self.assertTrue(env._engine.closed) # noqa: SLF001 + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_offline.py b/python/tests/test_offline.py new file mode 100644 index 00000000..7b1b4a9b --- /dev/null +++ b/python/tests/test_offline.py @@ -0,0 +1,159 @@ +"""Tests that need no engine binary. + +These cover the parts of the package that must work regardless: the action +space, the binary-discovery error message, and the promise that importing +``pokearena`` needs no third-party dependency. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest +from pathlib import Path + +import _support # noqa: F401 - puts the package on sys.path + +from pokearena import ( + ACTION_SPACE_SIZE, + ENV_VAR, + GO_INSTALL_COMMAND, + MOVE_SLOTS, + STRUGGLE_INDEX, + SWITCH_BASE, + TEAM_SIZE, + BinaryNotFoundError, + describe_action, + find_binary, +) +from pokearena._util import extract_action +from pokearena.spaces import BattleObservationSpace, action_space, observation_space + + +class ActionSpaceTest(unittest.TestCase): + def test_layout(self): + self.assertEqual(ACTION_SPACE_SIZE, MOVE_SLOTS + 1 + TEAM_SIZE) + self.assertEqual(ACTION_SPACE_SIZE, 11) + self.assertEqual(STRUGGLE_INDEX, MOVE_SLOTS) + self.assertEqual(SWITCH_BASE, MOVE_SLOTS + 1) + + def test_discrete_space(self): + space = action_space(seed=0) + self.assertEqual(space.n, ACTION_SPACE_SIZE) + for _ in range(20): + sample = space.sample() + self.assertTrue(space.contains(sample)) + self.assertFalse(space.contains(ACTION_SPACE_SIZE)) + self.assertFalse(space.contains(-1)) + + def test_observation_space_accepts_dicts(self): + space = observation_space() + self.assertIsInstance(space, BattleObservationSpace) + self.assertTrue(space.contains({"me": 0})) + self.assertFalse(space.contains([1, 2, 3])) + with self.assertRaises(NotImplementedError): + space.sample() + + def test_describe_action(self): + self.assertEqual(describe_action(0), "use move slot 0") + self.assertEqual(describe_action(STRUGGLE_INDEX), "Struggle / forced move") + self.assertEqual(describe_action(SWITCH_BASE + 2), "switch to team slot 2") + self.assertIn("invalid", describe_action(99)) + + +class ExtractActionTest(unittest.TestCase): + """Every action shape a caller plausibly holds, normalised without a round trip.""" + + RECORD = { + "index": 6, + "action": {"kind": "switch", "index": 1}, + "label": "switch to Dragonite", + } + + def test_legal_action_record_is_unwrapped(self): + self.assertEqual(extract_action(self.RECORD), {"kind": "switch", "index": 1}) + + def test_engine_object_passes_through(self): + obj = {"kind": "move", "index": 1, "switch_target": 3} + self.assertEqual(extract_action(obj), obj) + + def test_flat_index_and_int(self): + self.assertEqual(extract_action(self.RECORD["index"]), 6) + self.assertEqual(extract_action(0), 0) + + def test_numpy_style_scalar(self): + class Scalar: + def item(self): + return 3 + + self.assertEqual(extract_action(Scalar()), 3) + + def test_unusable_shapes_raise_here_not_over_the_wire(self): + for bad in ({"label": "use Psystrike"}, {}, {"kind": "forfeit", "index": 0}, None, True): + with self.subTest(bad=bad): + with self.assertRaises(TypeError): + extract_action(bad) + + def test_error_names_the_shapes_that_work(self): + with self.assertRaises(TypeError) as ctx: + extract_action({"label": "nope"}) + message = str(ctx.exception) + self.assertIn("legal_actions()", message) + self.assertIn("kind", message) + + +class BinaryDiscoveryTest(unittest.TestCase): + def test_missing_binary_error_is_actionable(self): + with self.assertRaises(BinaryNotFoundError) as ctx: + find_binary("/nonexistent/path/to/pokearena-env") + self.assertIn("pokearena-env", str(ctx.exception)) + + def test_error_when_nothing_is_installed_names_both_routes(self): + env = { + k: v + for k, v in os.environ.items() + if k not in ("PATH", ENV_VAR, "GOBIN", "GOPATH") + } + env["PATH"] = "/nonexistent-bin" + env["HOME"] = "/nonexistent-home" + script = ( + "import sys; sys.path.insert(0, %r)\n" + "from pokearena import find_binary\n" + "try:\n" + " find_binary()\n" + "except Exception as exc:\n" + " print(exc)\n" + "else:\n" + " print('FOUND')\n" + ) % str(Path(__file__).resolve().parents[1]) + out = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, env=env + ).stdout + if "FOUND" in out: + self.skipTest("a pokearena-env binary is reachable even with PATH cleared") + self.assertIn(GO_INSTALL_COMMAND, out) + self.assertIn(ENV_VAR, out) + self.assertIn("releases", out) + + +class NoRequiredDependenciesTest(unittest.TestCase): + def test_import_without_third_party_packages(self): + """Importing the package must not require gymnasium, pettingzoo or numpy.""" + script = ( + "import sys\n" + "sys.path.insert(0, %r)\n" + "for name in ('gymnasium', 'pettingzoo', 'numpy', 'gym'):\n" + " sys.modules[name] = None\n" + "import pokearena\n" + "env = pokearena.PokeArenaEnv\n" + "assert pokearena.ACTION_SPACE_SIZE == 11\n" + "print('OK', pokearena.__version__)\n" + ) % str(Path(__file__).resolve().parents[1]) + proc = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("OK", proc.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_parallel_env.py b/python/tests/test_parallel_env.py new file mode 100644 index 00000000..6d1cd4ea --- /dev/null +++ b/python/tests/test_parallel_env.py @@ -0,0 +1,129 @@ +"""Tests for the two-agent PettingZoo-style environment.""" + +from __future__ import annotations + +import unittest + +from _support import OTHER_TEAM, SMALL_TEAM, requires_engine + +from pokearena import AGENTS, PokeArenaParallelEnv + + +def _first_legal(infos, agent): + legal = infos[agent]["legal_actions"] + assert legal, f"{agent} has no legal actions while it is to move" + return legal[0]["index"] + + +@requires_engine +class ParallelEnvTest(unittest.TestCase): + def setUp(self): + self.env = PokeArenaParallelEnv(team=SMALL_TEAM) + self.addCleanup(self.env.close) + + def test_reset_gives_every_agent_an_observation(self): + obs, infos = self.env.reset(seed=0) + self.assertEqual(sorted(obs), sorted(AGENTS)) + self.assertEqual(sorted(infos), sorted(AGENTS)) + self.assertEqual(self.env.agents, list(AGENTS)) + self.assertEqual(self.env.agents_to_move, list(AGENTS)) + self.assertEqual(obs["player_0"]["me"], 0) + self.assertEqual(obs["player_1"]["me"], 1) + + def test_full_episode(self): + obs, infos = self.env.reset(seed=11) + steps = 0 + while self.env.agents: + self.assertLess(steps, 1000, "episode did not finish") + actions = {a: _first_legal(infos, a) for a in self.env.agents_to_move} + obs, rewards, terms, truncs, infos = self.env.step(actions) + steps += 1 + self.assertGreater(steps, 0) + self.assertTrue(all(terms.values()) or all(truncs.values())) + self.assertEqual(sum(rewards.values()), 0.0, "rewards are not zero-sum") + self.assertEqual(self.env.agents, [], "agents must empty out when the episode ends") + + def test_replace_phase_asks_only_one_side(self): + """After a single faint only one side chooses a replacement. + + Both agents still get an observation and stay in ``env.agents``; the + engine's ``to_move`` is what says whose action is consumed. + """ + obs, infos = self.env.reset(seed=11) + saw_single = False + while self.env.agents: + if len(self.env.agents_to_move) == 1: + saw_single = True + self.assertEqual(sorted(obs), sorted(AGENTS)) + self.assertEqual(self.env.agents, list(AGENTS)) + actions = {a: _first_legal(infos, a) for a in self.env.agents_to_move} + obs, _r, _t, _tr, infos = self.env.step(actions) + self.assertTrue(saw_single, "no forced-replacement step occurred in this battle") + + def test_every_action_shape_a_caller_might_hold(self): + """The same unwrapping seam exists on the two-agent path.""" + for pick in ( + lambda la: la[0], + lambda la: la[0]["action"], + lambda la: la[0]["index"], + lambda _la: 0, + ): + with self.subTest(shape=pick): + env = PokeArenaParallelEnv(team=SMALL_TEAM) + self.addCleanup(env.close) + _obs, infos = env.reset(seed=13) + actions = {a: pick(infos[a]["legal_actions"]) for a in env.agents_to_move} + _obs, _r, terms, truncs, infos = env.step(actions) + self.assertFalse(any(terms.values()) or any(truncs.values())) + self.assertEqual(infos["player_0"]["turn"], 1) + + def test_missing_action_for_a_moving_agent_raises(self): + _obs, _infos = self.env.reset(seed=1) + with self.assertRaises(KeyError): + self.env.step({"player_0": 0}) + + def test_extra_actions_are_ignored(self): + """The usual ``{a: policy(a) for a in env.agents}`` loop must be safe.""" + _obs, infos = self.env.reset(seed=11) + while self.env.agents: + actions = {a: _first_legal(infos, a) for a in self.env.agents_to_move} + for a in self.env.agents: + actions.setdefault(a, 0) # a stale action for a non-moving side + _obs, _r, _t, _tr, infos = self.env.step(actions) + + def test_spaces(self): + for agent in AGENTS: + self.assertEqual(self.env.action_space(agent).n, 11) + self.assertTrue(self.env.observation_space(agent).contains({})) + with self.assertRaises(KeyError): + self.env.action_space("player_2") + + def test_fog_of_war_per_agent(self): + env = PokeArenaParallelEnv(team=SMALL_TEAM, opponent_team=OTHER_TEAM) + self.addCleanup(env.close) + obs, infos = env.reset(seed=9) + rosters = {a: {p["name"] for p in obs[a]["self"]["team"]} for a in AGENTS} + checked = 0 + while env.agents: + for agent in AGENTS: + foe = obs[agent]["foe"] + for key in ("hp", "max_hp", "stats", "evs", "ivs", "nature", "ability", "item"): + self.assertNotIn(key, foe, f"{agent} sees foe.{key}") + other = AGENTS[1 - AGENTS.index(agent)] + hidden = rosters[other] - {foe["name"]} - rosters[agent] + text = repr(obs[agent]) + for name in hidden: + self.assertNotIn(name, text, f"{agent} sees benched opponent {name}") + checked += 1 + actions = {a: _first_legal(infos, a) for a in env.agents_to_move} + obs, _r, _t, _tr, infos = env.step(actions) + self.assertGreater(checked, 10) + + def test_context_manager(self): + with PokeArenaParallelEnv(team=SMALL_TEAM) as env: + env.reset(seed=0) + self.assertTrue(env._engine.closed) # noqa: SLF001 + + +if __name__ == "__main__": + unittest.main() From 7192b9a0e9c8236c34812b52f3b4e9c3a7f197aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 18:50:47 +0000 Subject: [PATCH 09/12] docs: bench no longer needs a data/ directory The @latest note was written before the dataset was embedded and listed two blockers. Only one is left: the tag. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6c74b119..0479b83f 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,11 @@ go run ./cmd/bench -agents heuristic \ Token cost is **measured** from real usage, never estimated. Full flag table and the agentic-harness comparison: **[docs/running-the-benchmark.md](docs/running-the-benchmark.md)**. -> **On `go run …@latest`:** a module-path invocation -> (`go run github.com/shaumik/PokeArena/cmd/bench@latest`) needs two things this -> repo doesn't have yet — a published tag, **and** a `data/` directory in your -> working directory, because `bench` loads the curated dataset from disk -> (`-data`, default `data`). The clone above is the path that works today. +> **On `go run …@latest`:** `bench` embeds the dataset, so it runs from any +> directory with no `data/` on disk. The module-path form — +> `go run github.com/shaumik/PokeArena/cmd/bench@latest` — starts working the +> moment the first tag is published; until then, use the clone above. Pass +> `-data` to point at a dataset directory of your own. --- From ec3b5ff340df1f222e1309396195503ce7936e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 23:50:49 +0000 Subject: [PATCH 10/12] release: cut a tag from the Actions tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tagging by hand means a clean local checkout and remembering the gates. Add a 'version' input that does it from the Actions tab instead. The tag job runs build, vet and test before creating anything, so a commit that fails the suite never gets tagged. Everything that can refuse a release happens while the tag still does not exist: an unpushed tag costs nothing to abandon, a pushed one is public history that a release may already point at. Tagging lives in this workflow rather than a dedicated one because a tag pushed with GITHUB_TOKEN cannot trigger another workflow — that is GitHub's recursion guard — so a separate tagger would create the tag and then nothing would happen. Doing both in one run needs no secret. The registry publish was gated on 'event_name == push', which would have skipped it for a tag this workflow cut itself. It is now gated on a flag the release job sets, true for a tag push or a freshly created tag and false when only rebuilding an existing tag's artifacts, which is the case the guard was actually there for. --- .github/workflows/release.yml | 115 +++++++++++++++++++++++++++++++--- docs/publishing.md | 31 +++++++++ 2 files changed, 137 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10001def..3fa039e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,13 +15,29 @@ on: push: tags: - "v*" - # Lets the owner re-run the artifact build for an existing tag without - # re-tagging. The registry publish is skipped on this path (see its `if`). + # Two manual modes, both from the Actions tab: + # + # version set -> cut a NEW release: verify, tag, build, publish. + # tag set -> rebuild artifacts for an EXISTING tag. Does not re-publish + # to the registry, which refuses a version it already has. + # + # A tag pushed by CI with GITHUB_TOKEN cannot trigger another workflow (that + # is GitHub's recursion guard), so tagging lives in this same workflow rather + # than in one that would have to wake this one up. workflow_dispatch: inputs: + version: + description: "New version to tag and release, without the v, e.g. 0.1.0" + required: false + type: string tag: - description: "Existing tag to (re)build artifacts for, e.g. v0.1.0" - required: true + description: "Existing tag to rebuild artifacts for, e.g. v0.1.0" + required: false + type: string + ref: + description: "Branch or commit to tag (default: main)" + required: false + default: main type: string permissions: @@ -31,14 +47,82 @@ env: GO_VERSION: "1.26" jobs: + # Only runs for a `version` dispatch. Everything that can say no to a release + # says it here, before the tag exists — an unpushed tag is free to abandon, + # a pushed one is public history that a release may already point at. + tag: + name: Verify and tag + if: github.event_name == 'workflow_dispatch' && inputs.version != '' + runs-on: ubuntu-latest + permissions: + contents: write # push the tag + outputs: + tag: ${{ steps.create.outputs.tag }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + + - name: Validate the version and check the tag is free + id: check + run: | + set -euo pipefail + VERSION="${{ inputs.version }}" + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::version must be semver without a leading v, e.g. 0.1.0 (got '${VERSION}')" + exit 1 + fi + TAG="v${VERSION}" + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "::error::${TAG} already exists. To rebuild its artifacts, re-run this workflow with 'tag' instead of 'version'." + exit 1 + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + # The release job builds too, but that is after the tag is public. A + # tag naming a commit whose tests fail is the one mistake here that + # cannot be taken back cleanly, so the suite gates the tag itself. + - name: Build and test + run: | + set -euo pipefail + go build ./... + go vet ./... + go test ./... + + - name: Create and push the tag + id: create + env: + TAG: ${{ steps.check.outputs.tag }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "PokeArena $TAG" + git push origin "$TAG" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "Tagged $(git rev-parse --short HEAD) as ${TAG}" >> "$GITHUB_STEP_SUMMARY" + release: name: Build and release artifacts + needs: [tag] + # `tag` is skipped on a tag push and on a rebuild dispatch; neither is a + # reason not to release. A tag job that actually failed is. + if: always() && needs.tag.result != 'failure' && needs.tag.result != 'cancelled' runs-on: ubuntu-latest permissions: contents: write # create the release, upload assets outputs: version: ${{ steps.meta.outputs.version }} tag: ${{ steps.meta.outputs.tag }} + publish: ${{ steps.meta.outputs.publish }} mcpb_sha256: ${{ steps.mcpb.outputs.sha256 }} mcpb_url: ${{ steps.mcpb.outputs.url }} @@ -48,16 +132,29 @@ jobs: with: # GoReleaser needs full history + tags to build the changelog. fetch-depth: 0 - ref: ${{ inputs.tag || github.ref }} + ref: ${{ needs.tag.outputs.tag || inputs.tag || github.ref }} - name: Resolve version id: meta run: | set -euo pipefail - TAG="${{ inputs.tag || github.ref_name }}" + TAG="${{ needs.tag.outputs.tag || inputs.tag || github.ref_name }}" + if [ -z "$TAG" ]; then + echo "::error::nothing to release: set 'version' to cut a new release, or 'tag' to rebuild an existing one" + exit 1 + fi echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + # The registry rejects a version it already holds, so only a genuinely + # new release publishes: a tag push, or a dispatch that just made the + # tag. Rebuilding an existing tag's artifacts must not try again. + if [ "${{ github.event_name }}" = "push" ] || [ "${{ needs.tag.result }}" = "success" ]; then + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} @@ -232,9 +329,9 @@ jobs: publish-registry: name: Publish to the MCP Registry needs: release - # Only a real tag push publishes. A manual artifact rebuild must not - # re-publish a version the registry already has. - if: github.event_name == 'push' + # Set by the release job: true for a tag push or a freshly cut tag, false + # when we are only rebuilding an existing tag's artifacts. + if: needs.release.outputs.publish == 'true' runs-on: ubuntu-latest permissions: contents: read diff --git a/docs/publishing.md b/docs/publishing.md index 0287a3cd..83aa0da3 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -25,6 +25,37 @@ required. ## 1. Cut `v0.1.0` +Two ways. Both end in the same place: a `v*` tag, a GitHub Release with the +binaries, and the registry record published. + +### 1a. From the Actions tab (no local checkout) + +**Actions → Release → Run workflow**, then fill in **`version`** with `0.1.0` +— no leading `v`, the workflow adds it — and leave `tag` blank. Optionally set +`ref` to a branch or commit other than `main`. + +The `tag` job runs `go build`, `go vet` and `go test` **before** it creates the +tag, so a commit that fails the suite never gets tagged at all. Only after that +passes does it push the tag, and the rest of the run builds and publishes. + +Two things worth knowing about why it is shaped this way: + +- **Tagging lives inside `release.yml` rather than in a workflow of its own.** + A tag pushed by CI with the default `GITHUB_TOKEN` cannot trigger another + workflow — GitHub blocks that to stop workflows re-triggering themselves + forever. A separate tagger would create the tag and then nothing would + happen. Doing both in one run sidesteps that with no secret to manage. +- **The tag is the point of no return.** Everything that can refuse a release + happens before the tag exists, because an unpushed tag costs nothing to + abandon and a pushed one is public history that a release may already point + at. + +To rebuild the artifacts for a tag that already exists, run the same workflow +with **`tag`** set (e.g. `v0.1.0`) and `version` blank. That path deliberately +skips the registry publish — the registry rejects a version it already holds. + +### 1b. From your machine + From a clean checkout of the commit you want to release: ```bash From 532886d9a7c8154bafdc9fa0cbf4cb5953d65c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 00:00:51 +0000 Subject: [PATCH 11/12] Fix lint: US spelling and gofumpt golangci-lint enforces locale US and gofumpt, which is stricter than the gofmt I had been checking with. Three British spellings and one composite literal that gofumpt splits across lines. --- cmd/bench/main.go | 2 +- cmd/pokearena-env/env_test.go | 2 +- cmd/pokearena-env/main.go | 6 ++++-- cmd/pokearena-env/session.go | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/bench/main.go b/cmd/bench/main.go index 28e90543..aff07824 100644 --- a/cmd/bench/main.go +++ b/cmd/bench/main.go @@ -543,7 +543,7 @@ func (d dataset) describe() string { return d.dir } -// teamLibrary honours an explicit -teams path; at the default it reads from +// teamLibrary honors an explicit -teams path; at the default it reads from // whichever dataset we resolved, so -data and the embedded copy both work. func (d dataset) teamLibrary(path string, dex *domain.Dex) (*eval.TeamLibrary, error) { if path != defaultTeamsPath { diff --git a/cmd/pokearena-env/env_test.go b/cmd/pokearena-env/env_test.go index 0d08b588..ff07452b 100644 --- a/cmd/pokearena-env/env_test.go +++ b/cmd/pokearena-env/env_test.go @@ -674,7 +674,7 @@ func TestProtocol_ErrorsAreObjectsNotCrashes(t *testing.T) { // TestServe_EndToEndOverPipes drives the real serve loop over an io.Reader / // io.Writer pair — the same loop main() hands stdin and stdout — and checks the -// output is one JSON object per line, ids echoed, close honoured, and nothing +// output is one JSON object per line, ids echoed, close honored, and nothing // written after close. func TestServe_EndToEndOverPipes(t *testing.T) { srv, err := newServer("", "", "test", 2) diff --git a/cmd/pokearena-env/main.go b/cmd/pokearena-env/main.go index 3d33b122..d6f7ec66 100644 --- a/cmd/pokearena-env/main.go +++ b/cmd/pokearena-env/main.go @@ -166,8 +166,10 @@ func (s *server) handleLine(line []byte) (resp Response, stop bool) { // down: the client would see a closed pipe and no explanation. Turn it // into an error response naming the command that caused it. if r := recover(); r != nil { - resp = Response{ID: req.ID, Cmd: req.Cmd, OK: false, - Error: errorf(ErrInternal, "panic handling %q: %v", req.Cmd, r)} + resp = Response{ + ID: req.ID, Cmd: req.Cmd, OK: false, + Error: errorf(ErrInternal, "panic handling %q: %v", req.Cmd, r), + } stop = false // The episode is no longer trustworthy after a panic mid-resolve; // drop it so the next reset starts clean rather than compounding. diff --git a/cmd/pokearena-env/session.go b/cmd/pokearena-env/session.go index 13681528..17d9b57f 100644 --- a/cmd/pokearena-env/session.go +++ b/cmd/pokearena-env/session.go @@ -380,7 +380,7 @@ func (ep *episode) actionFor(side int, supplied map[int]engine.Action) (engine.A // baselineAction asks a built-in agent for its move, over the same fog-of-war // View an external client gets. An error or an illegal proposal is replaced by -// the first legal action and flagged — identical to eval.RunGame's behaviour, +// the first legal action and flagged — identical to eval.RunGame's behavior, // which is what keeps the two drivers' trajectories equal. func (ep *episode) baselineAction(side int) engine.Action { v := ai.MakeView(ep.state, side) From be70312774fb2e1eef8242f758f7514fc40ed3d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 00:03:59 +0000 Subject: [PATCH 12/12] gwclient: a refused dial should say what to do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most likely first experience of anyone who installs pokearena-mcp from the MCP registry is a refused connection: the default gateway is ws://localhost:8080 and they have no arena running. They got 'connection refused' and nothing else — no hint that an arena is a thing they need, let alone how to get one. An agent relaying that message could not help either. Name the URL we tried and both ways forward. Only an unreachable dial gets this; a gateway that answered and refused us has a different problem and keeps its own error. --- internal/gwclient/gwclient.go | 29 ++++++++++++++++++++++++++++- internal/gwclient/gwclient_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/gwclient/gwclient.go b/internal/gwclient/gwclient.go index b76c53ca..9cc3a9ec 100644 --- a/internal/gwclient/gwclient.go +++ b/internal/gwclient/gwclient.go @@ -10,8 +10,12 @@ package gwclient import ( "context" + "errors" + "fmt" + "net" "net/url" "sync" + "syscall" "github.com/shaumik/PokeArena/internal/protocol" @@ -64,7 +68,7 @@ func dialPath(ctx context.Context, baseURL, path string) (*Client, error) { } conn, resp, err := websocket.DefaultDialer.DialContext(ctx, u, nil) if err != nil { - return nil, err + return nil, unreachableErr(baseURL, err) } if resp != nil && resp.Body != nil { _ = resp.Body.Close() // handshake response body; close it for hygiene @@ -170,3 +174,26 @@ func joinURL(baseURL, path string) (string, error) { u.RawQuery = p.RawQuery return u.String(), nil } + +// unreachableErr turns a refused dial into instructions. +// +// The common case is not a bug: someone installed pokearena-mcp from the MCP +// registry, where the default gateway is ws://localhost:8080, and has no arena +// running. Bare "connection refused" tells them nothing about what an arena is +// or how to get one, and an agent relaying that message cannot help either. So +// name the URL we tried and both ways forward. +// +// Only a dial that could not reach anything gets this treatment; a gateway +// that answered and rejected us is a different problem and keeps its own +// error. +func unreachableErr(baseURL string, err error) error { + var netErr net.Error + if !errors.As(err, &netErr) && !errors.Is(err, syscall.ECONNREFUSED) { + return err + } + return fmt.Errorf( + "no PokéArena gateway at %s: %w\n"+ + " Start one locally: docker compose up -d (then it is at ws://localhost:8080)\n"+ + " Or point at another arena: set POKEARENA_GATEWAY_URL=wss://your.host", + baseURL, err) +} diff --git a/internal/gwclient/gwclient_test.go b/internal/gwclient/gwclient_test.go index 7053fb20..a123c02e 100644 --- a/internal/gwclient/gwclient_test.go +++ b/internal/gwclient/gwclient_test.go @@ -223,3 +223,32 @@ func drain(t *testing.T, gc *Client, n int) []protocol.MatchUpdate { } return out } + +// A refused dial is the single most likely first experience of anyone who +// installed pokearena-mcp from the MCP registry: the default gateway is +// ws://localhost:8080 and they have no arena running. The error has to say +// what to do about it, because "connection refused" does not. +func TestDial_UnreachableGatewayExplainsItself(t *testing.T) { + // Port 1 is reserved and nothing listens on it, so this dial is refused + // rather than hanging or resolving somewhere unexpected. + const base = "ws://127.0.0.1:1" + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := Dial(ctx, base, "battle-x", "p1", "tok") + if err == nil { + t.Fatal("dial to a dead port returned no error") + } + + msg := err.Error() + for _, want := range []string{ + base, // which gateway we actually tried + "docker compose up -d", // how to get one locally + "POKEARENA_GATEWAY_URL", // how to point somewhere else + } { + if !strings.Contains(msg, want) { + t.Errorf("error should mention %q, got:\n%s", want, msg) + } + } +}