From 21dea47694b45d2928104bec0d5f690e47e3db93db4049dd043bb0c2c85cfa71 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Thu, 23 Jul 2026 14:03:09 -0400 Subject: [PATCH] v0.10.0 fix to engine game state mgmt, client and web ui updates to match --- doc/api.md | 6 +- doc/architecture.md | 6 +- doc/client.md | 4 +- go.mod | 20 +- go.sum | 21 + internal/client/command/game.go | 147 +++--- internal/server/engine/engine.go | 445 ++++++++++-------- internal/server/http/handler.go | 6 +- internal/server/processor/processor.go | 246 +++++----- internal/server/processor/queue.go | 103 ++-- internal/server/service/game.go | 44 +- internal/server/service/waiter.go | 13 +- .../server/webserver/chess-client-web/app.js | 22 +- .../webserver/chess-client-web/style.css | 63 +-- test/test-db.sh | 40 +- test/test-endstate.sh | 94 ++++ test/test-longpoll.sh | 10 +- 17 files changed, 741 insertions(+), 549 deletions(-) create mode 100755 test/test-endstate.sh diff --git a/doc/api.md b/doc/api.md index 7f127f9..56aef93 100644 --- a/doc/api.md +++ b/doc/api.md @@ -156,7 +156,7 @@ Returns current game state. **Long-polling support:** Add query parameters for real-time updates: -- `wait=true` - Enable long-polling (waits up to 25 seconds) +- `wait=true` - Enable long-polling (waits up to 30 seconds) - `moveCount=N` - Last known move count Returns immediately if game state changed, otherwise waits for updates: @@ -167,7 +167,7 @@ GET /games/{gameId}?wait=true&moveCount=5 Response includes all game data. Compare `moves` array length to detect changes. **Timeout behavior:** -- Returns current state after 25 seconds even if no changes +- Returns current state after 30 seconds even if no changes - Client disconnection cancels wait immediately - Game deletion notifies all waiting clients @@ -242,4 +242,4 @@ Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header: Authorization: Bearer ``` -Token claims include `sub` (user ID), `username`, `email`, and `exp` (expiration). \ No newline at end of file +Token claims include `sub` (user ID), `username`, `email`, and `exp` (expiration). diff --git a/doc/architecture.md b/doc/architecture.md index 2c68003..a29422d 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -12,7 +12,7 @@ Central command handler containing business logic. Single `Execute(Command)` ent In-memory state storage with authentication support. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, player configuration, user accounts, and JWT token generation. Coordinates with storage layer for persistence of both games and users. #### Long-Polling Registry (`internal/service/waiter.go`) -Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 25-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events. +Manages clients waiting for game state changes via HTTP long-polling. Tracks move counts per client, sends notifications on state changes, enforces 30-second timeout. Non-blocking notification pattern handles slow clients gracefully. Coordinates with service layer for game updates and deletion events. #### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`) - **Password Hashing**: Argon2id for secure password storage @@ -72,7 +72,7 @@ SQLite persistence with async writes for games, synchronous writes for authentic 1. Client sends `GET /games/{id}?wait=true&moveCount=N` 2. Handler creates context from HTTP connection 3. Registers wait with WaitRegistry using game ID and move count -4. If game state unchanged, blocks up to 25 seconds +4. If game state unchanged, blocks up to 30 seconds 5. On any game update, NotifyGame sends to all waiters 6. Returns immediately with current state 7. Client disconnection cancels wait via context @@ -210,4 +210,4 @@ moves ( - JWT secret rotates on restart (or fixed in dev mode) - User IDs use UUIDs with collision detection - Transactions ensure data consistency -- Case-insensitive queries prevent duplicate accounts \ No newline at end of file +- Case-insensitive queries prevent duplicate accounts diff --git a/doc/client.md b/doc/client.md index b676f2a..b9a0930 100644 --- a/doc/client.md +++ b/doc/client.md @@ -135,7 +135,7 @@ chess > delete # Delete specific game ``` #### `poll` / `p` -Long-poll for game updates (waits up to 25 seconds). +Long-poll for game updates (waits up to 30 seconds). ``` chess > poll ``` @@ -290,4 +290,4 @@ The client architecture follows a command pattern with: - **Display**: Terminal formatting utilities - **Commands**: Modular command handlers -Extensions can add new commands by registering handlers in the appropriate command group (game, auth, debug). \ No newline at end of file +Extensions can add new commands by registering handlers in the appropriate command group (game, auth, debug). diff --git a/go.mod b/go.mod index fa27007..0886ca2 100644 --- a/go.mod +++ b/go.mod @@ -6,28 +6,28 @@ require ( github.com/go-playground/validator/v10 v10.30.3 github.com/gofiber/fiber/v2 v2.52.14 github.com/google/uuid v1.6.0 - github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 - github.com/mattn/go-sqlite3 v1.14.47 - golang.org/x/term v0.44.0 + github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 + github.com/mattn/go-sqlite3 v1.14.48 + golang.org/x/term v0.45.0 ) require ( github.com/andybalholm/brotli v1.2.2 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gabriel-vasile/mimetype v1.4.14 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/klauspost/compress v1.19.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect + github.com/klauspost/compress v1.19.1 // indirect + github.com/leodido/go-urn v1.5.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.72.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index 72c40c7..f8d4165 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.14 h1:8eyElddS5wbWNDG4sIupw+IX2jEjHX2aqAAq/9C3M8s= +github.com/gabriel-vasile/mimetype v1.4.14/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -38,10 +40,16 @@ github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXD github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA= github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226/go.mod h1:1Kfy3ggtRbgrzR+qg99SaeUmmnUZKtur8uOSQsbWaPw= +github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8 h1:AOj9NHdpB3mSRXHYr4iOhFypovIDtzwWMAM80o6JNEY= +github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8/go.mod h1:anmBvIoOyZGcw/TaZPZAvzCYoFOBgVJji5MGP5MFZJE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -50,6 +58,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= @@ -64,10 +74,13 @@ github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQ github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= @@ -90,6 +103,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -97,12 +112,16 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= @@ -111,5 +130,7 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/command/game.go b/internal/client/command/game.go index a6ea31a..015860b 100644 --- a/internal/client/command/game.go +++ b/internal/client/command/game.go @@ -6,7 +6,6 @@ import ( "os" "strconv" "strings" - "time" "chess/internal/client/api" "chess/internal/client/display" @@ -234,6 +233,9 @@ func joinGameHandler(s *session.Session, args []string) error { return nil } +// moveHandler submits a human move. Terminal/error outcomes are reported via +// the shared printOutcome (no-op on "ongoing"/"pending"); the computer-turn +// hint stays local since it only applies after a successful human move. func moveHandler(s *session.Session, args []string) error { if len(args) < 1 { return fmt.Errorf("usage: move ") @@ -256,29 +258,13 @@ func moveHandler(s *session.Session, args []string) error { s.CurrentGameState = resp display.Println(display.Green, "Move accepted") - // Check if game ended - switch resp.State { - case "checkmate": - winner := "Black" - if resp.Turn == "b" { // Turn switches after move, so if black's turn after checkmate, white won - winner = "White" - } - display.Println(display.Green, "\nCHECKMATE! %s wins!", winner) - case "stalemate": - display.Println(display.Yellow, "\nSTALEMATE! Game drawn.") - case "draw": - display.Println(display.Yellow, "\nDRAW! Game drawn.") - case "ongoing": - // Check if computer needs to play - currentTurn := resp.Turn - var computerPlayer *api.PlayerInfo - if currentTurn == "w" && resp.Players.White.Type == 2 { - computerPlayer = &resp.Players.White - } else if currentTurn == "b" && resp.Players.Black.Type == 2 { - computerPlayer = &resp.Players.Black - } + printOutcome(resp) - if computerPlayer != nil { + // Hint to trigger the computer if the game continues on a computer's turn + if resp.State == "ongoing" { + isComputerTurn := (resp.Turn == "w" && resp.Players.White.Type == 2) || + (resp.Turn == "b" && resp.Players.Black.Type == 2) + if isComputerTurn { display.Println(display.Magenta, "\nComputer's turn. Use 'computer' or 'c' to trigger move.") } } @@ -286,6 +272,12 @@ func moveHandler(s *session.Session, args []string) error { return nil } +// computerMoveHandler triggers a computer move and waits for the result via +// the server's long-poll. With the state-aware waiter, a single poll wakes on +// either the applied move (move count delta) or a state-only settle +// (mate-without-move, stuck) — no fixed-interval GET hammering, no hard cap +// below the server's max searchTime. Polls loop only if the wake races the +// pending window (e.g. queue wait), each round costing at most WaitTimeout. func computerMoveHandler(s *session.Session, args []string) error { gameID := s.CurrentGame if gameID == "" { @@ -294,55 +286,82 @@ func computerMoveHandler(s *session.Session, args []string) error { c := s.Client + // Baseline BEFORE triggering: the long-poll returns immediately if the + // move count already differs from this value. + baselineMoves := s.LastMoveCount + if s.CurrentGameState != nil { + baselineMoves = len(s.CurrentGameState.Moves) + } + resp, err := c.MakeMove(gameID, "cccc") if err != nil { return err } - if resp.State == "pending" { - display.Println(display.Magenta, "Computer is thinking...") - - // Poll for completion - for i := 0; i < 50; i++ { - time.Sleep(200 * time.Millisecond) - resp2, err := c.GetGame(gameID) - if err == nil && resp2.State != "pending" { - s.LastMoveCount = len(resp2.Moves) - s.CurrentGameState = resp2 - if resp2.LastMove != nil { - display.Print(display.Magenta, "Computer played: %s", resp2.LastMove.Move) - if resp2.LastMove.Depth > 0 { - fmt.Printf(" (depth %d, score %d)", resp2.LastMove.Depth, resp2.LastMove.Score) - } - fmt.Println() - } - - // Check if game ended after computer move - switch resp2.State { - case "checkmate": - winner := "Black" - if resp2.Turn == "b" { - winner = "White" - } - display.Println(display.Green, "\nCHECKMATE! %s wins!", winner) - case "stalemate": - display.Println(display.Yellow, "\nSTALEMATE! Game drawn.") - case "draw": - display.Println(display.Yellow, "\nDRAW! Game drawn.") - } - - return nil - } - } - return fmt.Errorf("timeout waiting for computer move") + if resp.State != "pending" { + // Server resolved synchronously (shouldn't normally happen) + s.LastMoveCount = len(resp.Moves) + s.CurrentGameState = resp + display.Println(display.Green, "Move triggered") + printOutcome(resp) + return nil } - s.LastMoveCount = len(resp.Moves) - s.CurrentGameState = resp - display.Println(display.Green, "Move triggered") + display.Println(display.Magenta, "Computer is thinking...") + + // Up to 3 long-poll rounds (~90s ceiling) covers max searchTime (10s) + // plus pathological queue wait, without hanging indefinitely. + const maxPolls = 3 + var final *api.GameResponse + for i := 0; i < maxPolls; i++ { + polled, err := c.GetGameWithPoll(gameID, baselineMoves) + if err != nil { + return err + } + if polled.State != "pending" { + final = polled + break + } + // Woke on timeout while still pending; poll again. + } + if final == nil { + return fmt.Errorf("computer move still pending after %d poll rounds", maxPolls) + } + + s.LastMoveCount = len(final.Moves) + s.CurrentGameState = final + + // A move may legitimately be absent: mate-without-move detection or a + // stuck transition settle the state without applying anything. + if final.LastMove != nil && len(final.Moves) > baselineMoves { + display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move) + if final.LastMove.Depth > 0 { + fmt.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score) + } + fmt.Println() + } + + printOutcome(final) return nil } +// printOutcome reports terminal or error states using the server's actual +// State.String() values ("white wins"/"black wins", not "checkmate"). +func printOutcome(resp *api.GameResponse) { + switch resp.State { + case "white wins": + display.Println(display.Green, "\nCHECKMATE! White wins!") + case "black wins": + display.Println(display.Green, "\nCHECKMATE! Black wins!") + case "stalemate": + display.Println(display.Yellow, "\nSTALEMATE! Game drawn.") + case "draw": + display.Println(display.Yellow, "\nDRAW! Game drawn.") + case "stuck": + display.Println(display.Yellow, "\nEngine error — 'undo' to recover, or 'new'/'delete'.") + } +} + func undoHandler(s *session.Session, args []string) error { gameID := s.GetCurrentGame() if gameID == "" { @@ -490,7 +509,7 @@ func pollHandler(s *session.Session, args []string) error { moveCount := s.GetLastMoveCount() display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount) - display.Println(display.Cyan, "This may take up to 25 seconds") + display.Println(display.Cyan, "This may take up to 30 seconds") resp, err := c.GetGameWithPoll(gameID, moveCount) if err != nil { @@ -510,4 +529,4 @@ func pollHandler(s *session.Session, args []string) error { } return nil -} \ No newline at end of file +} diff --git a/internal/server/engine/engine.go b/internal/server/engine/engine.go index a739e12..8b541e5 100644 --- a/internal/server/engine/engine.go +++ b/internal/server/engine/engine.go @@ -2,7 +2,7 @@ package engine import ( "bufio" - "context" + "errors" "fmt" "io" "os/exec" @@ -11,13 +11,27 @@ import ( "time" ) -const enginePath = "stockfish" +const ( + enginePath = "stockfish" + handshakeTimeout = 5 * time.Second + barrierTimeout = 5 * time.Second + diagnoseTimeout = 3 * time.Second + probeTimeout = 3 * time.Second + lineBuffer = 512 +) +var ErrEngineTimeout = errors.New("engine timeout") + +// UCI wraps a stockfish process. All engine dialogue is a serialized +// request/response transaction under mu; a single reader goroutine owns stdout +// for the life of the process. Any timeout/EOF kills and respawns the process: +// output desync cannot survive into the next call. type UCI struct { - cmd *exec.Cmd - stdin io.WriteCloser - stdout *bufio.Scanner - mu sync.Mutex + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + lines chan string + alive bool } type SearchResult struct { @@ -28,225 +42,246 @@ type SearchResult struct { MateIn int } -func New() (*UCI, error) { - cmd := exec.Command(enginePath) - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - - if err = cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start engine: %v", err) - } - - uci := &UCI{ - cmd: cmd, - stdin: stdin, - stdout: bufio.NewScanner(stdout), - } - - if err := uci.initialize(); err != nil { - uci.Close() - return nil, err - } - - return uci, nil +type Diagnosis struct { + FEN string + InCheck bool } -// SetSkillLevel sets the Stockfish skill level (0-20) -func (u *UCI) SetSkillLevel(level int) { +func New() (*UCI, error) { + u := &UCI{} + u.mu.Lock() + defer u.mu.Unlock() + if err := u.spawnLocked(); err != nil { + return nil, err + } + return u, nil +} + +func (u *UCI) spawnLocked() error { + cmd := exec.Command(enginePath) + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("start engine: %w", err) + } + + lines := make(chan string, lineBuffer) + go func() { + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 64*1024), 1<<20) + for sc.Scan() { + lines <- sc.Text() + } + close(lines) // EOF: process exited or was killed + }() + + u.cmd, u.stdin, u.lines, u.alive = cmd, stdin, lines, true + + if _, err := u.txLocked(handshakeTimeout, []string{"uci"}, "uciok", nil); err != nil { + u.killLocked() + return fmt.Errorf("uci handshake: %w", err) + } + if _, err := u.txLocked(handshakeTimeout, []string{"isready"}, "readyok", nil); err != nil { + u.killLocked() + return fmt.Errorf("uci handshake: %w", err) + } + return nil +} + +// killLocked hard-stops the process. Reaping is deferred to a goroutine that +// first drains the line channel to completion, so cmd.Wait never races the +// reader's final reads on the stdout pipe. +func (u *UCI) killLocked() { + u.alive = false + if u.cmd != nil && u.cmd.Process != nil { + u.cmd.Process.Kill() + } + if u.stdin != nil { + u.stdin.Close() + } + if u.lines != nil { + go func(ch chan string, cmd *exec.Cmd) { + for range ch { + } + cmd.Wait() + }(u.lines, u.cmd) + } +} + +func (u *UCI) restartLocked() { + u.killLocked() + _ = u.spawnLocked() // on failure alive stays false; next tx errors immediately +} + +func (u *UCI) drainLocked() { + for { + select { + case _, ok := <-u.lines: + if !ok { + return + } + default: + return + } + } +} + +// txLocked: drain stale lines, send commands, read to the terminal prefix. +// visit observes every line including the terminal one. Timeout is +// per-transaction total. +func (u *UCI) txLocked(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) { + if !u.alive { + return "", errors.New("engine not running") + } + u.drainLocked() + for _, c := range cmds { + if _, err := fmt.Fprintln(u.stdin, c); err != nil { + u.restartLocked() + return "", fmt.Errorf("engine write: %w", err) + } + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + for { + select { + case ln, ok := <-u.lines: + if !ok { + u.restartLocked() + return "", errors.New("engine closed unexpectedly") + } + if visit != nil { + visit(ln) + } + if strings.HasPrefix(ln, terminal) { + return ln, nil + } + case <-deadline.C: + u.restartLocked() + return "", fmt.Errorf("%w awaiting %q", ErrEngineTimeout, terminal) + } + } +} + +func (u *UCI) tx(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) { + u.mu.Lock() + defer u.mu.Unlock() + return u.txLocked(timeout, cmds, terminal, visit) +} + +func (u *UCI) NewGame() error { + _, err := u.tx(barrierTimeout, []string{"ucinewgame", "isready"}, "readyok", nil) + return err +} + +func (u *UCI) SetSkillLevel(level int) error { if level < 0 { level = 0 } else if level > 20 { level = 20 } - u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level)) + _, err := u.tx(barrierTimeout, + []string{fmt.Sprintf("setoption name Skill Level value %d", level), "isready"}, + "readyok", nil) + return err } -// Get FEN from Stockfish's debug ('d') command -func (u *UCI) GetFEN() (string, error) { - u.sendCommand("d") - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - done := make(chan string, 1) - go func() { - for u.stdout.Scan() { - line := u.stdout.Text() - if strings.HasPrefix(line, "Fen: ") { - done <- strings.TrimPrefix(line, "Fen: ") - return - } - } - done <- "" - }() - - select { - case fen := <-done: - if fen == "" { - return "", fmt.Errorf("failed to get FEN from engine") - } - return fen, nil - case <-ctx.Done(): - return "", fmt.Errorf("timeout getting FEN") - } -} - -func (u *UCI) initialize() error { - u.sendCommand("uci") - - // Wait for uciok with timeout - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - done := make(chan bool) - go func() { - for u.stdout.Scan() { - if u.stdout.Text() == "uciok" { - done <- true - return - } - } - done <- false - }() - - select { - case success := <-done: - if !success { - return fmt.Errorf("engine closed unexpectedly") - } - case <-ctx.Done(): - return fmt.Errorf("timeout waiting for uciok") - } - - u.sendCommand("isready") - return u.waitReady() -} - -func (u *UCI) waitReady() error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - done := make(chan error) - go func() { - for u.stdout.Scan() { - if u.stdout.Text() == "readyok" { - done <- nil - return - } - } - done <- fmt.Errorf("engine closed unexpectedly") - }() - - select { - case err := <-done: - return err - case <-ctx.Done(): - return fmt.Errorf("timeout waiting for readyok") - } -} - -func (u *UCI) sendCommand(cmd string) { - u.mu.Lock() - defer u.mu.Unlock() - fmt.Fprintln(u.stdin, cmd) -} - -func (u *UCI) NewGame() { - u.sendCommand("ucinewgame") - u.sendCommand("isready") - u.waitReady() -} - -func (u *UCI) SetPosition(fen string, moves []string) { - cmd := fmt.Sprintf("position fen %s", fen) +func (u *UCI) SetPosition(fen string, moves []string) error { + cmd := "position fen " + fen if len(moves) > 0 { cmd += " moves " + strings.Join(moves, " ") } - u.sendCommand(cmd) + _, err := u.tx(barrierTimeout, []string{cmd, "isready"}, "readyok", nil) + return err +} + +// Diagnose runs `d` and consumes its full output. Terminal line is "Checkers:" +// (last line of `d` in current Stockfish; verify against the jailed build — +// see context requests). +func (u *UCI) Diagnose() (Diagnosis, error) { + var d Diagnosis + last, err := u.tx(diagnoseTimeout, []string{"d"}, "Checkers:", func(ln string) { + if s, ok := strings.CutPrefix(ln, "Fen: "); ok { + d.FEN = strings.TrimSpace(s) + } + }) + if err != nil { + return Diagnosis{}, err + } + if d.FEN == "" { + return Diagnosis{}, errors.New("d output missing Fen line") + } + d.InCheck = strings.TrimSpace(strings.TrimPrefix(last, "Checkers:")) != "" + return d, nil +} + +// HasLegalMoves probes with a depth-1 search: deterministic, milliseconds. +func (u *UCI) HasLegalMoves() (bool, error) { + last, err := u.tx(probeTimeout, []string{"go depth 1"}, "bestmove ", nil) + if err != nil { + return false, err + } + f := strings.Fields(last) + return len(f) >= 2 && f[1] != "(none)", nil } func (u *UCI) Search(timeMs int) (*SearchResult, error) { - u.sendCommand(fmt.Sprintf("go movetime %d", timeMs)) - - result := &SearchResult{} - - // Add timeout protection (2x the search time + buffer) - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond) - defer cancel() - - done := make(chan error) - go func() { - for u.stdout.Scan() { - line := u.stdout.Text() - - if strings.HasPrefix(line, "info ") { - fields := strings.Fields(line) - for i := 0; i < len(fields)-1; i++ { - switch fields[i] { - case "depth": - fmt.Sscanf(fields[i+1], "%d", &result.Depth) - case "cp": - fmt.Sscanf(fields[i+1], "%d", &result.Score) - result.IsMate = false - case "mate": - fmt.Sscanf(fields[i+1], "%d", &result.MateIn) - result.IsMate = true - // Convert mate score to centipawn equivalent for backwards compatibility - if result.MateIn > 0 { - result.Score = 100000 - result.MateIn - } else { - result.Score = -100000 - result.MateIn - } - } + r := &SearchResult{} + timeout := time.Duration(timeMs)*time.Millisecond + 5*time.Second + last, err := u.tx(timeout, []string{fmt.Sprintf("go movetime %d", timeMs)}, "bestmove ", func(ln string) { + if !strings.HasPrefix(ln, "info ") { + return + } + f := strings.Fields(ln) + for i := 0; i < len(f)-1; i++ { + switch f[i] { + case "depth": + fmt.Sscanf(f[i+1], "%d", &r.Depth) + case "cp": + fmt.Sscanf(f[i+1], "%d", &r.Score) + r.IsMate = false + case "mate": + fmt.Sscanf(f[i+1], "%d", &r.MateIn) + r.IsMate = true + if r.MateIn > 0 { + r.Score = 100000 - r.MateIn + } else { + r.Score = -100000 - r.MateIn } } - - if strings.HasPrefix(line, "bestmove ") { - parts := strings.Fields(line) - if len(parts) >= 2 { - result.BestMove = parts[1] - } - done <- nil - return - } } - done <- fmt.Errorf("engine closed unexpectedly") - }() - - select { - case err := <-done: - if err != nil { - return nil, err - } - return result, nil - case <-ctx.Done(): - return nil, fmt.Errorf("timeout waiting for bestmove") + }) + if err != nil { + return nil, err } + f := strings.Fields(last) + if len(f) >= 2 { + r.BestMove = f[1] + } + return r, nil } func (u *UCI) Close() error { - u.sendCommand("quit") - time.Sleep(100 * time.Millisecond) - - // Try graceful shutdown first - done := make(chan error, 1) - go func() { - done <- u.cmd.Wait() - }() - - select { - case <-done: - return nil - case <-time.After(1 * time.Second): - // Force kill if doesn't exit gracefully - return u.cmd.Process.Kill() + u.mu.Lock() + defer u.mu.Unlock() + if u.alive { + fmt.Fprintln(u.stdin, "quit") + done := make(chan struct{}) + go func() { u.cmd.Wait(); close(done) }() + u.alive = false + select { + case <-done: + u.stdin.Close() + return nil + case <-time.After(1 * time.Second): + } } -} \ No newline at end of file + u.killLocked() + return nil +} + diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 2fd4d15..50f2e93 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -333,9 +333,10 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error { } currentMoveCount := len(g.Moves()) - + st := g.State() + settled := st != core.StateOngoing && st != core.StatePending // If move count already different, return immediately - if moveCount != currentMoveCount { + if moveCount != currentMoveCount || settled { cmd := processor.NewGetGameCommand(gameID) resp := h.proc.Execute(cmd) if !resp.Success { @@ -518,4 +519,3 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error { return c.JSON(resp.Data) } - diff --git a/internal/server/processor/processor.go b/internal/server/processor/processor.go index 3f31ff1..4468df0 100644 --- a/internal/server/processor/processor.go +++ b/internal/server/processor/processor.go @@ -114,7 +114,9 @@ func (p *Processor) isMoveSafe(move string) bool { return true } -// handleCreateGame creates a new game and triggers computer move if needed +// handleCreateGame creates a new game. The initial FEN is classified BEFORE +// persisting: a terminal initial position is terminal in the creation response, +// and engine failure fails the request instead of creating a half-valid game. func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { args, ok := cmd.Args.(core.CreateGameRequest) if !ok { @@ -122,10 +124,10 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { } // Enforce minimum searchTime for computer players - if args.White.Type == core.PlayerComputer && args.White.SearchTime < 100 { + if args.White.Type == core.PlayerComputer && args.White.SearchTime < minSearchTime { args.White.SearchTime = minSearchTime } - if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < 100 { + if args.Black.Type == core.PlayerComputer && args.Black.SearchTime < minSearchTime { args.Black.SearchTime = minSearchTime } @@ -138,10 +140,9 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { ) } - // Generate game ID gameID := p.svc.GenerateGameID() - // Validate and canonicalize FEN if provided + // Validate FEN safety, then classify via engine initialFEN := board.StartingFEN if args.FEN != "" { if !p.isFENSafe(args.FEN) { @@ -151,16 +152,18 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { } p.mu.Lock() - p.validationEng.NewGame() - p.validationEng.SetPosition(initialFEN, []string{}) - validatedFEN, err := p.validationEng.GetFEN() + err := p.validationEng.NewGame() + var validatedFEN string + initialState := core.StateOngoing + if err == nil { + validatedFEN, initialState, err = p.classifyLocked(initialFEN) + } p.mu.Unlock() - if err != nil { - return p.errorResponse(fmt.Sprintf("invalid FEN: %v", err), core.ErrInvalidRequest) + return p.errorResponse(fmt.Sprintf("engine validation failed: %v", err), core.ErrInternalError) } - // Parse to get starting turn + // Parse canonical FEN to get starting turn b, err := board.ParseFEN(validatedFEN) if err != nil { return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest) @@ -170,39 +173,33 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse { whitePlayer := core.NewPlayer(args.White, core.ColorWhite) blackPlayer := core.NewPlayer(args.Black, core.ColorBlack) - // FIX: Only assign authenticated user to ONE human slot - // If both are human, authenticated user gets white; black remains unclaimed + // Only assign authenticated user to ONE human slot. + // If both are human, authenticated user gets white; black remains unclaimed. if cmd.UserID != "" { if args.White.Type == core.PlayerHuman { whitePlayer.ID = cmd.UserID whitePlayer.ClaimedBy = cmd.UserID } else if args.Black.Type == core.PlayerHuman { - // Only claim black if white is not human (i.e., H vs C scenario) blackPlayer.ID = cmd.UserID blackPlayer.ClaimedBy = cmd.UserID } } - // Create game in service with fully-formed players if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil { return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError) } + if initialState != core.StateOngoing { + p.svc.UpdateGameState(gameID, initialState) + } - // Check if the initial FEN represents a completed game - p.checkGameEnd(gameID, validatedFEN, core.OppositeColor(b.Turn())) - - // Get created game g, err := p.svc.GetGame(gameID) if err != nil { return p.errorResponse("game creation failed", core.ErrInternalError) } - // Build response - response := p.buildGameResponse(gameID, g) - return ProcessorResponse{ Success: true, - Data: response, + Data: p.buildGameResponse(gameID, g), } } @@ -264,7 +261,11 @@ func (p *Processor) handleGetGame(cmd Command) ProcessorResponse { } } -// handleMakeMove processes human moves with authorization +// handleMakeMove processes human moves with authorization, and the "cccc" +// computer-move trigger. Post-move classification runs BEFORE the move is +// applied; move + final state + metadata commit atomically with one +// notification, so a waking long-poller can never observe "ongoing" on a +// terminal position. func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse { args, ok := cmd.Args.(core.MoveRequest) if !ok { @@ -332,11 +333,8 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse { } // Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds) } else if cmd.UserID != "" && slotOwner != cmd.UserID { - // Slot claimed by different user return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized) } - // If slotOwner == cmd.UserID, authorized to proceed - // If slotOwner != "" && cmd.UserID == "", anonymous trying to move claimed slot - block if slotOwner != "" && cmd.UserID == "" { return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized) } @@ -349,60 +347,53 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse { currentFEN := g.CurrentFEN() - // Validate move with engine + // Validate move and classify the resulting position in one engine session p.mu.Lock() - p.validationEng.SetPosition(currentFEN, []string{move}) - newFEN, err := p.validationEng.GetFEN() + err = p.validationEng.SetPosition(currentFEN, []string{move}) + var newFEN string + finalState := core.StateOngoing + if err == nil { + newFEN, finalState, err = p.classifyCurrentLocked() + } p.mu.Unlock() - - if err != nil || newFEN == currentFEN { + if err != nil { + // Game untouched at pre-move position; retry runs on a respawned engine + return p.errorResponse("engine unavailable", core.ErrInternalError) + } + if newFEN == currentFEN { return p.errorResponse("illegal move", core.ErrInvalidMove) } - // Apply move to game state via service - if err = p.svc.ApplyMove(cmd.GameID, move, newFEN); err != nil { + // Atomic commit: move + state + metadata, single notification + if err = p.svc.ApplyMoveWithState(cmd.GameID, move, newFEN, finalState, &game.MoveResult{ + Move: move, + PlayerColor: currentColor, + GameState: finalState, + }); err != nil { return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError) } - // Store move result metadata - p.svc.SetLastMoveResult(cmd.GameID, &game.MoveResult{ - Move: move, - PlayerColor: currentColor, - GameState: core.StateOngoing, - }) - - // Check for checkmate/stalemate - p.checkGameEnd(cmd.GameID, newFEN, currentColor) - - // Get updated game + // buildGameResponse populates LastMove from the committed LastResult g, _ = p.svc.GetGame(cmd.GameID) - response := p.buildGameResponse(cmd.GameID, g) - - // Add human move info - response.LastMove = &core.MoveInfo{ - Move: move, - PlayerColor: currentColor.String(), - } - return ProcessorResponse{ Success: true, - Data: response, + Data: p.buildGameResponse(cmd.GameID, g), } } -// handleUndoMove reverts game state +// handleUndoMove reverts game state. StateStuck is deliberately permitted: +// undo -> StateOngoing is the recovery path for engine failures. Terminal +// states are also permitted so a finished game can be rewound. Any reverted-to +// snapshot had legal moves made from it, so resetting to Ongoing is sound +// without re-classification. func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse { g, err := p.svc.GetGame(cmd.GameID) if err != nil { return p.errorResponse("game not found", core.ErrGameNotFound) } - // Check game state - switch g.State() { - case core.StatePending: + if g.State() == core.StatePending { return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest) - case core.StateStuck: - return p.errorResponse("cannot undo in stuck game", core.ErrInvalidRequest) } args := core.UndoRequest{Count: 1} @@ -423,11 +414,9 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse { p.svc.UpdateGameState(cmd.GameID, core.StateOngoing) g, _ = p.svc.GetGame(cmd.GameID) - response := p.buildGameResponse(cmd.GameID, g) - return ProcessorResponse{ Success: true, - Data: response, + Data: p.buildGameResponse(cmd.GameID, g), } } @@ -474,64 +463,55 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse { } } -// triggerComputerMove initiates async engine calculation +// triggerComputerMove initiates async engine calculation. The callback +// re-classifies via the validation engine: worker output is never trusted for +// end-state determination, and no-move results are verified against the +// position rather than the IsMate info-line byproduct. func (p *Processor) triggerComputerMove(gameID string, g *game.Game) { fen := g.CurrentFEN() color := g.NextTurnColor() player := g.NextPlayer() - // Submit to queue with callback and computer config p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) { - // Check if game still exists currentGame, err := p.svc.GetGame(gameID) - if err != nil { - return // Game was deleted + if err != nil || currentGame.State() != core.StatePending { + return // Deleted, or state resolved elsewhere } - - // Only process if still in pending state - if currentGame.State() != core.StatePending { - return - } - if result.Error != nil { - log.Printf("Engine error for game %s: %v", gameID, result.Error) + log.Printf("engine error for game %s: %v", gameID, result.Error) p.svc.UpdateGameState(gameID, core.StateStuck) return } - - // Use centralized state determination - state := p.determineGameEndState(core.OppositeColor(color), &engine.SearchResult{ - BestMove: result.Move, - Score: result.Score, - Depth: result.Depth, - IsMate: result.IsMate, - MateIn: result.MateIn, - }) - - if state != core.StateOngoing { + if result.Move == "" || result.Move == "(none)" { + // Worker says no legal moves; verify against the validation engine. + p.mu.Lock() + _, state, cerr := p.classifyLocked(fen) + p.mu.Unlock() + if cerr != nil || state == core.StateOngoing { + p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree + return + } p.svc.UpdateGameState(gameID, state) return } - // Apply computer move p.mu.Lock() - p.validationEng.SetPosition(fen, []string{result.Move}) - newFEN, _ := p.validationEng.GetFEN() + aerr := p.validationEng.SetPosition(fen, []string{result.Move}) + var newFEN string + finalState := core.StateOngoing + if aerr == nil { + newFEN, finalState, aerr = p.classifyCurrentLocked() + } p.mu.Unlock() + if aerr != nil || newFEN == fen { + p.svc.UpdateGameState(gameID, core.StateStuck) + return + } - p.svc.ApplyMove(gameID, result.Move, newFEN) - p.svc.SetLastMoveResult(gameID, &game.MoveResult{ - Move: result.Move, - PlayerColor: color, - Score: result.Score, - Depth: result.Depth, + p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{ + Move: result.Move, PlayerColor: color, + Score: result.Score, Depth: result.Depth, GameState: finalState, }) - - // Reset to ongoing first - p.svc.UpdateGameState(gameID, core.StateOngoing) - - // Check if opponent is checkmated - p.checkGameEnd(gameID, newFEN, color) }) } @@ -554,18 +534,58 @@ func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *e return core.StateOngoing } -// checkGameEnd determines if game has ended -func (p *Processor) checkGameEnd(gameID, fen string, lastMoveBy core.Color) { - p.mu.Lock() - p.validationEng.SetPosition(fen, []string{}) - search, _ := p.validationEng.Search(100) - p.mu.Unlock() - - // Use centralized state determination - state := p.determineGameEndState(lastMoveBy, search) - if state != core.StateOngoing { - p.svc.UpdateGameState(gameID, state) +// classifyCurrentLocked classifies whatever position is loaded in the +// validation engine. Caller holds p.mu, immediately after a SetPosition. +func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) { + diag, err := p.validationEng.Diagnose() + if err != nil { + return "", core.StateOngoing, err } + legal, err := p.validationEng.HasLegalMoves() + if err != nil { + return "", core.StateOngoing, err + } + if legal { + return diag.FEN, core.StateOngoing, nil + } + if !diag.InCheck { + return diag.FEN, core.StateStalemate, nil + } + b, err := board.ParseFEN(diag.FEN) + if err != nil { + return "", core.StateOngoing, err + } + if b.Turn() == core.ColorWhite { + return diag.FEN, core.StateBlackWins, nil + } + return diag.FEN, core.StateWhiteWins, nil +} + +// classifyLocked sets a position from fen and classifies it. Caller holds p.mu. +func (p *Processor) classifyLocked(fen string) (string, core.State, error) { + if err := p.validationEng.SetPosition(fen, nil); err != nil { + return "", core.StateOngoing, err + } + return p.classifyCurrentLocked() +} + +// checkGameEnd: retry once (second attempt runs on a respawned process), then +// fail SAFE to StateStuck. Leaving a possibly-terminal position Ongoing is the +// original bug class; Stuck is now recoverable via undo (see handleUndoMove). +func (p *Processor) checkGameEnd(gameID, fen string) { + for attempt := 0; attempt < 2; attempt++ { + p.mu.Lock() + _, state, err := p.classifyLocked(fen) + p.mu.Unlock() + if err == nil { + if state != core.StateOngoing { + p.svc.UpdateGameState(gameID, state) + } + return + } + log.Printf("game %s: end-state check attempt %d failed: %v", gameID, attempt+1, err) + } + p.svc.UpdateGameState(gameID, core.StateStuck) } // buildGameResponse constructs standard game response @@ -610,4 +630,4 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse { func (p *Processor) Close() error { p.queue.Shutdown(5 * time.Second) return p.validationEng.Close() -} \ No newline at end of file +} diff --git a/internal/server/processor/queue.go b/internal/server/processor/queue.go index 60dd199..7e5bfdb 100644 --- a/internal/server/processor/queue.go +++ b/internal/server/processor/queue.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "log" "sync" "time" @@ -69,31 +70,27 @@ func (q *EngineQueue) start() { // worker processes engine tasks func (q *EngineQueue) worker(id int) { defer q.wg.Done() - - // Each worker gets its own engine instance - eng, err := engine.New() - if err != nil { - fmt.Printf("Worker %d failed to initialize engine: %v\n", id, err) - return + var eng *engine.UCI + for { + var err error + if eng, err = engine.New(); err == nil { + break + } + log.Printf("worker %d: engine init failed: %v; retrying", id, err) + select { + case <-q.ctx.Done(): + return + case <-time.After(2 * time.Second): + } } defer eng.Close() - for { select { case task, ok := <-q.tasks: if !ok { - return // Channel closed + return } - - result := q.processTask(eng, task) - - // Send result if receiver still listening - select { - case task.Response <- result: - case <-time.After(15 * time.Millisecond): - // Receiver abandoned, discard result - } - + task.Response <- q.processTask(eng, task) // Response is buffered(1); never blocks case <-q.ctx.Done(): return } @@ -102,45 +99,36 @@ func (q *EngineQueue) worker(id int) { // processTask executes a single engine calculation func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult { - result := EngineResult{ - GameID: task.GameID, + result := EngineResult{GameID: task.GameID} + if err := eng.NewGame(); err != nil { + result.Error = err + return result } - - // Apply computer configuration if provided if task.Player.Type == core.PlayerComputer { - eng.SetSkillLevel(task.Player.Level) + if err := eng.SetSkillLevel(task.Player.Level); err != nil { + result.Error = err + return result + } } - - // Setup position - eng.SetPosition(task.FEN, []string{}) - - // Determine search time - searchTime := 1000 // Default 1 second + if err := eng.SetPosition(task.FEN, nil); err != nil { + result.Error = err + return result + } + searchTime := 1000 if task.Player.Type == core.PlayerComputer && task.Player.SearchTime > 0 { searchTime = task.Player.SearchTime } - - // Search for best move search, err := eng.Search(searchTime) if err != nil { - result.Error = fmt.Errorf("engine search failed: %v", err) + result.Error = fmt.Errorf("engine search failed: %w", err) return result } - - // Check for no legal moves if search.BestMove == "" || search.BestMove == "(none)" { - result.Move = "" - result.IsMate = search.IsMate - result.MateIn = search.MateIn + result.IsMate, result.MateIn = search.IsMate, search.MateIn return result } - - result.Move = search.BestMove - result.Score = search.Score - result.Depth = search.Depth - result.IsMate = search.IsMate - result.MateIn = search.MateIn - + result.Move, result.Score, result.Depth = search.BestMove, search.Score, search.Depth + result.IsMate, result.MateIn = search.IsMate, search.MateIn return result } @@ -159,32 +147,22 @@ func (q *EngineQueue) Submit(task EngineTask) error { // SubmitAsync submits a task without blocking for result func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error { respChan := make(chan EngineResult, 1) - - task := EngineTask{ - GameID: gameID, - FEN: fen, - Color: color, - Player: player, - Response: respChan, - } - - if err := q.Submit(task); err != nil { + if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil { return err } - - // Handle result in background + budget := 1000 + if player.Type == core.PlayerComputer && player.SearchTime > 0 { + budget = player.SearchTime + } + wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom go func() { select { case result := <-respChan: callback(result) - case <-time.After(5 * time.Second): - callback(EngineResult{ - GameID: gameID, - Error: fmt.Errorf("engine timeout"), - }) + case <-time.After(wait): + callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")}) } }() - return nil } @@ -206,4 +184,3 @@ func (q *EngineQueue) Shutdown(timeout time.Duration) error { return fmt.Errorf("shutdown timeout exceeded") } } - diff --git a/internal/server/service/game.go b/internal/server/service/game.go index badc1b9..dfa91a6 100644 --- a/internal/server/service/game.go +++ b/internal/server/service/game.go @@ -113,7 +113,7 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error { g.AddSnapshot(newFEN, moveUCI, nextTurn) // Notify waiting clients about the state change - s.waiter.NotifyGame(gameID, len(g.Moves())) + s.waiter.NotifyGame(gameID, len(g.Moves()), g.State()) // Persist if storage enabled if s.store != nil { @@ -132,6 +132,36 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error { return nil } +// ApplyMoveWithState atomically records a move, its resulting state, and move +// metadata, then notifies waiters exactly once with the settled state. +func (s *Service) ApplyMoveWithState(gameID, moveUCI, newFEN string, state core.State, result *game.MoveResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + g, ok := s.games[gameID] + if !ok { + return fmt.Errorf("game not found: %s", gameID) + } + + currentTurn := g.NextTurnColor() + g.AddSnapshot(newFEN, moveUCI, core.OppositeColor(currentTurn)) + g.SetState(state) + if result != nil { + g.SetLastResult(result) + } + + s.waiter.NotifyGame(gameID, len(g.Moves()), state) + + if s.store != nil { + s.store.RecordMove(storage.MoveRecord{ + GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: moveUCI, + FENAfterMove: newFEN, PlayerColor: currentTurn.String(), + MoveTimeUTC: time.Now().UTC(), + }) + } + return nil +} + // UpdateGameState sets the game's end state (checkmate, stalemate, etc) func (s *Service) UpdateGameState(gameID string, state core.State) error { s.mu.Lock() @@ -143,11 +173,8 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error { } g.SetState(state) - - // Notify if game ended - if state != core.StateOngoing && state != core.StatePending { - s.waiter.NotifyGame(gameID, len(g.Moves())) - } + // Notify unconditionally; the registry decides. + s.waiter.NotifyGame(gameID, len(g.Moves()), state) return nil } @@ -183,7 +210,7 @@ func (s *Service) UndoMoves(gameID string, count int) error { } // Notify waiting clients about the undo - s.waiter.NotifyGame(gameID, len(g.Moves())) + s.waiter.NotifyGame(gameID, len(g.Moves()), g.State()) // Delete undone moves from storage if enabled if s.store != nil { @@ -214,4 +241,5 @@ func (s *Service) DeleteGame(gameID string) error { delete(s.games, gameID) return nil -} \ No newline at end of file +} + diff --git a/internal/server/service/waiter.go b/internal/server/service/waiter.go index 6eac4f0..562ca68 100644 --- a/internal/server/service/waiter.go +++ b/internal/server/service/waiter.go @@ -1,6 +1,7 @@ package service import ( + "chess/internal/server/core" "context" "fmt" "sync" @@ -84,24 +85,19 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co } // NotifyGame notifies all clients waiting on a game about state change -func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int) { +func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state core.State) { w.mu.RLock() waitList := w.waiters[gameID] w.mu.RUnlock() - if len(waitList) == 0 { return } - - // Non-blocking notification to all waiters + settled := state != core.StateOngoing && state != core.StatePending for _, req := range waitList { - // Only notify if move count changed - if req.MoveCount != currentMoveCount { + if settled || req.MoveCount != currentMoveCount { select { case req.Notify <- struct{}{}: - // Notification sent default: - // Channel full or closed, skip slow client } } } @@ -175,4 +171,3 @@ func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) { // Stop timer if still running req.Timer.Stop() } - diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index c451aaa..4eb6196 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -120,9 +120,11 @@ function updateAuthIndicator(authenticated) { if (authenticated) { light.setAttribute('data-status', 'authenticated'); indicator.setAttribute('data-status', gameState.username); + indicator.setAttribute('data-tooltip', 'Account'); } else { light.setAttribute('data-status', 'anonymous'); - indicator.setAttribute('data-status', 'anonymous'); + indicator.setAttribute('data-status', 'click to login'); + indicator.setAttribute('data-tooltip', 'Login'); } } @@ -438,6 +440,9 @@ function updateTurnIndicator(state, turn) { status = 'unknown'; tooltipText = 'Game Over'; } + } else if (state === 'stuck') { + status = 'degraded'; + tooltipText = 'Engine Error'; } else if (turn === 'w') { status = 'white'; tooltipText = 'White'; @@ -637,7 +642,10 @@ async function startNewGame() { initializeBoard(); updateGameDisplay(game); document.getElementById('undo-btn').disabled = true; - if (!gameState.isPlayerWhite) triggerComputerMove(); + const computerTurn = gameState.isPlayerWhite ? 'b' : 'w'; + if (isPlayable(game.state) && game.turn === computerTurn) { + triggerComputerMove(); + } setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success'); setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS); @@ -711,7 +719,7 @@ function handleSquareClick(e) { if (gameState.isLocked) return; // Block moves after game over - if (isGameOver(gameState.state)) return; + if (!isPlayable(gameState.state)) return; const squareEl = e.currentTarget; const { square, pieceColor } = squareEl.dataset; @@ -776,7 +784,7 @@ async function handleHumanMove(from, to) { flashSquare(fromEl, true); flashSquare(toEl, true); updateGameDisplay(game); - if (!isGameOver(game.state)) { + if (isPlayable(game.state)) { triggerComputerMove(); } } catch (error) { @@ -906,6 +914,9 @@ async function undoMoves() { const game = await response.json(); gameState.state = game.state; updateGameDisplay(game); + if (game.state === 'stuck') { + flashErrorMessage('Engine error — Undo to recover or start a new game'); + } } catch (error) { if (error.message === 'Failed to fetch') { handleApiError('undo', error); @@ -1021,6 +1032,9 @@ function markMatedKing(game) { function isGameOver(state) { return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state); } +function isPlayable(state) { + return !isGameOver(state) && state !== 'stuck'; +} function handleApiError(action, error, response = null) { let serverStatus = 'degraded'; diff --git a/internal/server/webserver/chess-client-web/style.css b/internal/server/webserver/chess-client-web/style.css index ead73e0..b360fcc 100644 --- a/internal/server/webserver/chess-client-web/style.css +++ b/internal/server/webserver/chess-client-web/style.css @@ -647,18 +647,26 @@ input[type="range"]::-webkit-slider-thumb { } /* Auth Indicator */ +.auth-indicator { + cursor: pointer; + border: 1px solid transparent; + border-radius: 6px; + transition: border-color .2s, background .2s; +} + +.auth-indicator:hover { + border-color: var(--host-royal); + background: rgba(95, 87, 245, 0.15); +} + .auth-indicator .light[data-status="anonymous"] { - color: var(--tokyo-border); + color: var(--tokyo-yellow); } .auth-indicator .light[data-status="authenticated"] { color: var(--tokyo-green); } -.auth-indicator { - cursor: pointer; -} - /* --- Modal status message (Issue 2) --- */ .modal-message { display: none; @@ -871,41 +879,20 @@ input[type="range"]:disabled { } @media (max-width: 530px) { - body { - overflow-y: auto; - overflow-x: auto; - min-width: clamp(440px, 100vw, 530px); + body { min-width: 0; overflow-x: hidden; } + .outer-container { width: 100%; min-width: 0; } + .container { width: calc(100% - 16px); min-width: 0; } + .board-container { + width: min(92vw, 440px); + height: min(92vw, 440px); + padding: 12px; } - - .outer-container { - width: clamp(440px, 100vw, 530px); - min-width: clamp(440px, 100vw, 530px); - padding: 8px; - min-height: 100vh; - height: auto; - display: flex; - justify-content: center; - align-items: flex-start; - overflow: visible; + .board-wrapper { + width: calc(min(92vw, 440px) - 24px); + height: calc(min(92vw, 440px) - 24px); } - - .container { - width: calc(100% - 16px); - min-width: clamp(424px, calc(100vw - 16px), 514px); - border-radius: 12px; - min-height: calc(100vh - 16px); - height: auto; - padding: 1rem; - margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - overflow: visible; - } - - .board-container, .info-panel { - width: clamp(360px, 83vw, 440px); - min-width: clamp(360px, 83vw, 440px); + width: min(92vw, 440px); + min-width: 0; } } diff --git a/test/test-db.sh b/test/test-db.sh index e9b605c..d2a4ac3 100755 --- a/test/test-db.sh +++ b/test/test-db.sh @@ -225,28 +225,31 @@ test_case "2.3: Login with Username" RESPONSE=$(api_request POST "$API_URL/auth/login" \ -H "Content-Type: application/json" \ -d "{\"identifier\": \"$TEST_USER1\", \"password\": \"$TEST_PASS1\"}") -TOKEN_ALICE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null) +TOKEN_ALICE_S1=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null) # kept for 2.4b USER_ID_ALICE=$(echo "$RESPONSE" | jq -r '.userId' 2>/dev/null) -if [ -n "$TOKEN_ALICE" ] && [ "$TOKEN_ALICE" != "null" ]; then - echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}" - ((PASS++)) +if [ -n "$TOKEN_ALICE_S1" ] && [ "$TOKEN_ALICE_S1" != "null" ]; then + echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}"; ((PASS++)) else - echo -e "${RED} ✗ Login failed${NC}" - ((FAIL++)) + echo -e "${RED} ✗ Login failed${NC}"; ((FAIL++)) fi -test_case "2.4: Login with Email" +test_case "2.4: Login with Email (re-login: supersedes previous session)" RESPONSE=$(api_request POST "$API_URL/auth/login" \ -H "Content-Type: application/json" \ -d "{\"identifier\": \"$TEST_EMAIL1\", \"password\": \"$TEST_PASS1\"}") -if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -q "^ey"; then - echo -e "${GREEN} ✓ Email login successful${NC}" - ((PASS++)) +TOKEN_ALICE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null) # ONLY this token is valid from here on +if echo "$TOKEN_ALICE" | grep -q "^ey"; then + echo -e "${GREEN} ✓ Email login successful${NC}"; ((PASS++)) else - echo -e "${RED} ✗ Email login failed${NC}" - ((FAIL++)) + echo -e "${RED} ✗ Email login failed${NC}"; ((FAIL++)) fi +test_case "2.4b: Single-Session Enforcement (prior token invalidated by re-login)" +STATUS=$(api_request GET "$API_URL/auth/me" \ + -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN_ALICE_S1") +assert_status 401 "$STATUS" "Superseded session token rejected" + test_case "2.5: Invalid Credentials" STATUS=$(api_request POST "$API_URL/auth/login" \ -o /dev/null -w "%{http_code}" \ @@ -298,20 +301,19 @@ else ((FAIL++)) fi -test_case "3.3: Both Players Same Authenticated User" +test_case "3.3: HvH Creation Claims Only One Slot for Creator" RESPONSE=$(api_request POST "$API_URL/games" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN_ALICE" \ -d '{"white": {"type": 1}, "black": {"type": 1}}') WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null) BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null) +BLACK_CLAIMED=$(echo "$RESPONSE" | jq -r '.players.black.claimedBy // empty' 2>/dev/null) -if [ "$WHITE_ID" = "$USER_ID_ALICE" ] && [ "$BLACK_ID" = "$USER_ID_ALICE" ]; then - echo -e "${GREEN} ✓ Same user can play both sides${NC}" - ((PASS++)) +if [ "$WHITE_ID" = "$USER_ID_ALICE" ] && [ "$BLACK_ID" != "$USER_ID_ALICE" ] && [ -z "$BLACK_CLAIMED" ]; then + echo -e "${GREEN} ✓ Creator claims white only; black remains claimable${NC}"; ((PASS++)) else - echo -e "${RED} ✗ Both sides should be same user${NC}" - ((FAIL++)) + echo -e "${RED} ✗ Slot assignment wrong: white=$WHITE_ID black=$BLACK_ID claimedBy=$BLACK_CLAIMED${NC}"; ((FAIL++)) fi # ============================================================================== @@ -459,4 +461,4 @@ if [ $FAIL -eq 0 ]; then else echo -e "\n${RED}⚠️ Some tests failed. Review the output above.${NC}" exit 1 -fi \ No newline at end of file +fi diff --git a/test/test-endstate.sh b/test/test-endstate.sh new file mode 100755 index 0000000..12fb3d7 --- /dev/null +++ b/test/test-endstate.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +BASE_URL="${BASE_URL:-http://localhost:8080}" +API_URL="${BASE_URL}/api/v1" +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +PASS=0; FAIL=0 + +t() { echo -e "\n${YELLOW}▶ TEST: $1${NC}"; } +ok() { echo -e "${GREEN} ✓ $1${NC}"; ((PASS++)); } +ko() { echo -e "${RED} ✗ $1${NC}"; ((FAIL++)); } +req(){ local m=$1 u=$2; shift 2; curl -s "$@" -X "$m" "$u"; } + +assert_field() { # json field expected name + local a + a=$(echo "$1" | jq -r "$2" 2>/dev/null) + if [ "$a" = "$3" ]; then + ok "$4: $2 = '$a'" + else + ko "$4: expected $2 = '$3', got '$a'" + fi +} + +MATE_FEN="rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3" # fool's mate: white to move, mated +STALE_FEN="7k/5Q2/6K1/8/8/8/8/8 b - - 0 1" # black to move, stalemate +PREMATE_W="7k/5Q2/5K2/8/8/8/8/8 w - - 0 1" # f7g7 mates +PREMATE_B="rnbqkbnr/pppp1ppp/8/4p3/6P1/5P2/PPPPP2P/RNBQKBNR b KQkq - 0 2" # black computer: d8h4# + +t "E1: Terminal FEN at creation (HvH)" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d "{\"white\":{\"type\":1},\"black\":{\"type\":1},\"fen\":\"$MATE_FEN\"}") +assert_field "$R" '.state' "black wins" "Creation response carries mate" +G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null + +t "E2: Terminal FEN at creation (human white vs computer) — original repro case 1" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d "{\"white\":{\"type\":1},\"black\":{\"type\":2,\"searchTime\":100},\"fen\":\"$MATE_FEN\"}") +assert_field "$R" '.state' "black wins" "Mate detected, no client trigger needed" +G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null + +t "E3: Terminal FEN at creation (computer white vs human) — original repro case 2" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d "{\"white\":{\"type\":2,\"searchTime\":100},\"black\":{\"type\":1},\"fen\":\"$MATE_FEN\"}") +assert_field "$R" '.state' "black wins" "Mate detected at creation, not via cccc path" +G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null + +t "E4: Stalemate FEN at creation" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d "{\"white\":{\"type\":1},\"black\":{\"type\":1},\"fen\":\"$STALE_FEN\"}") +assert_field "$R" '.state' "stalemate" "Stalemate not misclassified as mate" +G=$(echo "$R" | jq -r '.gameId'); [ "$G" != "null" ] && req DELETE "$API_URL/games/$G" >/dev/null + +t "E5: Mate delivered by human move" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d "{\"white\":{\"type\":1},\"black\":{\"type\":1},\"fen\":\"$PREMATE_W\"}") +G=$(echo "$R" | jq -r '.gameId') +R=$(req POST "$API_URL/games/$G/moves" -H "Content-Type: application/json" -d '{"move":"f7g7"}') +assert_field "$R" '.state' "white wins" "Move response carries terminal state" +assert_field "$R" '.lastMove.move' "f7g7" "LastMove present in terminal response" +req DELETE "$API_URL/games/$G" >/dev/null + +t "E6: Mate delivered by computer + long-poll wakes with settled state (<5s)" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d "{\"white\":{\"type\":1},\"black\":{\"type\":2,\"level\":20,\"searchTime\":1000},\"fen\":\"$PREMATE_B\"}") +G=$(echo "$R" | jq -r '.gameId') +N=$(echo "$R" | jq -r '.moves | length') +req POST "$API_URL/games/$G/moves" -H "Content-Type: application/json" -d '{"move":"cccc"}' >/dev/null +S=$(date +%s) +R=$(req GET "$API_URL/games/$G?wait=true&moveCount=$N") +E=$(( $(date +%s) - S )) +assert_field "$R" '.state' "black wins" "Computer mate classified" +[ "$E" -lt 5 ] && ok "Poll woke in ${E}s (state-aware notify, no 30s stall)" \ + || ko "Poll took ${E}s — state-only/atomic wake regressed" +req DELETE "$API_URL/games/$G" >/dev/null + +t "E7: Delete during long-poll wakes waiter promptly" +R=$(req POST "$API_URL/games" -H "Content-Type: application/json" \ + -d '{"white":{"type":1},"black":{"type":1}}') +G=$(echo "$R" | jq -r '.gameId') +S=$(date +%s) +req GET "$API_URL/games/$G?wait=true&moveCount=0" > /tmp/es_poll.json & +P=$! +sleep 1 +req DELETE "$API_URL/games/$G" >/dev/null +wait $P +E=$(( $(date +%s) - S )) +CODE=$(jq -r '.code' /tmp/es_poll.json 2>/dev/null) +if [ "$E" -lt 5 ]; then + ok "Poll woke in ${E}s (state-aware notify, no 30s stall)" +else + ko "Poll took ${E}s — state-only/atomic wake regressed" +fi + +echo -e "\n${CYAN}Passed: $PASS Failed: $FAIL${NC}" +[ $FAIL -eq 0 ] + diff --git a/test/test-longpoll.sh b/test/test-longpoll.sh index 2abd0dd..00e7247 100755 --- a/test/test-longpoll.sh +++ b/test/test-longpoll.sh @@ -125,7 +125,7 @@ test_multiple_waiters() { # Test 3: Timeout behavior test_timeout() { - log_test "Timeout behavior (this takes 25 seconds)" + log_test "Timeout behavior (this takes 30 seconds)" # Create a game GAME_ID=$(create_game 1 1) @@ -139,10 +139,10 @@ test_timeout() { elapsed=$((end_time - start_time)) # Check timeout was ~25 seconds - if [ "$elapsed" -ge 24 ] && [ "$elapsed" -le 26 ]; then - log_info "✓ Request timed out after ~25 seconds" + if [ "$elapsed" -ge 29 ] && [ "$elapsed" -le 31 ]; then + log_info "✓ Request timed out after ~30 seconds" else - log_error "✗ Timeout was $elapsed seconds (expected ~25)" + log_error "✗ Timeout was $elapsed seconds (expected ~30)" exit 1 fi @@ -209,4 +209,4 @@ else fi echo "" -log_info "All tests passed! ✓" \ No newline at end of file +log_info "All tests passed! ✓"