v0.10.0 fix to engine game state mgmt, client and web ui updates to match
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ chess > delete <gameId> # 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 <uci-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,53 +286,80 @@ 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" {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Check if game ended after computer move
|
||||
switch resp2.State {
|
||||
case "checkmate":
|
||||
winner := "Black"
|
||||
if resp2.Turn == "b" {
|
||||
winner = "White"
|
||||
printOutcome(final)
|
||||
return nil
|
||||
}
|
||||
display.Println(display.Green, "\nCHECKMATE! %s wins!", winner)
|
||||
|
||||
// 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'.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for computer move")
|
||||
}
|
||||
|
||||
s.LastMoveCount = len(resp.Moves)
|
||||
s.CurrentGameState = resp
|
||||
display.Println(display.Green, "Move triggered")
|
||||
return nil
|
||||
}
|
||||
|
||||
func undoHandler(s *session.Session, args []string) error {
|
||||
@@ -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 {
|
||||
|
||||
+215
-180
@@ -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 {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Scanner
|
||||
mu sync.Mutex
|
||||
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)
|
||||
type Diagnosis struct {
|
||||
FEN string
|
||||
InCheck bool
|
||||
}
|
||||
|
||||
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 nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start engine: %w", err)
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start engine: %v", 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
|
||||
}
|
||||
|
||||
uci := &UCI{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: bufio.NewScanner(stdout),
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if err := uci.initialize(); err != nil {
|
||||
uci.Close()
|
||||
return nil, err
|
||||
func (u *UCI) restartLocked() {
|
||||
u.killLocked()
|
||||
_ = u.spawnLocked() // on failure alive stays false; next tx errors immediately
|
||||
}
|
||||
|
||||
return uci, nil
|
||||
func (u *UCI) drainLocked() {
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-u.lines:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetSkillLevel sets the Stockfish skill level (0-20)
|
||||
func (u *UCI) SetSkillLevel(level int) {
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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:
|
||||
_, err := u.tx(barrierTimeout,
|
||||
[]string{fmt.Sprintf("setoption name Skill Level value %d", level), "isready"},
|
||||
"readyok", nil)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "bestmove ") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
result.BestMove = parts[1]
|
||||
}
|
||||
done <- nil
|
||||
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
|
||||
}
|
||||
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")
|
||||
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()
|
||||
}()
|
||||
|
||||
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):
|
||||
// Force kill if doesn't exit gracefully
|
||||
return u.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
u.killLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
// 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
|
||||
}
|
||||
|
||||
// Use centralized state determination
|
||||
state := p.determineGameEndState(lastMoveBy, search)
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -215,3 +242,4 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
delete(s.games, gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-18
@@ -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
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
Executable
+94
@@ -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 ]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user