v0.10.0 fix to engine game state mgmt, client and web ui updates to match

This commit is contained in:
2026-07-23 14:03:09 -04:00
parent a23ed1fb21
commit 21dea47694
17 changed files with 741 additions and 549 deletions
+2 -2
View File
@@ -156,7 +156,7 @@ Returns current game state.
**Long-polling support:** **Long-polling support:**
Add query parameters for real-time updates: 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 - `moveCount=N` - Last known move count
Returns immediately if game state changed, otherwise waits for updates: 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. Response includes all game data. Compare `moves` array length to detect changes.
**Timeout behavior:** **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 - Client disconnection cancels wait immediately
- Game deletion notifies all waiting clients - Game deletion notifies all waiting clients
+2 -2
View File
@@ -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. 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`) #### 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`) #### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
- **Password Hashing**: Argon2id for secure password storage - **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` 1. Client sends `GET /games/{id}?wait=true&moveCount=N`
2. Handler creates context from HTTP connection 2. Handler creates context from HTTP connection
3. Registers wait with WaitRegistry using game ID and move count 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 5. On any game update, NotifyGame sends to all waiters
6. Returns immediately with current state 6. Returns immediately with current state
7. Client disconnection cancels wait via context 7. Client disconnection cancels wait via context
+1 -1
View File
@@ -135,7 +135,7 @@ chess > delete <gameId> # Delete specific game
``` ```
#### `poll` / `p` #### `poll` / `p`
Long-poll for game updates (waits up to 25 seconds). Long-poll for game updates (waits up to 30 seconds).
``` ```
chess > poll chess > poll
``` ```
+10 -10
View File
@@ -6,28 +6,28 @@ require (
github.com/go-playground/validator/v10 v10.30.3 github.com/go-playground/validator/v10 v10.30.3
github.com/gofiber/fiber/v2 v2.52.14 github.com/gofiber/fiber/v2 v2.52.14
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 github.com/lixenwraith/auth v0.0.0-20260718210909-e4eb41658be8
github.com/mattn/go-sqlite3 v1.14.47 github.com/mattn/go-sqlite3 v1.14.48
golang.org/x/term v0.44.0 golang.org/x/term v0.45.0
) )
require ( require (
github.com/andybalholm/brotli v1.2.2 // indirect github.com/andybalholm/brotli v1.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/klauspost/compress v1.19.0 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // 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/mattn/go-runewidth v0.0.24 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect github.com/valyala/fasthttp v1.72.0 // indirect
golang.org/x/crypto v0.53.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.39.0 // indirect golang.org/x/text v0.40.0 // indirect
) )
+21
View File
@@ -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/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 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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= 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.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= 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.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 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.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 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-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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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= 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.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 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= 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 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= 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.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 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= 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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= 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.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= 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.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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= 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.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 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= 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.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+82 -63
View File
@@ -6,7 +6,6 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"chess/internal/client/api" "chess/internal/client/api"
"chess/internal/client/display" "chess/internal/client/display"
@@ -234,6 +233,9 @@ func joinGameHandler(s *session.Session, args []string) error {
return nil 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 { func moveHandler(s *session.Session, args []string) error {
if len(args) < 1 { if len(args) < 1 {
return fmt.Errorf("usage: move <uci-move>") return fmt.Errorf("usage: move <uci-move>")
@@ -256,29 +258,13 @@ func moveHandler(s *session.Session, args []string) error {
s.CurrentGameState = resp s.CurrentGameState = resp
display.Println(display.Green, "Move accepted") display.Println(display.Green, "Move accepted")
// Check if game ended printOutcome(resp)
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
}
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.") 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 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 { func computerMoveHandler(s *session.Session, args []string) error {
gameID := s.CurrentGame gameID := s.CurrentGame
if gameID == "" { if gameID == "" {
@@ -294,55 +286,82 @@ func computerMoveHandler(s *session.Session, args []string) error {
c := s.Client 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") resp, err := c.MakeMove(gameID, "cccc")
if err != nil { if err != nil {
return err return err
} }
if resp.State == "pending" { if resp.State != "pending" {
display.Println(display.Magenta, "Computer is thinking...") // Server resolved synchronously (shouldn't normally happen)
s.LastMoveCount = len(resp.Moves)
// Poll for completion s.CurrentGameState = resp
for i := 0; i < 50; i++ { display.Println(display.Green, "Move triggered")
time.Sleep(200 * time.Millisecond) printOutcome(resp)
resp2, err := c.GetGame(gameID) return nil
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")
} }
s.LastMoveCount = len(resp.Moves) display.Println(display.Magenta, "Computer is thinking...")
s.CurrentGameState = resp
display.Println(display.Green, "Move triggered") // 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 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 { func undoHandler(s *session.Session, args []string) error {
gameID := s.GetCurrentGame() gameID := s.GetCurrentGame()
if gameID == "" { if gameID == "" {
@@ -490,7 +509,7 @@ func pollHandler(s *session.Session, args []string) error {
moveCount := s.GetLastMoveCount() moveCount := s.GetLastMoveCount()
display.Println(display.Cyan, "Long-polling for updates (move count: %d)...", moveCount) 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) resp, err := c.GetGameWithPoll(gameID, moveCount)
if err != nil { if err != nil {
+239 -204
View File
@@ -2,7 +2,7 @@ package engine
import ( import (
"bufio" "bufio"
"context" "errors"
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
@@ -11,13 +11,27 @@ import (
"time" "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 { type UCI struct {
cmd *exec.Cmd mu sync.Mutex
stdin io.WriteCloser cmd *exec.Cmd
stdout *bufio.Scanner stdin io.WriteCloser
mu sync.Mutex lines chan string
alive bool
} }
type SearchResult struct { type SearchResult struct {
@@ -28,225 +42,246 @@ type SearchResult struct {
MateIn int MateIn int
} }
func New() (*UCI, error) { type Diagnosis struct {
cmd := exec.Command(enginePath) FEN string
InCheck bool
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
} }
// SetSkillLevel sets the Stockfish skill level (0-20) func New() (*UCI, error) {
func (u *UCI) SetSkillLevel(level int) { 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 { if level < 0 {
level = 0 level = 0
} else if level > 20 { } else if level > 20 {
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) SetPosition(fen string, moves []string) error {
func (u *UCI) GetFEN() (string, error) { cmd := "position fen " + fen
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)
if len(moves) > 0 { if len(moves) > 0 {
cmd += " moves " + strings.Join(moves, " ") 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) { func (u *UCI) Search(timeMs int) (*SearchResult, error) {
u.sendCommand(fmt.Sprintf("go movetime %d", timeMs)) r := &SearchResult{}
timeout := time.Duration(timeMs)*time.Millisecond + 5*time.Second
result := &SearchResult{} last, err := u.tx(timeout, []string{fmt.Sprintf("go movetime %d", timeMs)}, "bestmove ", func(ln string) {
if !strings.HasPrefix(ln, "info ") {
// Add timeout protection (2x the search time + buffer) return
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond) }
defer cancel() f := strings.Fields(ln)
for i := 0; i < len(f)-1; i++ {
done := make(chan error) switch f[i] {
go func() { case "depth":
for u.stdout.Scan() { fmt.Sscanf(f[i+1], "%d", &r.Depth)
line := u.stdout.Text() case "cp":
fmt.Sscanf(f[i+1], "%d", &r.Score)
if strings.HasPrefix(line, "info ") { r.IsMate = false
fields := strings.Fields(line) case "mate":
for i := 0; i < len(fields)-1; i++ { fmt.Sscanf(f[i+1], "%d", &r.MateIn)
switch fields[i] { r.IsMate = true
case "depth": if r.MateIn > 0 {
fmt.Sscanf(fields[i+1], "%d", &result.Depth) r.Score = 100000 - r.MateIn
case "cp": } else {
fmt.Sscanf(fields[i+1], "%d", &result.Score) r.Score = -100000 - r.MateIn
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
return
}
} }
done <- fmt.Errorf("engine closed unexpectedly") })
}() if err != nil {
return nil, err
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 { func (u *UCI) Close() error {
u.sendCommand("quit") u.mu.Lock()
time.Sleep(100 * time.Millisecond) defer u.mu.Unlock()
if u.alive {
// Try graceful shutdown first fmt.Fprintln(u.stdin, "quit")
done := make(chan error, 1) done := make(chan struct{})
go func() { go func() { u.cmd.Wait(); close(done) }()
done <- u.cmd.Wait() u.alive = false
}() select {
case <-done:
select { u.stdin.Close()
case <-done: return nil
return nil case <-time.After(1 * time.Second):
case <-time.After(1 * time.Second): }
// Force kill if doesn't exit gracefully
return u.cmd.Process.Kill()
} }
u.killLocked()
return nil
} }
+3 -3
View File
@@ -333,9 +333,10 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
} }
currentMoveCount := len(g.Moves()) currentMoveCount := len(g.Moves())
st := g.State()
settled := st != core.StateOngoing && st != core.StatePending
// If move count already different, return immediately // If move count already different, return immediately
if moveCount != currentMoveCount { if moveCount != currentMoveCount || settled {
cmd := processor.NewGetGameCommand(gameID) cmd := processor.NewGetGameCommand(gameID)
resp := h.proc.Execute(cmd) resp := h.proc.Execute(cmd)
if !resp.Success { if !resp.Success {
@@ -518,4 +519,3 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data) return c.JSON(resp.Data)
} }
+132 -112
View File
@@ -114,7 +114,9 @@ func (p *Processor) isMoveSafe(move string) bool {
return true 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 { func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.CreateGameRequest) args, ok := cmd.Args.(core.CreateGameRequest)
if !ok { if !ok {
@@ -122,10 +124,10 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
} }
// Enforce minimum searchTime for computer players // 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 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 args.Black.SearchTime = minSearchTime
} }
@@ -138,10 +140,9 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
) )
} }
// Generate game ID
gameID := p.svc.GenerateGameID() gameID := p.svc.GenerateGameID()
// Validate and canonicalize FEN if provided // Validate FEN safety, then classify via engine
initialFEN := board.StartingFEN initialFEN := board.StartingFEN
if args.FEN != "" { if args.FEN != "" {
if !p.isFENSafe(args.FEN) { if !p.isFENSafe(args.FEN) {
@@ -151,16 +152,18 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
} }
p.mu.Lock() p.mu.Lock()
p.validationEng.NewGame() err := p.validationEng.NewGame()
p.validationEng.SetPosition(initialFEN, []string{}) var validatedFEN string
validatedFEN, err := p.validationEng.GetFEN() initialState := core.StateOngoing
if err == nil {
validatedFEN, initialState, err = p.classifyLocked(initialFEN)
}
p.mu.Unlock() p.mu.Unlock()
if err != nil { 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) b, err := board.ParseFEN(validatedFEN)
if err != nil { if err != nil {
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest) 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) whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack) blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
// FIX: Only assign authenticated user to ONE human slot // Only assign authenticated user to ONE human slot.
// If both are human, authenticated user gets white; black remains unclaimed // If both are human, authenticated user gets white; black remains unclaimed.
if cmd.UserID != "" { if cmd.UserID != "" {
if args.White.Type == core.PlayerHuman { if args.White.Type == core.PlayerHuman {
whitePlayer.ID = cmd.UserID whitePlayer.ID = cmd.UserID
whitePlayer.ClaimedBy = cmd.UserID whitePlayer.ClaimedBy = cmd.UserID
} else if args.Black.Type == core.PlayerHuman { } 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.ID = cmd.UserID
blackPlayer.ClaimedBy = 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 { 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) 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) g, err := p.svc.GetGame(gameID)
if err != nil { if err != nil {
return p.errorResponse("game creation failed", core.ErrInternalError) return p.errorResponse("game creation failed", core.ErrInternalError)
} }
// Build response
response := p.buildGameResponse(gameID, g)
return ProcessorResponse{ return ProcessorResponse{
Success: true, 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 { func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
args, ok := cmd.Args.(core.MoveRequest) args, ok := cmd.Args.(core.MoveRequest)
if !ok { 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) // Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
} else if cmd.UserID != "" && slotOwner != cmd.UserID { } 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) 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 == "" { if slotOwner != "" && cmd.UserID == "" {
return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized) return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized)
} }
@@ -349,60 +347,53 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
currentFEN := g.CurrentFEN() currentFEN := g.CurrentFEN()
// Validate move with engine // Validate move and classify the resulting position in one engine session
p.mu.Lock() p.mu.Lock()
p.validationEng.SetPosition(currentFEN, []string{move}) err = p.validationEng.SetPosition(currentFEN, []string{move})
newFEN, err := p.validationEng.GetFEN() var newFEN string
finalState := core.StateOngoing
if err == nil {
newFEN, finalState, err = p.classifyCurrentLocked()
}
p.mu.Unlock() p.mu.Unlock()
if err != nil {
if err != nil || newFEN == currentFEN { // 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) return p.errorResponse("illegal move", core.ErrInvalidMove)
} }
// Apply move to game state via service // Atomic commit: move + state + metadata, single notification
if err = p.svc.ApplyMove(cmd.GameID, move, newFEN); err != nil { 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) return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
} }
// Store move result metadata // buildGameResponse populates LastMove from the committed LastResult
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
g, _ = p.svc.GetGame(cmd.GameID) 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{ return ProcessorResponse{
Success: true, 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 { func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
g, err := p.svc.GetGame(cmd.GameID) g, err := p.svc.GetGame(cmd.GameID)
if err != nil { if err != nil {
return p.errorResponse("game not found", core.ErrGameNotFound) return p.errorResponse("game not found", core.ErrGameNotFound)
} }
// Check game state if g.State() == core.StatePending {
switch g.State() {
case core.StatePending:
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest) 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} args := core.UndoRequest{Count: 1}
@@ -423,11 +414,9 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing) p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
g, _ = p.svc.GetGame(cmd.GameID) g, _ = p.svc.GetGame(cmd.GameID)
response := p.buildGameResponse(cmd.GameID, g)
return ProcessorResponse{ return ProcessorResponse{
Success: true, 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) { func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
fen := g.CurrentFEN() fen := g.CurrentFEN()
color := g.NextTurnColor() color := g.NextTurnColor()
player := g.NextPlayer() player := g.NextPlayer()
// Submit to queue with callback and computer config
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) { p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
// Check if game still exists
currentGame, err := p.svc.GetGame(gameID) currentGame, err := p.svc.GetGame(gameID)
if err != nil { if err != nil || currentGame.State() != core.StatePending {
return // Game was deleted return // Deleted, or state resolved elsewhere
} }
// Only process if still in pending state
if currentGame.State() != core.StatePending {
return
}
if result.Error != nil { 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) p.svc.UpdateGameState(gameID, core.StateStuck)
return return
} }
if result.Move == "" || result.Move == "(none)" {
// Use centralized state determination // Worker says no legal moves; verify against the validation engine.
state := p.determineGameEndState(core.OppositeColor(color), &engine.SearchResult{ p.mu.Lock()
BestMove: result.Move, _, state, cerr := p.classifyLocked(fen)
Score: result.Score, p.mu.Unlock()
Depth: result.Depth, if cerr != nil || state == core.StateOngoing {
IsMate: result.IsMate, p.svc.UpdateGameState(gameID, core.StateStuck) // engines disagree
MateIn: result.MateIn, return
}) }
if state != core.StateOngoing {
p.svc.UpdateGameState(gameID, state) p.svc.UpdateGameState(gameID, state)
return return
} }
// Apply computer move
p.mu.Lock() p.mu.Lock()
p.validationEng.SetPosition(fen, []string{result.Move}) aerr := p.validationEng.SetPosition(fen, []string{result.Move})
newFEN, _ := p.validationEng.GetFEN() var newFEN string
finalState := core.StateOngoing
if aerr == nil {
newFEN, finalState, aerr = p.classifyCurrentLocked()
}
p.mu.Unlock() p.mu.Unlock()
if aerr != nil || newFEN == fen {
p.svc.UpdateGameState(gameID, core.StateStuck)
return
}
p.svc.ApplyMove(gameID, result.Move, newFEN) p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
p.svc.SetLastMoveResult(gameID, &game.MoveResult{ Move: result.Move, PlayerColor: color,
Move: result.Move, Score: result.Score, Depth: result.Depth, GameState: finalState,
PlayerColor: color,
Score: result.Score,
Depth: result.Depth,
}) })
// 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 return core.StateOngoing
} }
// checkGameEnd determines if game has ended // classifyCurrentLocked classifies whatever position is loaded in the
func (p *Processor) checkGameEnd(gameID, fen string, lastMoveBy core.Color) { // validation engine. Caller holds p.mu, immediately after a SetPosition.
p.mu.Lock() func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
p.validationEng.SetPosition(fen, []string{}) diag, err := p.validationEng.Diagnose()
search, _ := p.validationEng.Search(100) if err != nil {
p.mu.Unlock() return "", core.StateOngoing, err
// Use centralized state determination
state := p.determineGameEndState(lastMoveBy, search)
if state != core.StateOngoing {
p.svc.UpdateGameState(gameID, state)
} }
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 // buildGameResponse constructs standard game response
+40 -63
View File
@@ -3,6 +3,7 @@ package processor
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"sync" "sync"
"time" "time"
@@ -69,31 +70,27 @@ func (q *EngineQueue) start() {
// worker processes engine tasks // worker processes engine tasks
func (q *EngineQueue) worker(id int) { func (q *EngineQueue) worker(id int) {
defer q.wg.Done() defer q.wg.Done()
var eng *engine.UCI
// Each worker gets its own engine instance for {
eng, err := engine.New() var err error
if err != nil { if eng, err = engine.New(); err == nil {
fmt.Printf("Worker %d failed to initialize engine: %v\n", id, err) break
return }
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() defer eng.Close()
for { for {
select { select {
case task, ok := <-q.tasks: case task, ok := <-q.tasks:
if !ok { if !ok {
return // Channel closed return
} }
task.Response <- q.processTask(eng, task) // Response is buffered(1); never blocks
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
}
case <-q.ctx.Done(): case <-q.ctx.Done():
return return
} }
@@ -102,45 +99,36 @@ func (q *EngineQueue) worker(id int) {
// processTask executes a single engine calculation // processTask executes a single engine calculation
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult { func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
result := EngineResult{ result := EngineResult{GameID: task.GameID}
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 { 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
}
} }
if err := eng.SetPosition(task.FEN, nil); err != nil {
// Setup position result.Error = err
eng.SetPosition(task.FEN, []string{}) return result
}
// Determine search time searchTime := 1000
searchTime := 1000 // Default 1 second
if task.Player.Type == core.PlayerComputer && task.Player.SearchTime > 0 { if task.Player.Type == core.PlayerComputer && task.Player.SearchTime > 0 {
searchTime = task.Player.SearchTime searchTime = task.Player.SearchTime
} }
// Search for best move
search, err := eng.Search(searchTime) search, err := eng.Search(searchTime)
if err != nil { if err != nil {
result.Error = fmt.Errorf("engine search failed: %v", err) result.Error = fmt.Errorf("engine search failed: %w", err)
return result return result
} }
// Check for no legal moves
if search.BestMove == "" || search.BestMove == "(none)" { if search.BestMove == "" || search.BestMove == "(none)" {
result.Move = "" result.IsMate, result.MateIn = search.IsMate, search.MateIn
result.IsMate = search.IsMate
result.MateIn = search.MateIn
return result return result
} }
result.Move, result.Score, result.Depth = search.BestMove, search.Score, search.Depth
result.Move = search.BestMove result.IsMate, result.MateIn = search.IsMate, search.MateIn
result.Score = search.Score
result.Depth = search.Depth
result.IsMate = search.IsMate
result.MateIn = search.MateIn
return result return result
} }
@@ -159,32 +147,22 @@ func (q *EngineQueue) Submit(task EngineTask) error {
// SubmitAsync submits a task without blocking for result // 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 { func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
respChan := make(chan EngineResult, 1) respChan := make(chan EngineResult, 1)
if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil {
task := EngineTask{
GameID: gameID,
FEN: fen,
Color: color,
Player: player,
Response: respChan,
}
if err := q.Submit(task); err != nil {
return err return err
} }
budget := 1000
// Handle result in background 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() { go func() {
select { select {
case result := <-respChan: case result := <-respChan:
callback(result) callback(result)
case <-time.After(5 * time.Second): case <-time.After(wait):
callback(EngineResult{ callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
GameID: gameID,
Error: fmt.Errorf("engine timeout"),
})
} }
}() }()
return nil return nil
} }
@@ -206,4 +184,3 @@ func (q *EngineQueue) Shutdown(timeout time.Duration) error {
return fmt.Errorf("shutdown timeout exceeded") return fmt.Errorf("shutdown timeout exceeded")
} }
} }
+35 -7
View File
@@ -113,7 +113,7 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
g.AddSnapshot(newFEN, moveUCI, nextTurn) g.AddSnapshot(newFEN, moveUCI, nextTurn)
// Notify waiting clients about the state change // 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 // Persist if storage enabled
if s.store != nil { if s.store != nil {
@@ -132,6 +132,36 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
return nil 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) // UpdateGameState sets the game's end state (checkmate, stalemate, etc)
func (s *Service) UpdateGameState(gameID string, state core.State) error { func (s *Service) UpdateGameState(gameID string, state core.State) error {
s.mu.Lock() s.mu.Lock()
@@ -143,11 +173,8 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
} }
g.SetState(state) g.SetState(state)
// Notify unconditionally; the registry decides.
// Notify if game ended s.waiter.NotifyGame(gameID, len(g.Moves()), state)
if state != core.StateOngoing && state != core.StatePending {
s.waiter.NotifyGame(gameID, len(g.Moves()))
}
return nil return nil
} }
@@ -183,7 +210,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
} }
// Notify waiting clients about the undo // 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 // Delete undone moves from storage if enabled
if s.store != nil { if s.store != nil {
@@ -215,3 +242,4 @@ func (s *Service) DeleteGame(gameID string) error {
delete(s.games, gameID) delete(s.games, gameID)
return nil return nil
} }
+4 -9
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"chess/internal/server/core"
"context" "context"
"fmt" "fmt"
"sync" "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 // 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() w.mu.RLock()
waitList := w.waiters[gameID] waitList := w.waiters[gameID]
w.mu.RUnlock() w.mu.RUnlock()
if len(waitList) == 0 { if len(waitList) == 0 {
return return
} }
settled := state != core.StateOngoing && state != core.StatePending
// Non-blocking notification to all waiters
for _, req := range waitList { for _, req := range waitList {
// Only notify if move count changed if settled || req.MoveCount != currentMoveCount {
if req.MoveCount != currentMoveCount {
select { select {
case req.Notify <- struct{}{}: case req.Notify <- struct{}{}:
// Notification sent
default: 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 // Stop timer if still running
req.Timer.Stop() req.Timer.Stop()
} }
@@ -120,9 +120,11 @@ function updateAuthIndicator(authenticated) {
if (authenticated) { if (authenticated) {
light.setAttribute('data-status', 'authenticated'); light.setAttribute('data-status', 'authenticated');
indicator.setAttribute('data-status', gameState.username); indicator.setAttribute('data-status', gameState.username);
indicator.setAttribute('data-tooltip', 'Account');
} else { } else {
light.setAttribute('data-status', 'anonymous'); 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'; status = 'unknown';
tooltipText = 'Game Over'; tooltipText = 'Game Over';
} }
} else if (state === 'stuck') {
status = 'degraded';
tooltipText = 'Engine Error';
} else if (turn === 'w') { } else if (turn === 'w') {
status = 'white'; status = 'white';
tooltipText = 'White'; tooltipText = 'White';
@@ -637,7 +642,10 @@ async function startNewGame() {
initializeBoard(); initializeBoard();
updateGameDisplay(game); updateGameDisplay(game);
document.getElementById('undo-btn').disabled = true; 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'); setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success');
setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS); setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS);
@@ -711,7 +719,7 @@ function handleSquareClick(e) {
if (gameState.isLocked) return; if (gameState.isLocked) return;
// Block moves after game over // Block moves after game over
if (isGameOver(gameState.state)) return; if (!isPlayable(gameState.state)) return;
const squareEl = e.currentTarget; const squareEl = e.currentTarget;
const { square, pieceColor } = squareEl.dataset; const { square, pieceColor } = squareEl.dataset;
@@ -776,7 +784,7 @@ async function handleHumanMove(from, to) {
flashSquare(fromEl, true); flashSquare(fromEl, true);
flashSquare(toEl, true); flashSquare(toEl, true);
updateGameDisplay(game); updateGameDisplay(game);
if (!isGameOver(game.state)) { if (isPlayable(game.state)) {
triggerComputerMove(); triggerComputerMove();
} }
} catch (error) { } catch (error) {
@@ -906,6 +914,9 @@ async function undoMoves() {
const game = await response.json(); const game = await response.json();
gameState.state = game.state; gameState.state = game.state;
updateGameDisplay(game); updateGameDisplay(game);
if (game.state === 'stuck') {
flashErrorMessage('Engine error — Undo to recover or start a new game');
}
} catch (error) { } catch (error) {
if (error.message === 'Failed to fetch') { if (error.message === 'Failed to fetch') {
handleApiError('undo', error); handleApiError('undo', error);
@@ -1021,6 +1032,9 @@ function markMatedKing(game) {
function isGameOver(state) { function isGameOver(state) {
return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state); return ['white wins', 'black wins', 'stalemate', 'draw'].includes(state);
} }
function isPlayable(state) {
return !isGameOver(state) && state !== 'stuck';
}
function handleApiError(action, error, response = null) { function handleApiError(action, error, response = null) {
let serverStatus = 'degraded'; let serverStatus = 'degraded';
@@ -647,18 +647,26 @@ input[type="range"]::-webkit-slider-thumb {
} }
/* Auth Indicator */ /* 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"] { .auth-indicator .light[data-status="anonymous"] {
color: var(--tokyo-border); color: var(--tokyo-yellow);
} }
.auth-indicator .light[data-status="authenticated"] { .auth-indicator .light[data-status="authenticated"] {
color: var(--tokyo-green); color: var(--tokyo-green);
} }
.auth-indicator {
cursor: pointer;
}
/* --- Modal status message (Issue 2) --- */ /* --- Modal status message (Issue 2) --- */
.modal-message { .modal-message {
display: none; display: none;
@@ -871,41 +879,20 @@ input[type="range"]:disabled {
} }
@media (max-width: 530px) { @media (max-width: 530px) {
body { body { min-width: 0; overflow-x: hidden; }
overflow-y: auto; .outer-container { width: 100%; min-width: 0; }
overflow-x: auto; .container { width: calc(100% - 16px); min-width: 0; }
min-width: clamp(440px, 100vw, 530px); .board-container {
width: min(92vw, 440px);
height: min(92vw, 440px);
padding: 12px;
} }
.board-wrapper {
.outer-container { width: calc(min(92vw, 440px) - 24px);
width: clamp(440px, 100vw, 530px); height: calc(min(92vw, 440px) - 24px);
min-width: clamp(440px, 100vw, 530px);
padding: 8px;
min-height: 100vh;
height: auto;
display: flex;
justify-content: center;
align-items: flex-start;
overflow: visible;
} }
.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 { .info-panel {
width: clamp(360px, 83vw, 440px); width: min(92vw, 440px);
min-width: clamp(360px, 83vw, 440px); min-width: 0;
} }
} }
+20 -18
View File
@@ -225,28 +225,31 @@ test_case "2.3: Login with Username"
RESPONSE=$(api_request POST "$API_URL/auth/login" \ RESPONSE=$(api_request POST "$API_URL/auth/login" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"identifier\": \"$TEST_USER1\", \"password\": \"$TEST_PASS1\"}") -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) USER_ID_ALICE=$(echo "$RESPONSE" | jq -r '.userId' 2>/dev/null)
if [ -n "$TOKEN_ALICE" ] && [ "$TOKEN_ALICE" != "null" ]; then if [ -n "$TOKEN_ALICE_S1" ] && [ "$TOKEN_ALICE_S1" != "null" ]; then
echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}" echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}"; ((PASS++))
((PASS++))
else else
echo -e "${RED} ✗ Login failed${NC}" echo -e "${RED} ✗ Login failed${NC}"; ((FAIL++))
((FAIL++))
fi 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" \ RESPONSE=$(api_request POST "$API_URL/auth/login" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"identifier\": \"$TEST_EMAIL1\", \"password\": \"$TEST_PASS1\"}") -d "{\"identifier\": \"$TEST_EMAIL1\", \"password\": \"$TEST_PASS1\"}")
if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -q "^ey"; then TOKEN_ALICE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null) # ONLY this token is valid from here on
echo -e "${GREEN} ✓ Email login successful${NC}" if echo "$TOKEN_ALICE" | grep -q "^ey"; then
((PASS++)) echo -e "${GREEN} ✓ Email login successful${NC}"; ((PASS++))
else else
echo -e "${RED} ✗ Email login failed${NC}" echo -e "${RED} ✗ Email login failed${NC}"; ((FAIL++))
((FAIL++))
fi 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" test_case "2.5: Invalid Credentials"
STATUS=$(api_request POST "$API_URL/auth/login" \ STATUS=$(api_request POST "$API_URL/auth/login" \
-o /dev/null -w "%{http_code}" \ -o /dev/null -w "%{http_code}" \
@@ -298,20 +301,19 @@ else
((FAIL++)) ((FAIL++))
fi 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" \ RESPONSE=$(api_request POST "$API_URL/games" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN_ALICE" \ -H "Authorization: Bearer $TOKEN_ALICE" \
-d '{"white": {"type": 1}, "black": {"type": 1}}') -d '{"white": {"type": 1}, "black": {"type": 1}}')
WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null) 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_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 if [ "$WHITE_ID" = "$USER_ID_ALICE" ] && [ "$BLACK_ID" != "$USER_ID_ALICE" ] && [ -z "$BLACK_CLAIMED" ]; then
echo -e "${GREEN}Same user can play both sides${NC}" echo -e "${GREEN}Creator claims white only; black remains claimable${NC}"; ((PASS++))
((PASS++))
else else
echo -e "${RED}Both sides should be same user${NC}" echo -e "${RED}Slot assignment wrong: white=$WHITE_ID black=$BLACK_ID claimedBy=$BLACK_CLAIMED${NC}"; ((FAIL++))
((FAIL++))
fi fi
# ============================================================================== # ==============================================================================
+94
View File
@@ -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 ]
+4 -4
View File
@@ -125,7 +125,7 @@ test_multiple_waiters() {
# Test 3: Timeout behavior # Test 3: Timeout behavior
test_timeout() { test_timeout() {
log_test "Timeout behavior (this takes 25 seconds)" log_test "Timeout behavior (this takes 30 seconds)"
# Create a game # Create a game
GAME_ID=$(create_game 1 1) GAME_ID=$(create_game 1 1)
@@ -139,10 +139,10 @@ test_timeout() {
elapsed=$((end_time - start_time)) elapsed=$((end_time - start_time))
# Check timeout was ~25 seconds # Check timeout was ~25 seconds
if [ "$elapsed" -ge 24 ] && [ "$elapsed" -le 26 ]; then if [ "$elapsed" -ge 29 ] && [ "$elapsed" -le 31 ]; then
log_info "✓ Request timed out after ~25 seconds" log_info "✓ Request timed out after ~30 seconds"
else else
log_error "✗ Timeout was $elapsed seconds (expected ~25)" log_error "✗ Timeout was $elapsed seconds (expected ~30)"
exit 1 exit 1
fi fi