From f459a4c231630ce996b112bbae389f3e46b18d41 Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Wed, 2 Sep 2026 21:06:39 -0400 Subject: [PATCH 1/2] games_ws_backend: retire the golf hub golf_hub is the golf backend; games_ws_backend serves thoughts alone. The /games/v1/golf-ws route and the jwt dependency go with it. --- bazel/go.MODULE.bazel | 1 - deploy/consolidated/Caddyfile | 7 - deploy/consolidated/Caddyfile.local | 7 - deploy/consolidated/deploy_config_test.go | 1 - domains/games/README.md | 2 +- .../games/apis/games_ws_backend/BUILD.bazel | 2 - domains/games/apis/games_ws_backend/README.md | 7 +- .../apis/games_ws_backend/golf/BUILD.bazel | 36 - .../games_ws_backend/golf/EXAMPLE_GOLF.md | 476 ---- .../apis/games_ws_backend/golf/README.md | 130 - .../games/apis/games_ws_backend/golf/auth.go | 66 - .../games/apis/games_ws_backend/golf/game.go | 806 ------- .../apis/games_ws_backend/golf/game_test.go | 1648 ------------- .../apis/games_ws_backend/golf/golf_hub.go | 1748 -------------- .../games_ws_backend/golf/golf_hub_test.go | 2142 ----------------- .../games_ws_backend/golf/integration_test.go | 1405 ----------- .../golf/state_transitions_test.go | 1192 --------- .../games_ws_backend/golf/state_validation.go | 417 ---- .../games/apis/games_ws_backend/golf/types.go | 388 --- .../apis/games_ws_backend/golf/types_test.go | 722 ------ domains/games/apis/games_ws_backend/main.go | 9 - domains/games/apis/golf_hub/README.md | 44 +- go.mod | 1 - go.sum | 24 +- 24 files changed, 32 insertions(+), 11249 deletions(-) delete mode 100644 domains/games/apis/games_ws_backend/golf/BUILD.bazel delete mode 100644 domains/games/apis/games_ws_backend/golf/EXAMPLE_GOLF.md delete mode 100644 domains/games/apis/games_ws_backend/golf/README.md delete mode 100644 domains/games/apis/games_ws_backend/golf/auth.go delete mode 100644 domains/games/apis/games_ws_backend/golf/game.go delete mode 100644 domains/games/apis/games_ws_backend/golf/game_test.go delete mode 100644 domains/games/apis/games_ws_backend/golf/golf_hub.go delete mode 100644 domains/games/apis/games_ws_backend/golf/golf_hub_test.go delete mode 100644 domains/games/apis/games_ws_backend/golf/integration_test.go delete mode 100644 domains/games/apis/games_ws_backend/golf/state_transitions_test.go delete mode 100644 domains/games/apis/games_ws_backend/golf/state_validation.go delete mode 100644 domains/games/apis/games_ws_backend/golf/types.go delete mode 100644 domains/games/apis/games_ws_backend/golf/types_test.go diff --git a/bazel/go.MODULE.bazel b/bazel/go.MODULE.bazel index 3a31bddd0..d6fe3ed6e 100644 --- a/bazel/go.MODULE.bazel +++ b/bazel/go.MODULE.bazel @@ -14,7 +14,6 @@ go_deps.gazelle_default_attributes( go_deps.from_file(go_mod = "//:go.mod") use_repo( go_deps, - "com_github_golang_jwt_jwt_v5", "com_github_google_uuid", "com_github_gorilla_websocket", "com_github_jackc_pgx_v5", diff --git a/deploy/consolidated/Caddyfile b/deploy/consolidated/Caddyfile index b9f6c1849..ebdbf9b2a 100644 --- a/deploy/consolidated/Caddyfile +++ b/deploy/consolidated/Caddyfile @@ -122,10 +122,6 @@ api.muchq.com { path /games/v1/thoughts-ws } - @ws_golf { - path /games/v1/golf-ws - } - @post_golf_v2_session { method POST path /games/v2/session @@ -175,9 +171,6 @@ api.muchq.com { handle @ws_thoughts { reverse_proxy games_ws_backend:8080 } - handle @ws_golf { - reverse_proxy games_ws_backend:8080 - } handle @post_golf_v2_session { reverse_proxy golf_hub:8089 } diff --git a/deploy/consolidated/Caddyfile.local b/deploy/consolidated/Caddyfile.local index 506ffbfe2..a020cec1d 100644 --- a/deploy/consolidated/Caddyfile.local +++ b/deploy/consolidated/Caddyfile.local @@ -72,10 +72,6 @@ path /games/v1/thoughts-ws } - @ws_golf { - path /games/v1/golf-ws - } - @post_golf_v2_session { method POST path /games/v2/session @@ -109,9 +105,6 @@ handle @ws_thoughts { reverse_proxy localhost:8080 } - handle @ws_golf { - reverse_proxy localhost:8080 - } handle @post_golf_v2_session { reverse_proxy localhost:8089 } diff --git a/deploy/consolidated/deploy_config_test.go b/deploy/consolidated/deploy_config_test.go index 269118e06..938d30a5f 100644 --- a/deploy/consolidated/deploy_config_test.go +++ b/deploy/consolidated/deploy_config_test.go @@ -2150,7 +2150,6 @@ func catchAllIsLastHandle(site []string, terminal string) (found bool, problem s // a rewrite of the block does not ship. var localRoutes = map[string]string{ "@ws_thoughts": "localhost:8080", - "@ws_golf": "localhost:8080", "@post_golf_v2_session": "localhost:8089", "@ws_golf_v2": "localhost:8089", "@post_portrait": "localhost:8081", diff --git a/domains/games/README.md b/domains/games/README.md index 2579d66cf..6fc01da8e 100644 --- a/domains/games/README.md +++ b/domains/games/README.md @@ -8,7 +8,7 @@ Game engines, services, and libraries. ## APIs -- [**Golf Hub**](apis/golf_hub): smithy-cpp event-stream backend for the golf card game (v2). +- [**Golf Hub**](apis/golf_hub): smithy-cpp event-stream backend for the golf card game. - [**Games WS Backend**](apis/games_ws_backend): WebSocket-based backend for real-time multiplayer games. - [**Mithril**](apis/mithril): Rust-based game service. - [**1d4.net**](apis/one_d4): Chess analysis service. diff --git a/domains/games/apis/games_ws_backend/BUILD.bazel b/domains/games/apis/games_ws_backend/BUILD.bazel index 9dcccfe11..d9aebb486 100644 --- a/domains/games/apis/games_ws_backend/BUILD.bazel +++ b/domains/games/apis/games_ws_backend/BUILD.bazel @@ -7,9 +7,7 @@ go_library( importpath = "github.com/muchq/moonbase/domains/games/apis/games_ws_backend", visibility = ["//visibility:public"], deps = [ - "//domains/games/apis/games_ws_backend/golf", "//domains/games/apis/games_ws_backend/hub", - "//domains/games/apis/games_ws_backend/players", "//domains/games/apis/games_ws_backend/thoughts", ], ) diff --git a/domains/games/apis/games_ws_backend/README.md b/domains/games/apis/games_ws_backend/README.md index 02bac79c9..92517d6f9 100644 --- a/domains/games/apis/games_ws_backend/README.md +++ b/domains/games/apis/games_ws_backend/README.md @@ -4,9 +4,8 @@ A real-time multiplayer, multitenant WebSocket game server. ## Overview -This server hosts multiple game backends on a single process, each on its own WebSocket endpoint: +This server hosts game backends on a single process, each on its own WebSocket endpoint: -- **[Golf](golf/)** (`/games/v1/golf-ws`) — a 4-card golf card game with rooms, JWT authentication, and session reconnection - **[Thoughts](thoughts/)** (`/games/v1/thoughts-ws`) — a chill 3D multiplayer vibe, playable at [muchq.com/thoughts](https://muchq.com/thoughts) ## Architecture @@ -15,7 +14,6 @@ This server hosts multiple game backends on a single process, each on its own We main.go ├── hub/ # Shared WebSocket hub: client lifecycle, ping/pong, origin checks ├── players/ # Player ID generators (whimsical for prod, deterministic for tests) -├── golf/ # Golf game hub + game logic + JWT auth └── thoughts/ # Thoughts game hub + game logic ``` @@ -36,7 +34,7 @@ Games receive raw messages via `GameMessage`, manage their own state, and send r ### Client Identity -Each WebSocket connection gets a UUID (`hub.Client.ID`) assigned at upgrade time. Games build their own identity layer on top — golf uses JWT-based player sessions that persist across reconnections. +Each WebSocket connection gets a UUID (`hub.Client.ID`) assigned at upgrade time. Games build their own identity layer on top. ## Running @@ -69,5 +67,4 @@ deploy/consolidated/deploy.sh ## Security - **Origin validation**: Production only allows `muchq.com`, `www.muchq.com`, and `thoughts.muchq.com` over HTTPS -- **JWT authentication** (golf): HMAC-SHA256 tokens with algorithm validation to prevent confusion attacks - **Server-assigned IDs**: Player IDs are generated server-side, never accepted from clients diff --git a/domains/games/apis/games_ws_backend/golf/BUILD.bazel b/domains/games/apis/games_ws_backend/golf/BUILD.bazel deleted file mode 100644 index be844cbce..000000000 --- a/domains/games/apis/games_ws_backend/golf/BUILD.bazel +++ /dev/null @@ -1,36 +0,0 @@ -load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "golf", - srcs = [ - "auth.go", - "game.go", - "golf_hub.go", - "state_validation.go", - "types.go", - ], - importpath = "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/golf", - visibility = ["//visibility:public"], - deps = [ - "//domains/games/apis/games_ws_backend/hub", - "//domains/games/apis/games_ws_backend/players", - "@com_github_golang_jwt_jwt_v5//:jwt", - ], -) - -go_test( - name = "golf_test", - size = "small", - srcs = [ - "game_test.go", - "golf_hub_test.go", - "integration_test.go", - "state_transitions_test.go", - "types_test.go", - ], - embed = [":golf"], - deps = [ - "//domains/games/apis/games_ws_backend/hub", - "//domains/games/apis/games_ws_backend/players", - ], -) diff --git a/domains/games/apis/games_ws_backend/golf/EXAMPLE_GOLF.md b/domains/games/apis/games_ws_backend/golf/EXAMPLE_GOLF.md deleted file mode 100644 index 8363c3840..000000000 --- a/domains/games/apis/games_ws_backend/golf/EXAMPLE_GOLF.md +++ /dev/null @@ -1,476 +0,0 @@ -# Golf WebSocket Message Flow Example - -This example demonstrates a complete message flow for the room-based multi-game golf system. - -## Scenario -- **Alice** creates a room -- **Bob** joins the room -- **Alice** creates a golf game -- **Bob** joins the game -- They play and finish the game -- **Bob** starts a new game -- **Alice** joins the new game -- They play again - ---- - -## Message Flow - -### 1. Alice Creates Room - -**Alice → Server:** -```json -{"type": "createGame"} -``` - -**Server → Alice:** -```json -{ - "type": "roomJoined", - "playerId": "player_ABC123_1", - "roomState": { - "id": "ABC123", - "players": [ - { - "id": "player_ABC123_1", - "name": "SunnyPenguin", - "clientId": "192.168.1.100:54321", - "totalScore": 0, - "gamesPlayed": 0, - "gamesWon": 0, - "isConnected": true, - "joinedAt": "2025-01-15T10:30:00Z" - } - ], - "games": {}, - "gameHistory": [], - "createdAt": "2025-01-15T10:30:00Z", - "lastActivity": "2025-01-15T10:30:00Z" - } -} -``` - -### 2. Bob Joins Room (without specific game) - -**Bob → Server:** -```json -{ - "type": "joinGame", - "roomId": "ABC123", - "gameId": "LOBBY" -} -``` - -**Server → Bob:** -```json -{ - "type": "roomJoined", - "playerId": "player_ABC123_2", - "roomState": { - "id": "ABC123", - "players": [ - { - "id": "player_ABC123_1", - "name": "SunnyPenguin", - "clientId": "192.168.1.100:54321", - "totalScore": 0, - "gamesPlayed": 0, - "gamesWon": 0, - "isConnected": true, - "joinedAt": "2025-01-15T10:30:00Z" - }, - { - "id": "player_ABC123_2", - "name": "CozyFox", - "clientId": "192.168.1.101:54322", - "totalScore": 0, - "gamesPlayed": 0, - "gamesWon": 0, - "isConnected": true, - "joinedAt": "2025-01-15T10:30:15Z" - } - ], - "games": {}, - "gameHistory": [], - "createdAt": "2025-01-15T10:30:00Z", - "lastActivity": "2025-01-15T10:30:15Z" - } -} -``` - -**Server → Alice (room state update):** -```json -{ - "type": "roomStateUpdate", - "roomState": { - "id": "ABC123", - "players": [ - {"id": "player_ABC123_1", "name": "SunnyPenguin", "isConnected": true}, - {"id": "player_ABC123_2", "name": "CozyFox", "isConnected": true} - ], - "games": {}, - "gameHistory": [] - } -} -``` - -### 3. Alice Creates and Joins Golf Game - -**Alice → Server:** -```json -{ - "type": "joinGame", - "roomId": "ABC123", - "gameId": "GAME1" -} -``` - -**Server → Alice:** -```json -{ - "type": "roomJoined", - "playerId": "player_ABC123_1", - "roomState": { - "id": "ABC123", - "players": [ - {"id": "player_ABC123_1", "name": "SunnyPenguin", "isConnected": true}, - {"id": "player_ABC123_2", "name": "CozyFox", "isConnected": true} - ], - "games": { - "GAME1": { - "id": "GAME1", - "players": [ - { - "id": "player_ABC123_1", - "name": "SunnyPenguin", - "cards": [null, null, null, null], - "score": 0, - "revealedCards": [], - "isReady": false, - "hasPeeked": false - } - ], - "currentPlayerIndex": 0, - "drawPile": 0, - "discardPile": [], - "gamePhase": "waiting" - } - }, - "gameHistory": [] - } -} -``` - -### 4. Bob Joins Game - -**Bob → Server:** -```json -{ - "type": "joinGame", - "roomId": "ABC123", - "gameId": "GAME1" -} -``` - -**Server → Bob:** -```json -{ - "type": "roomJoined", - "playerId": "player_ABC123_2", - "roomState": { - "id": "ABC123", - "games": { - "GAME1": { - "id": "GAME1", - "players": [ - {"id": "player_ABC123_1", "name": "SunnyPenguin"}, - {"id": "player_ABC123_2", "name": "CozyFox"} - ], - "gamePhase": "waiting" - } - } - } -} -``` - -**Server → Alice (room state update):** -```json -{ - "type": "roomStateUpdate", - "roomState": { - "games": { - "GAME1": { - "players": [ - {"name": "SunnyPenguin"}, - {"name": "CozyFox"} - ] - } - } - } -} -``` - -### 5. Alice Starts Game - -**Alice → Server:** -```json -{"type": "startGame"} -``` - -**Server → Alice & Bob:** -```json -{"type": "gameStarted"} -``` - -**Server → Alice (personalized game state):** -```json -{ - "type": "gameState", - "gameState": { - "id": "GAME1", - "players": [ - { - "id": "player_ABC123_1", - "name": "SunnyPenguin", - "cards": [ - {"rank": "7", "suit": "♠"}, - {"rank": "K", "suit": "♥"}, - null, - null - ], - "revealedCards": [], - "hasPeeked": false - }, - { - "id": "player_ABC123_2", - "name": "CozyFox", - "cards": [null, null, null, null], - "revealedCards": [], - "hasPeeked": false - } - ], - "currentPlayerIndex": 0, - "drawPile": 44, - "discardPile": [{"rank": "3", "suit": "♦"}], - "gamePhase": "peeking" - } -} -``` - -### 6. Game Play (Abbreviated) - -**Alice peeks at cards:** -```json -{"type": "peekCard", "cardIndex": 0} -``` - -**Alice peeks at another card:** -```json -{"type": "peekCard", "cardIndex": 2} -``` - -**Bob peeks at his cards:** -```json -{"type": "peekCard", "cardIndex": 1} -``` -```json -{"type": "peekCard", "cardIndex": 3} -``` - -**Game transitions to playing phase, players take turns...** - -**Alice draws card:** -```json -{"type": "drawCard"} -``` - -**Alice swaps card:** -```json -{"type": "swapCard", "cardIndex": 1} -``` - -**Bob takes from discard:** -```json -{"type": "takeFromDiscard"} -``` - -**Bob swaps card:** -```json -{"type": "swapCard", "cardIndex": 0} -``` - -**Alice knocks:** -```json -{"type": "knock"} -``` - -**Server → Alice & Bob:** -```json -{ - "type": "playerKnocked", - "playerName": "SunnyPenguin" -} -``` - -**Bob takes final turn:** -```json -{"type": "drawCard"} -``` -```json -{"type": "discardDrawn"} -``` - -### 7. Game Ends - -**Server → Alice & Bob:** -```json -{ - "type": "gameEnded", - "winner": "SunnyPenguin", - "finalScores": [ - {"playerName": "SunnyPenguin", "score": 12}, - {"playerName": "CozyFox", "score": 18} - ] -} -``` - -**Server → Alice & Bob (room state update):** -```json -{ - "type": "roomStateUpdate", - "roomState": { - "id": "ABC123", - "players": [ - { - "id": "player_ABC123_1", - "name": "SunnyPenguin", - "totalScore": 12, - "gamesPlayed": 1, - "gamesWon": 1 - }, - { - "id": "player_ABC123_2", - "name": "CozyFox", - "totalScore": 18, - "gamesPlayed": 1, - "gamesWon": 0 - } - ], - "games": {}, - "gameHistory": [ - { - "gameId": "GAME1", - "winner": "SunnyPenguin", - "finalScores": [ - {"playerName": "SunnyPenguin", "score": 12}, - {"playerName": "CozyFox", "score": 18} - ], - "completedAt": "2025-01-15T10:45:30Z" - } - ] - } -} -``` - -### 8. Bob Starts New Game - -**Bob → Server:** -```json -{ - "type": "joinGame", - "roomId": "ABC123", - "gameId": "GAME2" -} -``` - -**Server → Bob:** -```json -{ - "type": "roomJoined", - "playerId": "player_ABC123_2", - "roomState": { - "games": { - "GAME2": { - "id": "GAME2", - "players": [ - { - "id": "player_ABC123_2", - "name": "CozyFox" - } - ], - "gamePhase": "waiting" - } - } - } -} -``` - -### 9. Alice Joins New Game - -**Alice → Server:** -```json -{ - "type": "joinGame", - "roomId": "ABC123", - "gameId": "GAME2" -} -``` - -**Server → Alice:** -```json -{ - "type": "roomJoined", - "playerId": "player_ABC123_1", - "roomState": { - "games": { - "GAME2": { - "players": [ - {"name": "CozyFox"}, - {"name": "SunnyPenguin"} - ] - } - } - } -} -``` - -### 10. Second Game Starts - -**Bob → Server:** -```json -{"type": "startGame"} -``` - -**Server → Alice & Bob:** -```json -{"type": "gameStarted"} -``` - -**...Game continues with same message patterns as before...** - ---- - -## Key Message Flow Patterns - -### Room vs Game Messages -- **Room Operations**: Use `roomJoined` and `roomStateUpdate` -- **Game Operations**: Use `gameState` and game-specific messages -- **Game Creation**: Automatically happens when joining non-existent gameId - -### Player Context -- Players are **always in a room** (via GameContext.RoomID) -- Players **optionally in a game** (via GameContext.GameID) -- Room membership persists across multiple games - -### Game Lifecycle -1. **Game Creation**: Implicit when first player joins gameId -2. **Game Population**: Players join existing gameId -3. **Game Start**: Explicit `startGame` message -4. **Game Play**: Standard golf message flow -5. **Game End**: Automatic cleanup, stats added to room - -### Multi-Game Architecture Benefits -- **Room Persistence**: Player relationships and statistics maintained -- **Game Isolation**: Multiple concurrent games don't interfere -- **Flexible Joining**: Players can join different games as desired -- **Chat Ready**: Room context perfect for pre-game coordination \ No newline at end of file diff --git a/domains/games/apis/games_ws_backend/golf/README.md b/domains/games/apis/games_ws_backend/golf/README.md deleted file mode 100644 index c6f6f7ddb..000000000 --- a/domains/games/apis/games_ws_backend/golf/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Golf Card Game - -WebSocket backend for 4-card golf with rooms, JWT authentication, and session reconnection. - -## Game Rules - -- Each player receives 4 face-down cards in a 2x2 grid -- Players peek at exactly 2 cards at the start -- On each turn: draw from deck or take from discard, then swap with one of your cards or discard -- A player can knock to trigger the final round -- Lowest total score wins - -### Card Values - -| Card | Value | -|------|-------| -| A | 1 | -| 2-10 | Face value | -| J | 0 | -| Q, K | 10 | - -## Architecture - -### Room-Based Multi-Game System - -``` -Room (6-char ID, e.g. "ABC123") -├── Players[] — persistent across games, track wins/scores -├── Games{} — concurrent game instances -│ ├── GAME1 (players A, B playing) -│ └── GAME2 (players C, D playing) -└── GameHistory[] — completed game results -``` - -- **Rooms** are persistent containers with player membership and cumulative stats -- **Games** are isolated match instances within rooms (2-4 players each) -- Players can be in a room without being in a specific game - -### Authentication and Reconnection - -Players authenticate via JWT on every WebSocket connection: - -1. Client connects and sends `authenticate` with a stored session token (or empty for new session) -2. Server validates the token and either restores the existing session or creates a new one -3. Server responds with `authenticated` containing a session token for the client to store -4. If reconnecting, server automatically restores the player to their room and game - -Disconnected players have a **5-minute grace period** during which their session is preserved. If they reconnect within that window, they resume with the same player ID and game state. After the grace period, the session is cleaned up and the player is removed from their game/room. - -Tokens use HMAC-SHA256 with a random secret generated at server startup. The signing method is strictly validated to prevent algorithm confusion attacks. - -**Limitation:** The JWT secret is generated randomly on each server start, so all existing session tokens are invalidated on restart or deploy. Players will need to re-authenticate as new sessions. A future improvement is to load the secret from a file or environment variable so tokens survive restarts. - -### Concurrency Model - -`GolfHub` runs a single-goroutine event loop processing channels for register, unregister, game messages, and session cleanup. Game instances (`Game`) use mutex-based locking for state access. This means the hub never blocks on game operations, and games are safe to access from broadcast goroutines. - -## Message Protocol - -### Connection Lifecycle - -``` -Client Server - |--- WebSocket connect -------->| - |--- authenticate ------------->| (with sessionToken or empty) - |<-- authenticated -------------| (sessionToken, playerId, reconnected) - |--- createGame / joinGame ---->| (room + game operations) - |<-- roomJoined ----------------| (playerId, roomState) - |--- startGame ---------------->| - |<-- gameStarted ---------------| - |<-- gameState -----------------| (personalized per player) - | ... game play ... | - |<-- gameEnded -----------------| (winner, finalScores) -``` - -### Client to Server - -| Message | Fields | Description | -|---------|--------|-------------| -| `authenticate` | `sessionToken?` | First message after connect; empty token for new session | -| `createGame` | | Create a new room with the player in it | -| `joinGame` | `roomId`, `gameId` | Join a specific game in a room (creates game if needed) | -| `startGame` | | Start the current game (requires 2+ players) | -| `peekCard` | `cardIndex` | Peek at one of your cards (during peeking phase) | -| `drawCard` | | Draw from deck | -| `takeFromDiscard` | | Take the top discard pile card | -| `swapCard` | `cardIndex` | Swap drawn card with one of yours | -| `discardDrawn` | | Discard the drawn card | -| `knock` | | Signal final round | -| `hideCards` | | Hide peeked cards | -| `startNewGame` | | Start a new game in the current room | - -### Server to Client - -| Message | Fields | Description | -|---------|--------|-------------| -| `authenticated` | `sessionToken`, `playerId`, `reconnected` | Auth confirmation | -| `roomJoined` | `playerId`, `roomState` | Joined room confirmation | -| `roomStateUpdate` | `roomState` | Room state changed | -| `gameJoined` | `playerId`, `gameState` | Joined game confirmation | -| `gameState` | `gameState` | Game state update (cards personalized per player) | -| `gameStarted` | | Game has begun | -| `turnChanged` | `playerName` | Turn passed to next player | -| `playerKnocked` | `playerName` | A player knocked | -| `gameEnded` | `winner`, `finalScores` | Game over | -| `newGameStarted` | `gameId`, `previousGameId?` | New game created in room | -| `error` | `message` | Error message | - -## Code Layout - -| File | Description | -|------|-------------| -| `golf_hub.go` | Hub event loop: register/unregister, message routing, auth, reconnection, session cleanup | -| `game.go` | Game instance: state machine, turn logic, scoring, player management | -| `auth.go` | JWT token creation and validation (HMAC-SHA256 via `golang-jwt/jwt/v5`) | -| `types.go` | All message types, game state structs, card utilities | -| `state_validation.go` | Game phase transition validators | - -## Development - -```bash -# Run all tests -bazel test //domains/games/apis/games_ws_backend/golf:golf_test - -# Run specific tests -bazel test //domains/games/apis/games_ws_backend/golf:golf_test --test_filter="TestHub_Auth" -bazel test //domains/games/apis/games_ws_backend/golf:golf_test --test_filter="TestIntegration" -``` - -See [EXAMPLE_GOLF.md](EXAMPLE_GOLF.md) for a complete annotated message flow walkthrough. diff --git a/domains/games/apis/games_ws_backend/golf/auth.go b/domains/games/apis/games_ws_backend/golf/auth.go deleted file mode 100644 index bfed0ab52..000000000 --- a/domains/games/apis/games_ws_backend/golf/auth.go +++ /dev/null @@ -1,66 +0,0 @@ -package golf - -import ( - "crypto/rand" - "fmt" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -// TokenManager handles JWT creation and validation for player authentication. -// Uses HMAC-SHA256 with a random secret generated at startup. -type TokenManager struct { - secret []byte -} - -// NewTokenManager creates a TokenManager with a cryptographically random 32-byte secret. -func NewTokenManager() *TokenManager { - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - panic("failed to generate JWT secret: " + err.Error()) - } - return &TokenManager{secret: secret} -} - -// NewTokenManagerWithSecret creates a TokenManager with a provided secret (for testing). -func NewTokenManagerWithSecret(secret []byte) *TokenManager { - return &TokenManager{secret: secret} -} - -// CreateToken generates a JWT containing the playerID with the given TTL. -func (tm *TokenManager) CreateToken(playerID string, ttl time.Duration) (string, error) { - claims := jwt.RegisteredClaims{ - Subject: playerID, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), - IssuedAt: jwt.NewNumericDate(time.Now()), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(tm.secret) -} - -// ValidateToken verifies the JWT signature, checks expiry, and returns the playerID. -// Strictly validates that the signing method is HMAC to prevent algorithm confusion attacks. -func (tm *TokenManager) ValidateToken(tokenString string) (string, error) { - token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"]) - } - return tm.secret, nil - }) - if err != nil { - return "", fmt.Errorf("invalid token: %w", err) - } - - claims, ok := token.Claims.(*jwt.RegisteredClaims) - if !ok { - return "", fmt.Errorf("invalid token claims") - } - - if claims.Subject == "" { - return "", fmt.Errorf("token missing subject") - } - - return claims.Subject, nil -} diff --git a/domains/games/apis/games_ws_backend/golf/game.go b/domains/games/apis/games_ws_backend/golf/game.go deleted file mode 100644 index a3b264e9c..000000000 --- a/domains/games/apis/games_ws_backend/golf/game.go +++ /dev/null @@ -1,806 +0,0 @@ -package golf - -import ( - "fmt" - "strings" - "sync" - "time" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -// Game represents a single golf game instance -type Game struct { - mu sync.RWMutex - state *GameState - deck []*Card - playersByClient map[string]*Player // client ID -> player - finalRoundPlayed map[string]bool // track who has played their final turn - idGenerator players.PlayerIDGenerator - roomID string // ID of the room this game belongs to -} - -// NewGame creates a new game instance -func NewGame(gameID string, idGenerator players.PlayerIDGenerator) *Game { - return &Game{ - state: &GameState{ - ID: gameID, - Players: make([]*Player, 0), - CurrentPlayerIndex: 0, - DrawPile: 0, - DiscardPile: make([]*Card, 0), - GamePhase: "waiting", - KnockedPlayerID: nil, - DrawnCard: nil, - PeekedAtDrawPile: false, - }, - playersByClient: make(map[string]*Player), - finalRoundPlayed: make(map[string]bool), - idGenerator: idGenerator, - roomID: "", // Will be set when game is created in a room - } -} - -// NewGameInRoom creates a new game instance for a specific room -func NewGameInRoom(gameID string, roomID string, roomPlayers []*Player, idGenerator players.PlayerIDGenerator) *Game { - // Create game players from room players with reset game-specific state - gamePlayers := make([]*Player, len(roomPlayers)) - playersByClient := make(map[string]*Player) - - for i, roomPlayer := range roomPlayers { - gamePlayer := &Player{ - // Copy persistent room data - ID: roomPlayer.ID, - Name: roomPlayer.Name, - ClientID: roomPlayer.ClientID, - TotalScore: roomPlayer.TotalScore, - GamesPlayed: roomPlayer.GamesPlayed, - GamesWon: roomPlayer.GamesWon, - IsConnected: roomPlayer.IsConnected, - JoinedAt: roomPlayer.JoinedAt, - - // Reset game-specific state - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: make([]int, 0), - IsReady: false, - HasPeeked: false, - } - gamePlayers[i] = gamePlayer - playersByClient[roomPlayer.ClientID] = gamePlayer - } - - return &Game{ - state: &GameState{ - ID: gameID, - Players: gamePlayers, - CurrentPlayerIndex: 0, - DrawPile: 0, - DiscardPile: make([]*Card, 0), - GamePhase: "waiting", - KnockedPlayerID: nil, - DrawnCard: nil, - PeekedAtDrawPile: false, - }, - playersByClient: playersByClient, - finalRoundPlayed: make(map[string]bool), - idGenerator: idGenerator, - roomID: roomID, - } -} - -// AddPlayer adds a new player to the game with given playerID and playerName -func (g *Game) AddPlayer(clientID string, playerID string, playerName string) (*Player, error) { - g.mu.Lock() - defer g.mu.Unlock() - - if g.state.GamePhase != "waiting" { - return nil, fmt.Errorf("game already started") - } - - if len(g.state.Players) >= 4 { - return nil, fmt.Errorf("game is full") - } - - // Check if player is already in the game - if _, exists := g.playersByClient[clientID]; exists { - return nil, fmt.Errorf("player already in game") - } - - player := &Player{ - ID: playerID, - Name: playerName, - ClientID: clientID, - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: make([]int, 0), - IsReady: false, - HasPeeked: false, - IsConnected: true, - JoinedAt: time.Now(), - } - - g.state.Players = append(g.state.Players, player) - g.playersByClient[clientID] = player - - return player, nil -} - -// RemovePlayer removes a player from the game. -// If the game is in progress and fewer than 2 players remain, the game ends. -func (g *Game) RemovePlayer(clientID string) error { - g.mu.Lock() - defer g.mu.Unlock() - - player, exists := g.playersByClient[clientID] - if !exists { - return fmt.Errorf("player not found") - } - - // Remove from players list - for i, p := range g.state.Players { - if p.ID == player.ID { - g.state.Players = append(g.state.Players[:i], g.state.Players[i+1:]...) - break - } - } - - delete(g.playersByClient, clientID) - - // If game is in progress, handle the reduced player count - if g.state.GamePhase == "playing" || g.state.GamePhase == "knocked" || g.state.GamePhase == "peeking" { - if len(g.state.Players) < 2 { - // Not enough players to continue — end the game - g.state.GamePhase = "ended" - g.calculateFinalScores() - } else { - g.state.CurrentPlayerIndex = g.state.CurrentPlayerIndex % len(g.state.Players) - } - } - - return nil -} - -// StartGame initializes the game with dealt cards -func (g *Game) StartGame() error { - g.mu.Lock() - defer g.mu.Unlock() - - if g.state.GamePhase != "waiting" { - return fmt.Errorf("game already started") - } - - if len(g.state.Players) < 2 { - return fmt.Errorf("need at least 2 players to start") - } - - // Create and shuffle deck - g.deck = CreateDeck() - ShuffleDeck(g.deck) - - // Deal 4 cards to each player - for _, player := range g.state.Players { - for i := 0; i < 4; i++ { - if len(g.deck) > 0 { - player.Cards[i] = g.deck[0] - g.deck = g.deck[1:] - } - } - } - - // Set up discard pile with one card - if len(g.deck) > 0 { - g.state.DiscardPile = append(g.state.DiscardPile, g.deck[0]) - g.deck = g.deck[1:] - } - - g.state.DrawPile = len(g.deck) - g.state.GamePhase = "playing" - g.state.CurrentPlayerIndex = 0 - - return nil -} - -// PeekCard allows a player to peek at one of their cards -func (g *Game) PeekCard(clientID string, cardIndex int) error { - g.mu.Lock() - defer g.mu.Unlock() - - player, exists := g.playersByClient[clientID] - if !exists { - return fmt.Errorf("player not found") - } - - if err := ValidateCardIndex(cardIndex); err != nil { - return err - } - - if g.state.GamePhase != "playing" && g.state.GamePhase != "peeking" { - return fmt.Errorf("can only peek during playing phase") - } - - // Check if player has already peeked at 2 cards - if len(player.RevealedCards) >= 2 { - return fmt.Errorf("already peeked at 2 cards") - } - - // Check if already peeked at this card - for _, idx := range player.RevealedCards { - if idx == cardIndex { - return fmt.Errorf("already peeked at this card") - } - } - - player.RevealedCards = append(player.RevealedCards, cardIndex) - player.Score = CalculatePlayerScore(player) - - // Mark that player has peeked - if len(player.RevealedCards) == 2 { - player.HasPeeked = true - - // Check if all players have peeked - allPeeked := true - for _, p := range g.state.Players { - if !p.HasPeeked { - allPeeked = false - break - } - } - - if allPeeked { - // Set the flag that all players have peeked - g.state.GamePhase = "peeking" - g.state.AllPlayersPeeked = true - } - } - - return nil -} - -// DrawCard draws a card from the deck -func (g *Game) DrawCard(clientID string) error { - g.mu.Lock() - defer g.mu.Unlock() - - if err := g.validateTurn(clientID); err != nil { - return err - } - - if g.state.DrawnCard != nil { - return fmt.Errorf("already have a drawn card") - } - - if len(g.deck) == 0 { - return fmt.Errorf("deck is empty") - } - - g.state.DrawnCard = g.deck[0] - g.deck = g.deck[1:] - g.state.DrawPile = len(g.deck) - g.state.PeekedAtDrawPile = true - - return nil -} - -// TakeFromDiscard takes the top card from the discard pile -func (g *Game) TakeFromDiscard(clientID string) error { - g.mu.Lock() - defer g.mu.Unlock() - - if err := g.validateTurn(clientID); err != nil { - return err - } - - if g.state.DrawnCard != nil { - return fmt.Errorf("already have a drawn card") - } - - if len(g.state.DiscardPile) == 0 { - return fmt.Errorf("discard pile is empty") - } - - if g.state.PeekedAtDrawPile { - return fmt.Errorf("cannot swap for discard after peeking") - } - - // Take the top card - g.state.DrawnCard = g.state.DiscardPile[len(g.state.DiscardPile)-1] - g.state.DiscardPile = g.state.DiscardPile[:len(g.state.DiscardPile)-1] - - return nil -} - -// SwapCard swaps the drawn card with one of the player's cards -func (g *Game) SwapCard(clientID string, cardIndex int) error { - g.mu.Lock() - defer g.mu.Unlock() - - if err := g.validateTurn(clientID); err != nil { - return err - } - - if g.state.DrawnCard == nil { - return fmt.Errorf("no drawn card to swap") - } - - if err := ValidateCardIndex(cardIndex); err != nil { - return err - } - - player := g.playersByClient[clientID] - - // Swap cards - oldCard := player.Cards[cardIndex] - player.Cards[cardIndex] = g.state.DrawnCard - g.state.DrawnCard = nil - - // Add old card to discard pile - if oldCard != nil { - g.state.DiscardPile = append(g.state.DiscardPile, oldCard) - } - - // Update revealed cards if this position was revealed - for _, idx := range player.RevealedCards { - if idx == cardIndex { - player.Score = CalculatePlayerScore(player) - break - } - } - - return g.endTurn(clientID) -} - -// DiscardDrawn discards the drawn card without swapping -func (g *Game) DiscardDrawn(clientID string) error { - g.mu.Lock() - defer g.mu.Unlock() - - if err := g.validateTurn(clientID); err != nil { - return err - } - - if g.state.DrawnCard == nil { - return fmt.Errorf("no drawn card to discard") - } - - // Add drawn card to discard pile - g.state.DiscardPile = append(g.state.DiscardPile, g.state.DrawnCard) - g.state.DrawnCard = nil - - return g.endTurn(clientID) -} - -// Knock signals the last round -func (g *Game) Knock(clientID string) error { - g.mu.Lock() - defer g.mu.Unlock() - - if err := g.validateTurn(clientID); err != nil { - return err - } - - if g.state.DrawnCard != nil { - return fmt.Errorf("cannot knock after drawing") - } - - if g.state.PeekedAtDrawPile { - return fmt.Errorf("cannot knock after peeking") - } - - if g.state.GamePhase == "knocked" { - return fmt.Errorf("someone already knocked") - } - - player := g.playersByClient[clientID] - g.state.KnockedPlayerID = &player.ID - g.state.GamePhase = "knocked" - - // Don't mark the knocking player as having played - they knocked instead of playing - - // Advance to next player - g.state.CurrentPlayerIndex = (g.state.CurrentPlayerIndex + 1) % len(g.state.Players) - - return nil -} - -// GetState returns a copy of the current game state -func (g *Game) GetState() *GameState { - g.mu.RLock() - defer g.mu.RUnlock() - - // Deep copy the state - stateCopy := &GameState{ - ID: g.state.ID, - Players: make([]*Player, len(g.state.Players)), - CurrentPlayerIndex: g.state.CurrentPlayerIndex, - DrawPile: g.state.DrawPile, - DiscardPile: make([]*Card, len(g.state.DiscardPile)), - GamePhase: g.state.GamePhase, - KnockedPlayerID: g.state.KnockedPlayerID, - DrawnCard: g.state.DrawnCard, - PeekedAtDrawPile: g.state.PeekedAtDrawPile, - AllPlayersPeeked: g.state.AllPlayersPeeked, - } - - // Copy players - for i, player := range g.state.Players { - playerCopy := &Player{ - ID: player.ID, - Name: player.Name, - Cards: make([]*Card, 4), - Score: 0, // Hide score during gameplay - RevealedCards: make([]int, len(player.RevealedCards)), - IsReady: player.IsReady, - HasPeeked: player.HasPeeked, - } - - // Only show scores when game has ended - if g.state.GamePhase == "ended" { - playerCopy.Score = player.Score - } - - copy(playerCopy.Cards, player.Cards) - copy(playerCopy.RevealedCards, player.RevealedCards) - stateCopy.Players[i] = playerCopy - } - - // Copy discard pile - copy(stateCopy.DiscardPile, g.state.DiscardPile) - - return stateCopy -} - -// GetPublicState is GetState redacted for an audience with no seat at the -// table (room broadcasts — issue #1187 phase 0): no card faces, no revealed -// indexes, and no held drawn card until the game has ended, at which point -// everything is public anyway. -func (g *Game) GetPublicState() *GameState { - state := g.GetState() - if state.GamePhase == "ended" { - return state - } - state.DrawnCard = nil - for _, player := range state.Players { - player.Cards = make([]*Card, 4) - player.RevealedCards = nil - } - return state -} - -// GetPlayerByClientID returns the player associated with a client ID -func (g *Game) GetPlayerByClientID(clientID string) *Player { - g.mu.RLock() - defer g.mu.RUnlock() - return g.playersByClient[clientID] -} - -// GetStateForPlayer returns a personalized view of the game state for a specific player -func (g *Game) GetStateForPlayer(clientID string) *GameState { - g.mu.RLock() - defer g.mu.RUnlock() - - viewingPlayer := g.playersByClient[clientID] - if viewingPlayer == nil { - return g.GetState() // Fallback to full state if player not found - } - - // Deep copy the state - stateCopy := &GameState{ - ID: g.state.ID, - Players: make([]*Player, len(g.state.Players)), - CurrentPlayerIndex: g.state.CurrentPlayerIndex, - DrawPile: g.state.DrawPile, - DiscardPile: make([]*Card, len(g.state.DiscardPile)), - GamePhase: g.state.GamePhase, - KnockedPlayerID: g.state.KnockedPlayerID, - DrawnCard: nil, // Will be set below only for current player - PeekedAtDrawPile: g.state.PeekedAtDrawPile, - AllPlayersPeeked: g.state.AllPlayersPeeked, - } - - // Only show drawn card to the current player - if g.state.DrawnCard != nil && g.state.Players[g.state.CurrentPlayerIndex].ID == viewingPlayer.ID { - stateCopy.DrawnCard = g.state.DrawnCard - } - - // Copy players with visibility rules - for i, player := range g.state.Players { - playerCopy := &Player{ - ID: player.ID, - Name: player.Name, - Cards: make([]*Card, 4), - Score: 0, // Hide score during gameplay - RevealedCards: make([]int, len(player.RevealedCards)), - IsReady: player.IsReady, - HasPeeked: player.HasPeeked, - } - - // Only show scores when game has ended - if g.state.GamePhase == "ended" { - playerCopy.Score = player.Score - } - - // Only copy card data if it's the viewing player and cards should be shown - if player.ID == viewingPlayer.ID && g.ShouldShowCards(clientID) { - copy(playerCopy.Cards, player.Cards) - copy(playerCopy.RevealedCards, player.RevealedCards) - } else { - // For other players, cards remain nil (hidden) - // and RevealedCards is empty - } - - stateCopy.Players[i] = playerCopy - } - - // Copy discard pile (visible to all) - copy(stateCopy.DiscardPile, g.state.DiscardPile) - - return stateCopy -} - -// validateTurn checks if it's the player's turn -func (g *Game) validateTurn(clientID string) error { - player, exists := g.playersByClient[clientID] - if !exists { - return fmt.Errorf("player not found") - } - - if g.state.GamePhase != "playing" && g.state.GamePhase != "knocked" { - return fmt.Errorf("game not in playing phase") - } - - currentPlayer := g.state.Players[g.state.CurrentPlayerIndex] - if currentPlayer.ID != player.ID { - return fmt.Errorf("not your turn") - } - - return nil -} - -// endTurn advances to the next player's turn -func (g *Game) endTurn(clientID string) error { - player := g.playersByClient[clientID] - - // If in knocked phase, track who has played their final turn - if g.state.GamePhase == "knocked" { - g.finalRoundPlayed[player.ID] = true - - // Check if all OTHER players have had their final turn - // The knocking player doesn't get another turn - allPlayed := true - for _, p := range g.state.Players { - if p.ID != *g.state.KnockedPlayerID && !g.finalRoundPlayed[p.ID] { - allPlayed = false - break - } - } - - if allPlayed { - g.state.GamePhase = "ended" - // Calculate final scores - g.calculateFinalScores() - return nil - } - } - - // An exhausted draw pile ends the game with normal scoring instead of - // wedging the next player on "deck is empty". Issue #1187 phase 0. - if len(g.deck) == 0 { - g.state.GamePhase = "ended" - g.calculateFinalScores() - return nil - } - - // Reset peeked state for next turn - g.state.PeekedAtDrawPile = false - - // Advance to next player - g.state.CurrentPlayerIndex = (g.state.CurrentPlayerIndex + 1) % len(g.state.Players) - return nil -} - -// calculateFinalScores reveals all cards and calculates final scores -func (g *Game) calculateFinalScores() { - for _, player := range g.state.Players { - // Reveal all cards - player.RevealedCards = []int{0, 1, 2, 3} - // Calculate total score with pair cancellation - player.Score = g.calculatePlayerFinalScore(player) - } -} - -// calculatePlayerFinalScore calculates score with pair cancellation -func (g *Game) calculatePlayerFinalScore(player *Player) int { - // Count occurrences of each rank - rankCounts := make(map[string]int) - for i := 0; i < 4; i++ { - if player.Cards[i] != nil { - rankCounts[player.Cards[i].Rank]++ - } - } - - // Pairwise cancellation: each pair of a rank cancels, so only an odd - // remainder scores — and exactly one card of it (three of a kind = one - // pair cancelled + one card counted). Issue #1187 phase 0. - score := 0 - counted := make(map[string]bool) - for i := 0; i < 4; i++ { - if player.Cards[i] != nil { - rank := player.Cards[i].Rank - if rankCounts[rank]%2 == 1 && !counted[rank] { - score += GetCardValue(player.Cards[i]) - counted[rank] = true - } - } - } - - return score -} - -// winnersLocked returns every winner: the knocker alone if the knocker is -// among the lowest scores, otherwise all tied lowest scorers (shared win — -// issue #1187 phase 0). Caller must hold g.mu. -func (g *Game) winnersLocked() []*Player { - if g.state.GamePhase != "ended" || len(g.state.Players) == 0 { - return nil - } - - // Find the minimum score - minScore := g.state.Players[0].Score - for _, player := range g.state.Players[1:] { - if player.Score < minScore { - minScore = player.Score - } - } - - // Get all players with the minimum score - var winners []*Player - for _, player := range g.state.Players { - if player.Score == minScore { - winners = append(winners, player) - } - } - - // Special rule: if the knocker is among the winners, only they win - if g.state.KnockedPlayerID != nil { - for _, winner := range winners { - if winner.ID == *g.state.KnockedPlayerID { - return []*Player{winner} - } - } - } - - return winners -} - -// GetWinners returns all winners (ties are shared wins). -func (g *Game) GetWinners() []*Player { - g.mu.RLock() - defer g.mu.RUnlock() - return g.winnersLocked() -} - -// GetWinner returns the first winner, or nil. Kept for callers that only -// display a single name; GetWinners is the authority on ties. -func (g *Game) GetWinner() *Player { - winners := g.GetWinners() - if len(winners) == 0 { - return nil - } - return winners[0] -} - -// GetFinalScores returns the final scores of all players -func (g *Game) GetFinalScores() []*FinalScore { - g.mu.RLock() - defer g.mu.RUnlock() - - scores := make([]*FinalScore, 0, len(g.state.Players)) - for _, player := range g.state.Players { - scores = append(scores, &FinalScore{ - PlayerName: player.Name, - Score: player.Score, - }) - } - - return scores -} - -// HidePeekedCards hides all peeked cards after countdown -func (g *Game) HidePeekedCards() { - g.mu.Lock() - defer g.mu.Unlock() - - if g.state.GamePhase == "peeking" { - // Hide all cards - for _, player := range g.state.Players { - player.RevealedCards = make([]int, 0) - } - g.state.GamePhase = "playing" - g.state.AllPlayersPeeked = false - } -} - -// ShouldShowCards determines if cards should be shown to a player -func (g *Game) ShouldShowCards(clientID string) bool { - g.mu.RLock() - defer g.mu.RUnlock() - - player := g.playersByClient[clientID] - if player == nil { - return false - } - - // Show revealed cards during initial peeking phase - if len(player.RevealedCards) > 0 { - // Always show during peeking countdown phase - if g.state.GamePhase == "peeking" { - return true - } - // Show during playing phase if we haven't started the countdown yet - if g.state.GamePhase == "playing" && !g.state.AllPlayersPeeked { - return true - } - } - - // Show cards at game end - if g.state.GamePhase == "ended" { - return true - } - - // Show cards when player has drawn a card (about to discard) - if g.state.DrawnCard != nil && g.state.Players[g.state.CurrentPlayerIndex].ID == player.ID { - return true - } - - return false -} - -// ReplaceClient swaps the client ID for a player, used when a player reconnects -// with a new WebSocket connection. -func (g *Game) ReplaceClient(oldClientID, newClientID string) error { - g.mu.Lock() - defer g.mu.Unlock() - - player, exists := g.playersByClient[oldClientID] - if !exists { - return fmt.Errorf("player not found for client %s", oldClientID) - } - - delete(g.playersByClient, oldClientID) - player.ClientID = newClientID - player.IsConnected = true - g.playersByClient[newClientID] = player - - return nil -} - -// GetRoomID returns the room ID this game belongs to -func (g *Game) GetRoomID() string { - g.mu.RLock() - defer g.mu.RUnlock() - return g.roomID -} - -// GetGameResult returns the result of this game for room tracking -func (g *Game) GetGameResult() *GameResult { - winners := g.GetWinners() - if len(winners) == 0 { - return nil - } - names := make([]string, len(winners)) - for i, winner := range winners { - names[i] = winner.Name - } - - return &GameResult{ - GameID: g.state.ID, - // Winner stays the display string ("A & B" on a shared win) so - // existing consumers render ties without changes; Winners is the - // typed list. - Winner: strings.Join(names, " & "), - Winners: names, - FinalScores: g.GetFinalScores(), - CompletedAt: time.Now(), - } -} diff --git a/domains/games/apis/games_ws_backend/golf/game_test.go b/domains/games/apis/games_ws_backend/golf/game_test.go deleted file mode 100644 index c7090711b..000000000 --- a/domains/games/apis/games_ws_backend/golf/game_test.go +++ /dev/null @@ -1,1648 +0,0 @@ -package golf - -import ( - "fmt" - "testing" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -// Helper function to add test players with consistent IDs -func addTestPlayerToGame(g *Game, clientID string) (*Player, error) { - playerID := fmt.Sprintf("TestPlayer%s", clientID) - return g.AddPlayer(clientID, playerID, playerID) -} - -func TestNewGame(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - if game.state.ID != "TEST123" { - t.Errorf("Expected game ID TEST123, got %s", game.state.ID) - } - - if game.state.GamePhase != "waiting" { - t.Errorf("Expected game phase waiting, got %s", game.state.GamePhase) - } - - if len(game.state.Players) != 0 { - t.Errorf("Expected 0 players, got %d", len(game.state.Players)) - } -} - -func TestAddPlayer(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - player1, err := addTestPlayerToGame(game, "client1") - if err != nil { - t.Fatalf("Failed to add player1: %v", err) - } - - if player1.Name != "TestPlayerclient1" { - t.Errorf("Expected TestPlayerclient1, got %s", player1.Name) - } - - player2, err := addTestPlayerToGame(game, "client2") - if err != nil { - t.Fatalf("Failed to add player2: %v", err) - } - - if player2.Name != "TestPlayerclient2" { - t.Errorf("Expected TestPlayerclient2, got %s", player2.Name) - } - - if len(game.state.Players) != 2 { - t.Errorf("Expected 2 players, got %d", len(game.state.Players)) - } -} - -func TestStartGame(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - // Try to start with no players - err := game.StartGame() - if err == nil { - t.Error("Expected error starting game with no players") - } - - // Add players - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - - // Start game - err = game.StartGame() - if err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - if game.state.GamePhase != "playing" { - t.Errorf("Expected game phase playing, got %s", game.state.GamePhase) - } - - // Check cards dealt - for _, player := range game.state.Players { - if len(player.Cards) != 4 { - t.Errorf("Expected 4 cards for player %s, got %d", player.Name, len(player.Cards)) - } - } - - // Check discard pile - if len(game.state.DiscardPile) != 1 { - t.Errorf("Expected 1 card in discard pile, got %d", len(game.state.DiscardPile)) - } -} - -func TestCardValues(t *testing.T) { - tests := []struct { - card *Card - value int - }{ - {&Card{Rank: "A", Suit: "♠"}, 1}, - {&Card{Rank: "2", Suit: "♥"}, 2}, - {&Card{Rank: "9", Suit: "♦"}, 9}, - {&Card{Rank: "10", Suit: "♣"}, 10}, - {&Card{Rank: "J", Suit: "♠"}, 0}, - {&Card{Rank: "Q", Suit: "♥"}, 10}, - {&Card{Rank: "K", Suit: "♦"}, 10}, - } - - for _, test := range tests { - value := GetCardValue(test.card) - if value != test.value { - t.Errorf("Card %s%s: expected value %d, got %d", - test.card.Rank, test.card.Suit, test.value, value) - } - } -} - -func TestGenerateGameID(t *testing.T) { - id := GenerateGameID() - - if len(id) != 6 { - t.Errorf("Expected ID length 6, got %d", len(id)) - } - - // Check all characters are uppercase alphanumeric - for _, char := range id { - if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) { - t.Errorf("Invalid character in game ID: %c", char) - } - } -} - -// Game State Management Tests - -func TestPeekCard(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Test peeking at first card - err := game.PeekCard("client1", 0) - if err != nil { - t.Fatalf("Failed to peek at card: %v", err) - } - - player := game.state.Players[0] - if len(player.RevealedCards) != 1 { - t.Errorf("Expected 1 revealed card, got %d", len(player.RevealedCards)) - } - - // Test peeking at second card - err = game.PeekCard("client1", 2) - if err != nil { - t.Fatalf("Failed to peek at second card: %v", err) - } - - if len(player.RevealedCards) != 2 { - t.Errorf("Expected 2 revealed cards, got %d", len(player.RevealedCards)) - } - - // Test peeking at third card (should fail) - err = game.PeekCard("client1", 3) - if err == nil { - t.Error("Expected error when peeking at third card") - } - - // Test peeking at same card twice - err = game.PeekCard("client1", 0) - if err == nil { - t.Error("Expected error when peeking at same card twice") - } - - // Test invalid card index - err = game.PeekCard("client1", 5) - if err == nil { - t.Error("Expected error with invalid card index") - } -} - -func TestDrawCard(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Test drawing when it's player's turn - deckSize := game.state.DrawPile - err := game.DrawCard("client1") - if err != nil { - t.Fatalf("Failed to draw card: %v", err) - } - - if game.state.DrawnCard == nil { - t.Error("Expected drawn card to be set") - } - - if game.state.DrawPile != deckSize-1 { - t.Errorf("Expected draw pile to decrease by 1") - } - - // Test drawing again (should fail) - err = game.DrawCard("client1") - if err == nil { - t.Error("Expected error when drawing with card already drawn") - } - - // Test drawing when not player's turn - err = game.DrawCard("client2") - if err == nil { - t.Error("Expected error when drawing on wrong turn") - } -} - -func TestTakeFromDiscard(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - discardTop := game.state.DiscardPile[len(game.state.DiscardPile)-1] - - err := game.TakeFromDiscard("client1") - if err != nil { - t.Fatalf("Failed to take from discard: %v", err) - } - - if game.state.DrawnCard == nil { - t.Error("Expected drawn card to be set") - } - - if game.state.DrawnCard.Rank != discardTop.Rank || game.state.DrawnCard.Suit != discardTop.Suit { - t.Error("Drawn card doesn't match top of discard pile") - } - - if len(game.state.DiscardPile) != 0 { - t.Error("Expected discard pile to be empty after taking") - } -} - -func TestSwapCard(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Draw a card first - game.DrawCard("client1") - drawnCard := game.state.DrawnCard - - // Get the card that will be swapped - player := game.state.Players[0] - originalCard := player.Cards[1] - - // Swap with card at index 1 - err := game.SwapCard("client1", 1) - if err != nil { - t.Fatalf("Failed to swap card: %v", err) - } - - // Check swap occurred - if player.Cards[1] != drawnCard { - t.Error("Card was not swapped correctly") - } - - // Check original card is now on discard pile - if len(game.state.DiscardPile) == 0 { - t.Fatal("Discard pile is empty") - } - - topDiscard := game.state.DiscardPile[len(game.state.DiscardPile)-1] - if topDiscard != originalCard { - t.Error("Original card not placed on discard pile") - } - - // Check drawn card is cleared - if game.state.DrawnCard != nil { - t.Error("Drawn card should be cleared after swap") - } - - // Check turn advanced - if game.state.CurrentPlayerIndex != 1 { - t.Error("Turn did not advance to next player") - } -} - -func TestDiscardDrawn(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Draw a card first - game.DrawCard("client1") - drawnCard := game.state.DrawnCard - - // Discard the drawn card - err := game.DiscardDrawn("client1") - if err != nil { - t.Fatalf("Failed to discard drawn card: %v", err) - } - - // Check card is on discard pile - topDiscard := game.state.DiscardPile[len(game.state.DiscardPile)-1] - if topDiscard != drawnCard { - t.Error("Drawn card not placed on discard pile") - } - - // Check drawn card is cleared - if game.state.DrawnCard != nil { - t.Error("Drawn card should be cleared after discard") - } - - // Check turn advanced - if game.state.CurrentPlayerIndex != 1 { - t.Error("Turn did not advance to next player") - } -} - -func TestKnock(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Test knocking - err := game.Knock("client1") - if err != nil { - t.Fatalf("Failed to knock: %v", err) - } - - if game.state.GamePhase != "knocked" { - t.Errorf("Expected game phase 'knocked', got %s", game.state.GamePhase) - } - - if game.state.KnockedPlayerID == nil || *game.state.KnockedPlayerID != "TestPlayerclient1" { - t.Error("Knocked player ID not set correctly") - } - - // Test knocking again (should fail) - err = game.Knock("client2") - if err == nil { - t.Error("Expected error when knocking after someone already knocked") - } - - // Test knocking after drawing (should fail) - game = NewGame("TEST456", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - game.DrawCard("client1") - - err = game.Knock("client1") - if err == nil { - t.Error("Expected error when knocking after drawing") - } -} - -func TestGameEnd(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Player 1 knocks (it's their turn) - err := game.Knock("client1") - if err != nil { - t.Fatalf("Failed to knock: %v", err) - } - - // Turn should advance to player 2 since player 1 knocked - if game.state.CurrentPlayerIndex != 1 { - t.Errorf("Expected current player index 1, got %d", game.state.CurrentPlayerIndex) - } - - // Player 2 takes their final turn - err = game.DrawCard("client2") - if err != nil { - t.Fatalf("Failed to draw card: %v", err) - } - - err = game.DiscardDrawn("client2") - if err != nil { - t.Fatalf("Failed to discard: %v", err) - } - - // Game should be ended - if game.state.GamePhase != "ended" { - t.Errorf("Expected game phase 'ended', got %s", game.state.GamePhase) - } - - // All cards should be revealed - for _, player := range game.state.Players { - if len(player.RevealedCards) != 4 { - t.Errorf("Expected all 4 cards revealed for %s, got %d", - player.Name, len(player.RevealedCards)) - } - } - - // Scores should be calculated - for _, player := range game.state.Players { - if player.Score == 0 { - t.Errorf("Expected non-zero score for %s", player.Name) - } - } -} - -func TestGetWinner(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Manually set up a winning scenario - // Give player 1 low cards (Aces) - game.state.Players[0].Cards = []*Card{ - {Rank: "A", Suit: "♠"}, - {Rank: "A", Suit: "♥"}, - {Rank: "A", Suit: "♦"}, - {Rank: "A", Suit: "♣"}, - } - - // Give player 2 high cards (Kings) - game.state.Players[1].Cards = []*Card{ - {Rank: "K", Suit: "♠"}, - {Rank: "K", Suit: "♥"}, - {Rank: "K", Suit: "♦"}, - {Rank: "K", Suit: "♣"}, - } - - // End the game - game.state.GamePhase = "ended" - game.calculateFinalScores() - - winner := game.GetWinner() - if winner == nil { - t.Fatal("No winner returned") - } - - if winner.Name != "TestPlayerclient1" { - t.Errorf("Expected TestPlayerclient1 to win, got %s", winner.Name) - } - - if winner.Score != 0 { // 4 Aces = 2 pairs that cancel out = 0 points - t.Errorf("Expected winner score 0, got %d", winner.Score) - } -} - -func TestRemovePlayer(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - - if len(game.state.Players) != 3 { - t.Fatalf("Expected 3 players, got %d", len(game.state.Players)) - } - - // Remove player 2 - err := game.RemovePlayer("client2") - if err != nil { - t.Fatalf("Failed to remove player: %v", err) - } - - if len(game.state.Players) != 2 { - t.Errorf("Expected 2 players after removal, got %d", len(game.state.Players)) - } - - // Check remaining players - for _, player := range game.state.Players { - if player.Name == "Player 2" { - t.Error("Player 2 should have been removed") - } - } - - // Try to remove non-existent player - err = game.RemovePlayer("client999") - if err == nil { - t.Error("Expected error when removing non-existent player") - } -} - -func TestRemovePlayerEndsGameWhenTooFewPlayers(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - if game.state.GamePhase != "playing" { - t.Fatalf("Expected playing phase, got %s", game.state.GamePhase) - } - - // Remove a player during an active game — should end it - err := game.RemovePlayer("client2") - if err != nil { - t.Fatalf("Failed to remove player: %v", err) - } - - if game.state.GamePhase != "ended" { - t.Errorf("Expected ended phase after removal left <2 players, got %s", game.state.GamePhase) - } - - // Remaining player should have final scores calculated - if len(game.state.Players) != 1 { - t.Fatalf("Expected 1 player remaining, got %d", len(game.state.Players)) - } - if len(game.state.Players[0].RevealedCards) != 4 { - t.Errorf("Expected all 4 cards revealed, got %d", len(game.state.Players[0].RevealedCards)) - } -} - -func TestRemovePlayerDuringKnockedPhaseEndsGame(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - game.StartGame() - - // Complete peek phase for all players, then hide to return to playing - game.PeekCard("client1", 0) - game.PeekCard("client1", 1) - game.PeekCard("client2", 0) - game.PeekCard("client2", 1) - game.PeekCard("client3", 0) - game.PeekCard("client3", 1) - game.HidePeekedCards() - - // Player 1 knocks (must be at start of turn, before drawing) - if err := game.Knock("client1"); err != nil { - t.Fatalf("Failed to knock: %v", err) - } - - if game.state.GamePhase != "knocked" { - t.Fatalf("Expected knocked phase, got %s", game.state.GamePhase) - } - - // Remove both non-knocking players — should end the game - game.RemovePlayer("client2") - if game.state.GamePhase != "knocked" { - t.Fatalf("Expected still knocked with 2 players, got %s", game.state.GamePhase) - } - - game.RemovePlayer("client3") - if game.state.GamePhase != "ended" { - t.Errorf("Expected ended phase after removal left <2 players, got %s", game.state.GamePhase) - } -} - -func TestRemovePlayerDuringWaitingDoesNotEnd(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - - // Remove during waiting — game hasn't started, shouldn't transition to ended - err := game.RemovePlayer("client2") - if err != nil { - t.Fatalf("Failed to remove player: %v", err) - } - - if game.state.GamePhase != "waiting" { - t.Errorf("Expected waiting phase, got %s", game.state.GamePhase) - } -} - -func TestRemoveCurrentPlayerAdvancesTurn(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - game.StartGame() - - // Complete peek phase for all players, then hide to return to playing - game.PeekCard("client1", 0) - game.PeekCard("client1", 1) - game.PeekCard("client2", 0) - game.PeekCard("client2", 1) - game.PeekCard("client3", 0) - game.PeekCard("client3", 1) - game.HidePeekedCards() - - // It's client1's turn (index 0). Remove client1. - if game.state.CurrentPlayerIndex != 0 { - t.Fatalf("Expected current player index 0, got %d", game.state.CurrentPlayerIndex) - } - - game.RemovePlayer("client1") - - // Game should still be playing with 2 players - if game.state.GamePhase != "playing" { - t.Fatalf("Expected playing phase, got %s", game.state.GamePhase) - } - if len(game.state.Players) != 2 { - t.Fatalf("Expected 2 players, got %d", len(game.state.Players)) - } - - // CurrentPlayerIndex should be valid (0, pointing to what was client2) - if game.state.CurrentPlayerIndex >= len(game.state.Players) { - t.Errorf("CurrentPlayerIndex %d out of bounds for %d players", - game.state.CurrentPlayerIndex, len(game.state.Players)) - } -} - -func TestValidateCardIndex(t *testing.T) { - tests := []struct { - index int - wantErr bool - }{ - {0, false}, - {1, false}, - {2, false}, - {3, false}, - {4, true}, - {-1, true}, - {10, true}, - } - - for _, tt := range tests { - err := ValidateCardIndex(tt.index) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateCardIndex(%d) error = %v, wantErr %v", - tt.index, err, tt.wantErr) - } - } -} - -func TestTurnValidation(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Player 2 tries to draw (not their turn) - err := game.DrawCard("client2") - if err == nil { - t.Error("Expected error when player 2 draws on player 1's turn") - } - - // Player 1 draws (their turn) - err = game.DrawCard("client1") - if err != nil { - t.Errorf("Player 1 should be able to draw on their turn: %v", err) - } - - // Complete player 1's turn - game.DiscardDrawn("client1") - - // Now player 2 should be able to draw - err = game.DrawCard("client2") - if err != nil { - t.Errorf("Player 2 should be able to draw on their turn: %v", err) - } -} - -func TestGamePhaseValidation(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - - // Try to draw before game starts - err := game.DrawCard("client1") - if err == nil { - t.Error("Expected error when drawing before game starts") - } - - // Try to peek before game starts - err = game.PeekCard("client1", 0) - if err == nil { - t.Error("Expected error when peeking before game starts") - } - - // Start game with only one player (should fail) - err = game.StartGame() - if err == nil { - t.Error("Expected error starting game with only one player") - } - - // Add second player and start - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Now drawing should work - err = game.DrawCard("client1") - if err != nil { - t.Errorf("Should be able to draw after game starts: %v", err) - } -} - -func TestMaxPlayers(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - // Add 4 players (max) - for i := 0; i < 4; i++ { - _, err := addTestPlayerToGame(game, string(rune('a'+i))) - if err != nil { - t.Fatalf("Failed to add player %d: %v", i+1, err) - } - } - - // Try to add 5th player - _, err := addTestPlayerToGame(game, "client5") - if err == nil { - t.Error("Expected error when adding 5th player") - } -} - -func TestGetState(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Get state copy - stateCopy := game.GetState() - - // Verify it's a copy by modifying it - stateCopy.CurrentPlayerIndex = 99 - - // Original should be unchanged - if game.state.CurrentPlayerIndex == 99 { - t.Error("GetState returned reference instead of copy") - } - - // Verify player data is copied - if len(stateCopy.Players) != len(game.state.Players) { - t.Error("Player count mismatch in state copy") - } - - // Modify player in copy - if len(stateCopy.Players) > 0 { - stateCopy.Players[0].Name = "Modified" - if game.state.Players[0].Name == "Modified" { - t.Error("Player data not properly copied") - } - } -} - -// Concurrent Access Tests - -func TestConcurrentPlayerJoins(t *testing.T) { - game := NewGame("CONCURRENT1", &players.DeterministicIDGenerator{}) - - // Try to add 10 players concurrently (should only allow 4) - type result struct { - player *Player - err error - } - - results := make(chan result, 10) - - for i := 0; i < 10; i++ { - go func(clientID string) { - player, err := addTestPlayerToGame(game, clientID) - results <- result{player, err} - }(string(rune('a' + i))) - } - - // Collect results - successCount := 0 - for i := 0; i < 10; i++ { - res := <-results - if res.err == nil { - successCount++ - } - } - - // Should have exactly 4 successful joins - if successCount != 4 { - t.Errorf("Expected 4 successful joins, got %d", successCount) - } - - // Verify game has exactly 4 players - if len(game.state.Players) != 4 { - t.Errorf("Expected 4 players in game, got %d", len(game.state.Players)) - } -} - -func TestConcurrentGameActions(t *testing.T) { - game := NewGame("CONCURRENT2", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Multiple goroutines trying to perform actions - done := make(chan bool, 5) - - // Goroutine 1: Try to draw card - go func() { - game.DrawCard("client1") - done <- true - }() - - // Goroutine 2: Try to peek cards - go func() { - game.PeekCard("client1", 0) - done <- true - }() - - // Goroutine 3: Try to peek another card - go func() { - game.PeekCard("client1", 1) - done <- true - }() - - // Goroutine 4: Try to draw (wrong turn) - go func() { - game.DrawCard("client2") - done <- true - }() - - // Goroutine 5: Get state - go func() { - state := game.GetState() - if state == nil { - t.Error("GetState returned nil during concurrent access") - } - done <- true - }() - - // Wait for all goroutines - for i := 0; i < 5; i++ { - <-done - } - - // Verify game state is consistent - state := game.GetState() - if state == nil { - t.Fatal("Game state is nil") - } - - // Should have drawn card or not, but state should be consistent - player1 := state.Players[0] - if len(player1.RevealedCards) > 2 { - t.Error("Player peeked at more than 2 cards") - } -} - -func TestConcurrentStateReads(t *testing.T) { - game := NewGame("CONCURRENT3", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Many concurrent reads - done := make(chan bool, 100) - - for i := 0; i < 100; i++ { - go func() { - state := game.GetState() - if state == nil { - t.Error("GetState returned nil") - } - if len(state.Players) != 2 { - t.Error("Inconsistent player count") - } - done <- true - }() - } - - // Wait for all reads - for i := 0; i < 100; i++ { - <-done - } -} - -func TestConcurrentTurnActions(t *testing.T) { - game := NewGame("CONCURRENT4", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Both players try to draw at the same time - errors := make(chan error, 2) - - go func() { - err := game.DrawCard("client1") - errors <- err - }() - - go func() { - err := game.DrawCard("client2") - errors <- err - }() - - // Collect results - err1 := <-errors - err2 := <-errors - - // Exactly one should succeed (player 1's turn) - if err1 == nil && err2 == nil { - t.Error("Both players drew cards - turn validation failed") - } - - if err1 != nil && err2 != nil { - t.Error("Neither player could draw - expected player 1 to succeed") - } - - // Verify only one card was drawn - if game.state.DrawnCard == nil { - t.Error("No card was drawn") - } -} - -func TestConcurrentKnocking(t *testing.T) { - game := NewGame("CONCURRENT5", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - game.StartGame() - - // Multiple players try to knock simultaneously - results := make(chan error, 3) - - go func() { - results <- game.Knock("client1") - }() - - go func() { - // Player 2 draws first (not their turn, should fail) - game.DrawCard("client2") - results <- game.Knock("client2") - }() - - go func() { - // Player 3 just tries to knock - results <- game.Knock("client3") - }() - - // Collect results - successCount := 0 - for i := 0; i < 3; i++ { - if err := <-results; err == nil { - successCount++ - } - } - - // Only one player should successfully knock - if successCount != 1 { - t.Errorf("Expected 1 successful knock, got %d", successCount) - } - - // Verify game is in knocked phase - if game.state.GamePhase != "knocked" { - t.Errorf("Expected game phase 'knocked', got %s", game.state.GamePhase) - } - - // Verify knocked player is set - if game.state.KnockedPlayerID == nil { - t.Error("Knocked player ID not set") - } -} - -func TestScoreVisibilityDuringGame(t *testing.T) { - game := NewGame("TEST_SCORE", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Get state for player 1 during gameplay - state := game.GetStateForPlayer("client1") - - // All players should have score 0 during gameplay - for _, player := range state.Players { - if player.Score != 0 { - t.Errorf("Player %s has score %d during gameplay, expected 0", player.Name, player.Score) - } - } - - // Player 1 peeks at cards - game.PeekCard("client1", 0) - game.PeekCard("client1", 1) - - // Get state again - scores should still be hidden - state = game.GetStateForPlayer("client1") - for _, player := range state.Players { - if player.Score != 0 { - t.Errorf("Player %s has score %d after peeking, expected 0", player.Name, player.Score) - } - } - - // Player 1 knocks - game.Knock("client1") - - // Player 2 takes final turn - game.DrawCard("client2") - game.DiscardDrawn("client2") - - // Game should be ended now - if game.state.GamePhase != "ended" { - t.Fatalf("Expected game to be ended, but phase is %s", game.state.GamePhase) - } - - // Get state after game end - scores should now be visible - state = game.GetStateForPlayer("client1") - hasNonZeroScore := false - for _, player := range state.Players { - if player.Score != 0 { - hasNonZeroScore = true - } - } - - if !hasNonZeroScore { - t.Error("All scores are still 0 after game ended - scores should be visible") - } - - // Verify internal state has actual scores - for _, player := range game.state.Players { - if player.Score == 0 { - // This might be legitimate if they have pairs that cancel, but unlikely for both players - t.Logf("Warning: Player %s has score 0 in internal state", player.Name) - } - } -} - -func TestRaceConditionProtection(t *testing.T) { - game := NewGame("RACE1", &players.DeterministicIDGenerator{}) - - // Start many operations concurrently - done := make(chan bool, 20) - - // Add players - for i := 0; i < 5; i++ { - go func(id string) { - addTestPlayerToGame(game, id) - done <- true - }(string(rune('a' + i))) - } - - // Try to start game multiple times - for i := 0; i < 5; i++ { - go func() { - game.StartGame() - done <- true - }() - } - - // Try to remove players - for i := 0; i < 5; i++ { - go func(id string) { - game.RemovePlayer(id) - done <- true - }(string(rune('a' + i))) - } - - // Get state many times - for i := 0; i < 5; i++ { - go func() { - game.GetState() - done <- true - }() - } - - // Wait for all operations - for i := 0; i < 20; i++ { - <-done - } - - // Game should still be in a valid state - state := game.GetState() - if state == nil { - t.Fatal("Game state is nil after concurrent operations") - } - - // Player count should be reasonable (0-4) - if len(state.Players) > 4 { - t.Errorf("Too many players: %d", len(state.Players)) - } -} - -func TestDrawnCardPrivacy(t *testing.T) { - game := NewGame("TEST_PRIVACY", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - game.StartGame() - - // Player 1 draws a card - err := game.DrawCard("client1") - if err != nil { - t.Fatalf("Failed to draw card: %v", err) - } - - // Verify that the drawn card exists in the game state - if game.state.DrawnCard == nil { - t.Fatal("No drawn card in game state after drawing") - } - - // Get the actual drawn card for comparison - actualDrawnCard := game.state.DrawnCard - - // Get state for player 1 (who drew the card) - stateForPlayer1 := game.GetStateForPlayer("client1") - if stateForPlayer1.DrawnCard == nil { - t.Error("Player 1 should see the drawn card") - } - if stateForPlayer1.DrawnCard != actualDrawnCard { - t.Error("Player 1 should see the correct drawn card") - } - - // Get state for player 2 (who did not draw) - stateForPlayer2 := game.GetStateForPlayer("client2") - if stateForPlayer2.DrawnCard != nil { - t.Errorf("Player 2 should NOT see the drawn card, but sees: %v", stateForPlayer2.DrawnCard) - } - - // Get state for player 3 (who did not draw) - stateForPlayer3 := game.GetStateForPlayer("client3") - if stateForPlayer3.DrawnCard != nil { - t.Errorf("Player 3 should NOT see the drawn card, but sees: %v", stateForPlayer3.DrawnCard) - } - - // Complete the turn - err = game.DiscardDrawn("client1") - if err != nil { - t.Fatalf("Failed to discard drawn card: %v", err) - } - - // Now it's player 2's turn, they draw a card - err = game.DrawCard("client2") - if err != nil { - t.Fatalf("Failed to draw card for player 2: %v", err) - } - - // Verify the new drawn card exists - if game.state.DrawnCard == nil { - t.Fatal("No drawn card in game state after player 2 draws") - } - newDrawnCard := game.state.DrawnCard - - // Get state for each player again - stateForPlayer1 = game.GetStateForPlayer("client1") - stateForPlayer2 = game.GetStateForPlayer("client2") - stateForPlayer3 = game.GetStateForPlayer("client3") - - // Player 1 should NOT see the card drawn by player 2 - if stateForPlayer1.DrawnCard != nil { - t.Errorf("Player 1 should NOT see the card drawn by player 2, but sees: %v", stateForPlayer1.DrawnCard) - } - - // Player 2 should see their own drawn card - if stateForPlayer2.DrawnCard == nil { - t.Error("Player 2 should see their own drawn card") - } - if stateForPlayer2.DrawnCard != newDrawnCard { - t.Error("Player 2 should see the correct drawn card") - } - - // Player 3 should NOT see the card drawn by player 2 - if stateForPlayer3.DrawnCard != nil { - t.Errorf("Player 3 should NOT see the card drawn by player 2, but sees: %v", stateForPlayer3.DrawnCard) - } - - // Test with taking from discard pile - err = game.DiscardDrawn("client2") - if err != nil { - t.Fatalf("Failed to discard for player 2: %v", err) - } - - // Player 3's turn - they take from discard - err = game.TakeFromDiscard("client3") - if err != nil { - t.Fatalf("Failed to take from discard: %v", err) - } - - // Verify drawn card privacy when taken from discard - stateForPlayer1 = game.GetStateForPlayer("client1") - stateForPlayer2 = game.GetStateForPlayer("client2") - stateForPlayer3 = game.GetStateForPlayer("client3") - - // Only player 3 should see the drawn card - if stateForPlayer1.DrawnCard != nil { - t.Error("Player 1 should NOT see card taken from discard by player 3") - } - if stateForPlayer2.DrawnCard != nil { - t.Error("Player 2 should NOT see card taken from discard by player 3") - } - if stateForPlayer3.DrawnCard == nil { - t.Error("Player 3 should see the card they took from discard") - } -} - -// Double Join Prevention Tests - -func TestAddPlayerTwice(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - // Add player once - player1, err := addTestPlayerToGame(game, "client1") - if err != nil { - t.Fatalf("Failed to add player1: %v", err) - } - - if player1.Name != "TestPlayerclient1" { - t.Errorf("Expected TestPlayerclient1, got %s", player1.Name) - } - - if len(game.state.Players) != 1 { - t.Errorf("Expected 1 player, got %d", len(game.state.Players)) - } - - // Try to add the same player again (should fail) - _, err = addTestPlayerToGame(game, "client1") - if err == nil { - t.Error("Expected error when adding the same player twice") - } - - // Verify player count hasn't changed - if len(game.state.Players) != 1 { - t.Errorf("Expected still 1 player after double-join attempt, got %d", len(game.state.Players)) - } - - // Verify the error message indicates already joined - if err != nil && err.Error() != "player already in game" { - t.Errorf("Expected 'player already in game' error, got: %s", err.Error()) - } -} - -func TestAddPlayerTwiceAfterGameStarted(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - // Add two players - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - - // Start the game - err := game.StartGame() - if err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Try to add a player who's already in the game (should fail with "already started") - _, err = addTestPlayerToGame(game, "client1") - if err == nil { - t.Error("Expected error when adding player to already started game") - } - - if err.Error() != "game already started" { - t.Errorf("Expected 'game already started' error, got: %s", err.Error()) - } -} - -// Phase 0 rules fixes (issue #1187): pair scoring counts exactly one card of -// an odd-remainder rank, an exhausted deck ends the game with normal scoring, -// and non-knocker ties are shared wins. - -func TestFinalScorePairCancellation(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - cases := []struct { - name string - cards []*Card - expected int - }{ - { - name: "three of a kind scores exactly one card", - cards: []*Card{ - {Rank: "K", Suit: "♠"}, - {Rank: "K", Suit: "♥"}, - {Rank: "K", Suit: "♦"}, - {Rank: "A", Suit: "♣"}, - }, - expected: 11, // one K (pair cancelled) + A - }, - { - name: "two pairs cancel to zero", - cards: []*Card{ - {Rank: "5", Suit: "♠"}, - {Rank: "5", Suit: "♥"}, - {Rank: "9", Suit: "♦"}, - {Rank: "9", Suit: "♣"}, - }, - expected: 0, - }, - { - name: "one pair cancels, rest count", - cards: []*Card{ - {Rank: "7", Suit: "♠"}, - {Rank: "7", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, - {Rank: "Q", Suit: "♣"}, - }, - expected: 13, // 3 + Q - }, - { - name: "no pairs, everything counts", - cards: []*Card{ - {Rank: "2", Suit: "♠"}, - {Rank: "4", Suit: "♥"}, - {Rank: "6", Suit: "♦"}, - {Rank: "8", Suit: "♣"}, - }, - expected: 20, - }, - { - name: "four of a kind cancels to zero", - cards: []*Card{ - {Rank: "Q", Suit: "♠"}, - {Rank: "Q", Suit: "♥"}, - {Rank: "Q", Suit: "♦"}, - {Rank: "Q", Suit: "♣"}, - }, - expected: 0, - }, - { - name: "three of a kind with a jack", - cards: []*Card{ - {Rank: "K", Suit: "♠"}, - {Rank: "K", Suit: "♥"}, - {Rank: "K", Suit: "♦"}, - {Rank: "J", Suit: "♣"}, - }, - expected: 10, // one K; the jack is worth 0 - }, - { - name: "unpaired jacks are worth zero", - cards: []*Card{ - {Rank: "J", Suit: "♠"}, - {Rank: "2", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, - {Rank: "A", Suit: "♣"}, - }, - expected: 6, // 2 + 3 + A - }, - { - name: "nil card slot is skipped, pair still cancels", - cards: []*Card{ - {Rank: "K", Suit: "♠"}, - {Rank: "K", Suit: "♥"}, - nil, - {Rank: "5", Suit: "♣"}, - }, - expected: 5, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - player := &Player{Cards: tc.cards} - if got := game.calculatePlayerFinalScore(player); got != tc.expected { - t.Errorf("Expected score %d, got %d", tc.expected, got) - } - }) - } -} - -func TestEmptyDeckEndsGame(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Leave exactly one card so player 1's draw exhausts the deck. - game.deck = game.deck[:1] - - if err := game.DrawCard("client1"); err != nil { - t.Fatalf("Failed to draw last card: %v", err) - } - if err := game.DiscardDrawn("client1"); err != nil { - t.Fatalf("Failed to discard: %v", err) - } - - if game.state.GamePhase != "ended" { - t.Fatalf("Expected empty deck to end the game, got phase %s", game.state.GamePhase) - } - - // Scoring ran: all cards revealed and winners determined without a knock. - for _, player := range game.state.Players { - if len(player.RevealedCards) != 4 { - t.Errorf("Expected all 4 cards revealed for %s, got %d", - player.Name, len(player.RevealedCards)) - } - } - if len(game.GetWinners()) == 0 { - t.Error("Expected winners after empty-deck game end") - } -} - -func TestSharedWinOnNonKnockerTie(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - game.StartGame() - - // Players 1 and 2 tie at zero; player 3 knocked but scores higher. - game.state.Players[0].Cards = []*Card{ - {Rank: "A", Suit: "♠"}, {Rank: "A", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, {Rank: "3", Suit: "♣"}, - } - game.state.Players[1].Cards = []*Card{ - {Rank: "5", Suit: "♠"}, {Rank: "5", Suit: "♥"}, - {Rank: "9", Suit: "♦"}, {Rank: "9", Suit: "♣"}, - } - game.state.Players[2].Cards = []*Card{ - {Rank: "K", Suit: "♠"}, {Rank: "Q", Suit: "♥"}, - {Rank: "8", Suit: "♦"}, {Rank: "2", Suit: "♣"}, - } - game.state.KnockedPlayerID = &game.state.Players[2].ID - game.state.GamePhase = "ended" - game.calculateFinalScores() - - winners := game.GetWinners() - if len(winners) != 2 { - t.Fatalf("Expected 2 shared winners, got %d", len(winners)) - } - if winners[0].Name != "TestPlayerclient1" || winners[1].Name != "TestPlayerclient2" { - t.Errorf("Expected players 1 and 2 to share the win, got %s and %s", - winners[0].Name, winners[1].Name) - } - - result := game.GetGameResult() - if result == nil { - t.Fatal("Expected a game result") - } - if result.Winner != "TestPlayerclient1 & TestPlayerclient2" { - t.Errorf("Expected joined display winner, got %q", result.Winner) - } - if len(result.Winners) != 2 { - t.Errorf("Expected 2 entries in Winners, got %d", len(result.Winners)) - } -} - -func TestKnockerAmongTiedWinsAlone(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Both players tie at zero, but player 2 knocked and takes the win solo. - game.state.Players[0].Cards = []*Card{ - {Rank: "A", Suit: "♠"}, {Rank: "A", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, {Rank: "3", Suit: "♣"}, - } - game.state.Players[1].Cards = []*Card{ - {Rank: "5", Suit: "♠"}, {Rank: "5", Suit: "♥"}, - {Rank: "9", Suit: "♦"}, {Rank: "9", Suit: "♣"}, - } - game.state.KnockedPlayerID = &game.state.Players[1].ID - game.state.GamePhase = "ended" - game.calculateFinalScores() - - winners := game.GetWinners() - if len(winners) != 1 { - t.Fatalf("Expected knocker to win alone, got %d winners", len(winners)) - } - if winners[0].Name != "TestPlayerclient2" { - t.Errorf("Expected TestPlayerclient2 (knocker) to win, got %s", winners[0].Name) - } - - result := game.GetGameResult() - if result.Winner != "TestPlayerclient2" { - t.Errorf("Expected single-name display winner, got %q", result.Winner) - } - if len(result.Winners) != 1 { - t.Errorf("Expected 1 entry in Winners, got %d", len(result.Winners)) - } -} - -func TestGetPublicStateRedactsInProgressGame(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - if err := game.DrawCard("client1"); err != nil { - t.Fatalf("Failed to draw: %v", err) - } - - public := game.GetPublicState() - if public.DrawnCard != nil { - t.Error("Public state must not expose the held drawn card") - } - for _, player := range public.Players { - for i, card := range player.Cards { - if card != nil { - t.Errorf("Public state must not expose %s's card %d", player.Name, i) - } - } - if len(player.RevealedCards) != 0 { - t.Errorf("Public state must not expose %s's revealed indexes", player.Name) - } - } - - // After the game ends everything is public again. - game.state.GamePhase = "ended" - game.calculateFinalScores() - ended := game.GetPublicState() - for _, player := range ended.Players { - for i, card := range player.Cards { - if card == nil { - t.Errorf("Ended game should expose %s's card %d", player.Name, i) - } - } - } -} - -func TestDeckNotEmptyDoesNotEndGame(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Two cards left: the draw leaves one, which must NOT end the game. - game.deck = game.deck[:2] - - if err := game.DrawCard("client1"); err != nil { - t.Fatalf("Failed to draw: %v", err) - } - if err := game.DiscardDrawn("client1"); err != nil { - t.Fatalf("Failed to discard: %v", err) - } - - if game.state.GamePhase != "playing" { - t.Errorf("Game must continue with cards in the deck, got phase %s", game.state.GamePhase) - } - if game.state.CurrentPlayerIndex != 1 { - t.Errorf("Turn should advance to player 2, got index %d", game.state.CurrentPlayerIndex) - } - if game.GetWinners() != nil { - t.Error("No winners while the game is still in progress") - } -} - -func TestEmptyDeckEndsGameViaDiscardPath(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Deck already exhausted; the turn ends via take-from-discard + swap, - // which must also trigger the empty-deck game end. - game.deck = nil - - if err := game.TakeFromDiscard("client1"); err != nil { - t.Fatalf("Failed to take from discard: %v", err) - } - if err := game.SwapCard("client1", 0); err != nil { - t.Fatalf("Failed to swap: %v", err) - } - - if game.state.GamePhase != "ended" { - t.Fatalf("Expected empty deck to end the game after swap, got phase %s", game.state.GamePhase) - } - if len(game.GetWinners()) == 0 { - t.Error("Expected winners after empty-deck game end") - } -} - -func TestEmptyDeckDuringKnockedPhaseEndsEarly(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Player 1 knocks; player 2 draws the last card. The game ends on deck - // exhaustion even though player 3 never got a final turn. - if err := game.Knock("client1"); err != nil { - t.Fatalf("Failed to knock: %v", err) - } - game.deck = game.deck[:1] - - if err := game.DrawCard("client2"); err != nil { - t.Fatalf("Failed to draw: %v", err) - } - if err := game.DiscardDrawn("client2"); err != nil { - t.Fatalf("Failed to discard: %v", err) - } - - if game.state.GamePhase != "ended" { - t.Fatalf("Expected game to end when the deck ran out mid final round, got phase %s", - game.state.GamePhase) - } - if len(game.GetWinners()) == 0 { - t.Error("Expected winners after the deck ran out") - } -} - -func TestNoWinnersOrResultBeforeGameEnds(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - - // Waiting phase: no players dealt, nothing to win. - if game.GetWinners() != nil { - t.Error("GetWinners must be nil in waiting phase") - } - if game.GetWinner() != nil { - t.Error("GetWinner must be nil in waiting phase") - } - if game.GetGameResult() != nil { - t.Error("GetGameResult must be nil in waiting phase") - } - - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - game.StartGame() - - // Playing phase: still nothing. - if game.GetWinners() != nil { - t.Error("GetWinners must be nil while the game is in progress") - } - if game.GetGameResult() != nil { - t.Error("GetGameResult must be nil while the game is in progress") - } -} - -func TestSoloWinnerIsNotShared(t *testing.T) { - game := NewGame("TEST123", &players.DeterministicIDGenerator{}) - addTestPlayerToGame(game, "client1") - addTestPlayerToGame(game, "client2") - addTestPlayerToGame(game, "client3") - game.StartGame() - - // Distinct scores: exactly one winner, no spurious sharing. - game.state.Players[0].Cards = []*Card{ - {Rank: "A", Suit: "♠"}, {Rank: "2", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, {Rank: "4", Suit: "♣"}, // 10 - } - game.state.Players[1].Cards = []*Card{ - {Rank: "5", Suit: "♠"}, {Rank: "6", Suit: "♥"}, - {Rank: "7", Suit: "♦"}, {Rank: "8", Suit: "♣"}, // 26 - } - game.state.Players[2].Cards = []*Card{ - {Rank: "K", Suit: "♠"}, {Rank: "Q", Suit: "♥"}, - {Rank: "10", Suit: "♦"}, {Rank: "9", Suit: "♣"}, // 39 - } - game.state.GamePhase = "ended" - game.calculateFinalScores() - - winners := game.GetWinners() - if len(winners) != 1 { - t.Fatalf("Expected exactly 1 winner, got %d", len(winners)) - } - if winners[0].Name != "TestPlayerclient1" { - t.Errorf("Expected TestPlayerclient1 to win, got %s", winners[0].Name) - } - - result := game.GetGameResult() - if result.Winner != "TestPlayerclient1" { - t.Errorf("Solo win must not be a joined string, got %q", result.Winner) - } - if len(result.Winners) != 1 || result.Winners[0] != "TestPlayerclient1" { - t.Errorf("Expected Winners == [TestPlayerclient1], got %v", result.Winners) - } -} diff --git a/domains/games/apis/games_ws_backend/golf/golf_hub.go b/domains/games/apis/games_ws_backend/golf/golf_hub.go deleted file mode 100644 index 62c350a7c..000000000 --- a/domains/games/apis/games_ws_backend/golf/golf_hub.go +++ /dev/null @@ -1,1748 +0,0 @@ -package golf - -import ( - "encoding/json" - "fmt" - "log/slog" - "strings" - "sync" - "time" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/hub" - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -const ( - defaultGracePeriod = 5 * time.Minute - defaultTokenTTL = 24 * time.Hour -) - -// DisconnectedSession holds state for a player who has disconnected but may reconnect. -type DisconnectedSession struct { - PlayerID string - ClientID string // the clientID used in room/game player lookups - RoomID string - GameID string - DisconnectedAt time.Time -} - -// GolfHub maintains active rooms and routes messages -type GolfHub struct { - // Active rooms mapped by room ID - rooms map[string]*Room - - // Client contexts mapping (authenticated clients only) - clientContexts map[*hub.Client]*ClientContext - - // playerToClient maps playerID → active client (for reverse lookup) - playerToClient map[string]*hub.Client - - // disconnectedSessions maps playerID → session state for reconnection - disconnectedSessions map[string]*DisconnectedSession - - // pendingAuth tracks clients that have connected but not yet authenticated - pendingAuth map[*hub.Client]bool - - // Token manager for JWT creation/validation - tokenManager *TokenManager - - // Player ID generator - idGenerator players.PlayerIDGenerator - - // Grace period before cleaning up disconnected sessions - gracePeriod time.Duration - - // Mutex for thread safety - mu sync.RWMutex - - // Channels from hub interface - gameMessage chan hub.GameMessageData - register chan *hub.Client - unregister chan *hub.Client - cleanup chan string // playerID to clean up after grace period -} - -// NewGolfHub creates a new golf hub instance -func NewGolfHub(idGenerator players.PlayerIDGenerator) hub.Hub { - return &GolfHub{ - rooms: make(map[string]*Room), - clientContexts: make(map[*hub.Client]*ClientContext), - playerToClient: make(map[string]*hub.Client), - disconnectedSessions: make(map[string]*DisconnectedSession), - pendingAuth: make(map[*hub.Client]bool), - tokenManager: NewTokenManager(), - idGenerator: idGenerator, - gracePeriod: defaultGracePeriod, - gameMessage: make(chan hub.GameMessageData), - register: make(chan *hub.Client), - unregister: make(chan *hub.Client), - cleanup: make(chan string, 16), - } -} - -// Register handles client registration -func (h *GolfHub) Register(c *hub.Client) { - h.register <- c -} - -// Unregister handles client unregistration -func (h *GolfHub) Unregister(c *hub.Client) { - h.unregister <- c -} - -// GameMessage handles incoming game messages -func (h *GolfHub) GameMessage(data hub.GameMessageData) { - h.gameMessage <- data -} - -// Run starts the hub's main event loop -func (h *GolfHub) Run() { - for { - select { - case client := <-h.register: - h.handleRegister(client) - - case client := <-h.unregister: - h.handleUnregister(client) - - case msgData := <-h.gameMessage: - h.handleGameMessage(msgData) - - case playerID := <-h.cleanup: - h.handleCleanupSession(playerID) - } - } -} - -// handleRegister processes client registration. -// The client is added to pendingAuth and must send an authenticate message -// before any other messages will be accepted. -func (h *GolfHub) handleRegister(client *hub.Client) { - h.mu.Lock() - h.pendingAuth[client] = true - h.mu.Unlock() - - slog.Info("Golf client connected, awaiting authentication", - "clientAddr", getClientAddr(client)) -} - -// handleUnregister processes client disconnection. -// Instead of removing the player from games, we preserve their session -// for a grace period to allow reconnection. -func (h *GolfHub) handleUnregister(client *hub.Client) { - var roomToUpdate *Room - - func() { - h.mu.Lock() - defer h.mu.Unlock() - - // Handle pending auth clients (never authenticated) - if h.pendingAuth[client] { - delete(h.pendingAuth, client) - close(client.Send) - slog.Info("Unauthenticated client disconnected", - "clientAddr", getClientAddr(client)) - return - } - - ctx, ok := h.clientContexts[client] - if !ok { - // Client not in our maps - might have been force-disconnected during reconnect - return - } - - playerID := ctx.PlayerID - clientID := getClientID(client) - - // Create disconnected session for reconnection - h.disconnectedSessions[playerID] = &DisconnectedSession{ - PlayerID: playerID, - ClientID: clientID, - RoomID: ctx.RoomID, - GameID: ctx.GameID, - DisconnectedAt: time.Now(), - } - - // Mark player as disconnected in room (but DON'T remove from game) - if ctx.RoomID != "" { - if room, roomExists := h.rooms[ctx.RoomID]; roomExists { - for _, player := range room.Players { - if player.ClientID == clientID { - player.IsConnected = false - break - } - } - room.LastActivity = time.Now() - roomToUpdate = room - } - } - - // Clean up client maps - delete(h.clientContexts, client) - delete(h.playerToClient, playerID) - close(client.Send) - - slog.Info("Golf client disconnected, session preserved for reconnect", - "clientAddr", getClientAddr(client), - "playerID", playerID, - "roomID", ctx.RoomID, - "gameID", ctx.GameID, - "gracePeriod", h.gracePeriod) - }() - - // Schedule cleanup after grace period - h.mu.RLock() - // Find the playerID we just disconnected - var disconnectedPlayerID string - for pid, session := range h.disconnectedSessions { - if session.DisconnectedAt.After(time.Now().Add(-1 * time.Second)) { - disconnectedPlayerID = pid - break - } - } - h.mu.RUnlock() - - if disconnectedPlayerID != "" { - gracePeriod := h.gracePeriod - go func() { - time.Sleep(gracePeriod) - h.cleanup <- disconnectedPlayerID - }() - } - - // Broadcast updated room state after releasing the lock - if roomToUpdate != nil { - h.broadcastRoomState(roomToUpdate) - } -} - -// handleGameMessage processes incoming game messages. -// The authenticate message is handled before auth check since it IS the auth. -func (h *GolfHub) handleGameMessage(msgData hub.GameMessageData) { - msg, err := ParseIncomingMessage(msgData.Message) - if err != nil { - h.sendError(msgData.Sender, "Invalid message format") - return - } - - // Authenticate is always allowed (it's the auth handshake) - if msg.Type == "authenticate" { - h.handleAuthenticate(msgData.Sender, msg.SessionToken) - return - } - - // All other messages require authentication - h.mu.RLock() - _, isAuthenticated := h.clientContexts[msgData.Sender] - isPending := h.pendingAuth[msgData.Sender] - h.mu.RUnlock() - - if !isAuthenticated { - if isPending { - h.sendError(msgData.Sender, "Must authenticate first") - } else { - h.sendError(msgData.Sender, "Unauthenticated") - } - return - } - - switch msg.Type { - case "createRoom": - h.handleCreateRoom(msgData.Sender) - case "joinRoom": - h.handleJoinRoom(msgData.Sender, msg.RoomID) - case "leaveRoom": - h.handleLeaveRoom(msgData.Sender, msg.RoomID) - case "createGame": - h.handleCreateGame(msgData.Sender, msg.RoomID) - case "joinGame": - h.handleJoinGame(msgData.Sender, msg.RoomID, msg.GameID) - case "startGame": - h.handleStartGame(msgData.Sender) - case "startNewGame": - h.handleStartNewGame(msgData.Sender) - case "getRoomState": - h.handleGetRoomState(msgData.Sender) - case "peekCard": - h.handlePeekCard(msgData.Sender, msg.CardIndex) - case "drawCard": - h.handleDrawCard(msgData.Sender) - case "takeFromDiscard": - h.handleTakeFromDiscard(msgData.Sender) - case "swapCard": - h.handleSwapCard(msgData.Sender, msg.CardIndex) - case "discardDrawn": - h.handleDiscardDrawn(msgData.Sender) - case "knock": - h.handleKnock(msgData.Sender) - case "hideCards": - h.handleHideCards(msgData.Sender) - case "leaveGame": - h.handleLeaveGame(msgData.Sender) - default: - h.sendError(msgData.Sender, "Unknown message type: "+msg.Type) - } -} - -// handleAuthenticate processes authentication requests. -// With a valid token: reconnects to existing session. -// Without a token (or invalid): creates a new session. -func (h *GolfHub) handleAuthenticate(client *hub.Client, sessionToken string) { - // Phase 1: state mutation under lock, collect messages to send - type pendingMessages struct { - auth *AuthenticatedMessage - reconnect *reconnectResult - playerID string - } - var pending pendingMessages - - func() { - h.mu.Lock() - defer h.mu.Unlock() - - // If client is already authenticated, it's a no-op - if _, alreadyAuth := h.clientContexts[client]; alreadyAuth { - slog.Info("Client already authenticated, ignoring duplicate authenticate") - return - } - - // Try to validate existing token for reconnection - if sessionToken != "" { - playerID, err := h.tokenManager.ValidateToken(sessionToken) - if err == nil { - // Valid token - try to reconnect - result := h.reconnectPlayer(client, playerID) - if result.ok { - pending.auth = &AuthenticatedMessage{ - Type: "authenticated", - SessionToken: sessionToken, - PlayerID: playerID, - Reconnected: true, - } - pending.reconnect = &result - pending.playerID = playerID - slog.Info("Player reconnected", - "playerID", playerID, - "clientAddr", getClientAddr(client)) - return - } - // Token valid but no session to reconnect to - fall through to new session - // but reuse the same playerID to maintain identity - h.createNewSession(client, playerID) - token, err := h.tokenManager.CreateToken(playerID, defaultTokenTTL) - if err != nil { - slog.Error("Failed to create token", "error", err) - h.sendError(client, "Authentication failed") - return - } - pending.auth = &AuthenticatedMessage{ - Type: "authenticated", - SessionToken: token, - PlayerID: playerID, - Reconnected: false, - } - pending.playerID = playerID - slog.Info("Player re-authenticated with existing identity (no active session)", - "playerID", playerID, - "clientAddr", getClientAddr(client)) - return - } - slog.Info("Token validation failed, creating new session", - "error", err, - "clientAddr", getClientAddr(client)) - } - - // No token or invalid token - create new session - playerID := h.idGenerator.GenerateID() - h.createNewSession(client, playerID) - - token, err := h.tokenManager.CreateToken(playerID, defaultTokenTTL) - if err != nil { - slog.Error("Failed to create token", "error", err) - h.sendError(client, "Authentication failed") - return - } - pending.auth = &AuthenticatedMessage{ - Type: "authenticated", - SessionToken: token, - PlayerID: playerID, - Reconnected: false, - } - pending.playerID = playerID - slog.Info("New player authenticated", - "playerID", playerID, - "clientAddr", getClientAddr(client)) - }() - - // Phase 2: send messages without holding the lock. - // Order matters: authenticated first, then room/game restore. - if pending.auth == nil { - return - } - h.sendJSON(client, pending.auth) - - if pending.reconnect != nil { - if pending.reconnect.room != nil { - h.sendRoomJoined(client, pending.playerID, pending.reconnect.room) - if pending.reconnect.gameState != nil { - h.sendGameJoined(client, pending.playerID, pending.reconnect.gameState) - } - h.broadcastRoomState(pending.reconnect.room) - } - } -} - -// createNewSession sets up a fresh session for a client. -// Must be called with h.mu held. -func (h *GolfHub) createNewSession(client *hub.Client, playerID string) { - delete(h.pendingAuth, client) - h.clientContexts[client] = &ClientContext{ - PlayerID: playerID, - JoinedAt: time.Now(), - LastAction: time.Now(), - } - h.playerToClient[playerID] = client -} - -// reconnectResult holds the data needed to send restore messages after -// the hub lock is released. -type reconnectResult struct { - ok bool - room *Room - gameState *GameState -} - -// reconnectPlayer restores a disconnected player's session to a new client. -// Must be called with h.mu held. Returns the data needed to send restore -// messages; the caller is responsible for sending them after releasing the lock. -func (h *GolfHub) reconnectPlayer(client *hub.Client, playerID string) reconnectResult { - session, exists := h.disconnectedSessions[playerID] - if !exists { - return reconnectResult{} - } - - newClientID := getClientID(client) - oldClientID := session.ClientID - - // Remove from pending auth - delete(h.pendingAuth, client) - delete(h.disconnectedSessions, playerID) - - // If there's still an old client in our maps, remove it - if oldClient, ok := h.playerToClient[playerID]; ok { - delete(h.clientContexts, oldClient) - } - - // Create new client context with restored room/game - h.clientContexts[client] = &ClientContext{ - RoomID: session.RoomID, - GameID: session.GameID, - PlayerID: playerID, - JoinedAt: time.Now(), - LastAction: time.Now(), - } - h.playerToClient[playerID] = client - - result := reconnectResult{ok: true} - - // Update room player's ClientID and mark as connected - if session.RoomID != "" { - if room, roomExists := h.rooms[session.RoomID]; roomExists { - for _, player := range room.Players { - if player.ClientID == oldClientID || player.ID == playerID { - player.ClientID = newClientID - player.IsConnected = true - break - } - } - - result.room = room - - // Update game player's ClientID - if session.GameID != "" { - if game, gameExists := room.Games[session.GameID]; gameExists { - if err := game.ReplaceClient(oldClientID, newClientID); err != nil { - slog.Warn("Failed to replace client in game (player may have been removed)", - "error", err, - "playerID", playerID, - "gameID", session.GameID) - // Clear game from context since we couldn't restore it - h.clientContexts[client].GameID = "" - } else { - result.gameState = game.GetStateForPlayer(newClientID) - } - } else { - // Game no longer exists - h.clientContexts[client].GameID = "" - } - } - } else { - // Room no longer exists - h.clientContexts[client].RoomID = "" - h.clientContexts[client].GameID = "" - } - } - - return result -} - -// handleCleanupSession removes an expired disconnected session. -func (h *GolfHub) handleCleanupSession(playerID string) { - var roomToUpdate *Room - - func() { - h.mu.Lock() - defer h.mu.Unlock() - - session, exists := h.disconnectedSessions[playerID] - if !exists { - // Session already cleaned up (player reconnected) - return - } - - // Check if player reconnected since cleanup was scheduled - if _, isConnected := h.playerToClient[playerID]; isConnected { - delete(h.disconnectedSessions, playerID) - return - } - - slog.Info("Cleaning up expired session", - "playerID", playerID, - "disconnectedAt", session.DisconnectedAt) - - // Remove player from room - if session.RoomID != "" { - if room, roomExists := h.rooms[session.RoomID]; roomExists { - // Remove from game - if session.GameID != "" { - if game, gameExists := room.Games[session.GameID]; gameExists { - if err := game.RemovePlayer(session.ClientID); err != nil { - slog.Error("Failed to remove expired player from game", - "error", err, - "playerID", playerID, - "gameID", session.GameID) - } - } - } - - room.LastActivity = time.Now() - roomToUpdate = room - - // Clean up empty rooms (no connected players) - connectedCount := 0 - for _, player := range room.Players { - if player.IsConnected { - connectedCount++ - } - } - if connectedCount == 0 { - delete(h.rooms, session.RoomID) - slog.Info("Removed empty room", "roomID", session.RoomID) - roomToUpdate = nil - } - } - } - - delete(h.disconnectedSessions, playerID) - }() - - if roomToUpdate != nil { - h.broadcastRoomState(roomToUpdate) - } -} - -// handleCreateRoom creates a new room -func (h *GolfHub) handleCreateRoom(client *hub.Client) { - h.mu.Lock() - defer h.mu.Unlock() - - // Check if client is already in a room - ctx := h.clientContexts[client] - if ctx != nil && ctx.RoomID != "" { - h.sendError(client, "Already in a room") - return - } - - // Create new room - room := h.createRoom(client) - h.rooms[room.ID] = room - h.clientContexts[client] = &ClientContext{ - RoomID: room.ID, - GameID: "", // Not in a specific game yet - PlayerID: room.Players[0].ID, - JoinedAt: time.Now(), - LastAction: time.Now(), - } - - // Send room joined message - player := room.Players[0] // First player is the creator - h.sendRoomJoined(client, player.ID, room) - - slog.Info("Room created", - "roomID", room.ID, - "playerID", player.ID, - "clientAddr", getClientAddr(client)) -} - -// handleJoinRoom joins an existing room -func (h *GolfHub) handleJoinRoom(client *hub.Client, roomID string) { - var room *Room - var player *Player - - // Do the joining logic with the lock - func() { - h.mu.Lock() - defer h.mu.Unlock() - - // Validate required parameters - if roomID == "" { - h.sendError(client, "Room ID is required") - return - } - - // Check if client is already in a room - ctx := h.clientContexts[client] - if ctx != nil && ctx.RoomID != "" { - // If already in the same room, return error - if ctx.RoomID == roomID { - h.sendError(client, "player already in room") - return - } else { - h.sendError(client, "Already in a different room") - return - } - } else { - // Add player to room (new player) - var err error - player, err = h.addPlayerToRoom(roomID, client) - if err != nil { - h.sendError(client, err.Error()) - return - } - room = h.rooms[roomID] - - // Set up client context for new player - h.clientContexts[client] = &ClientContext{ - RoomID: roomID, - GameID: "", // Not in a specific game yet - PlayerID: player.ID, - JoinedAt: time.Now(), - LastAction: time.Now(), - } - } - }() - - // Exit if join failed - if room == nil || player == nil { - return - } - - // Send room joined message to new player - h.sendRoomJoined(client, player.ID, room) - - // Broadcast updated state to all players in room - h.broadcastRoomState(room) - - slog.Info("Player joined room and game", - "roomID", roomID, - "playerID", player.ID, - "clientAddr", getClientAddr(client)) -} - -// handleLeaveRoom leaves an existing room -func (h *GolfHub) handleLeaveRoom(client *hub.Client, roomID string) { - var room *Room - - // Do the leaving logic with the lock - func() { - h.mu.Lock() - defer h.mu.Unlock() - - // Validate required parameters - if roomID == "" { - h.sendError(client, "Room ID is required") - return - } - - // Check if client is in the specified room - ctx := h.clientContexts[client] - if ctx == nil || ctx.RoomID != roomID { - h.sendError(client, "Not in the specified room") - return - } - - room = h.rooms[roomID] - if room == nil { - h.sendError(client, "Room not found") - return - } - - // Mark player as disconnected but keep in room history - clientID := getClientID(client) - for _, player := range room.Players { - if player.ClientID == clientID { - player.IsConnected = false - break - } - } - - // Remove from active game if in one - if ctx.GameID != "" { - if game, exists := room.Games[ctx.GameID]; exists { - game.RemovePlayer(clientID) - } - } - - // Clear room/game from context but keep the client authenticated - ctx.RoomID = "" - ctx.GameID = "" - room.LastActivity = time.Now() - }() - - // Broadcast updated room state if room still exists - if room != nil { - h.broadcastRoomState(room) - } - - slog.Info("Player left room", - "roomID", roomID, - "clientAddr", getClientAddr(client)) -} - -// handleLeaveGame removes a player from their current game but keeps them in the room. -// If the game was in progress and fewer than 2 players remain, the game ends. -func (h *GolfHub) handleLeaveGame(client *hub.Client) { - var room *Room - var game *Game - var gameEnded bool - - func() { - h.mu.Lock() - defer h.mu.Unlock() - - ctx := h.clientContexts[client] - if ctx == nil || ctx.GameID == "" { - h.sendError(client, "Not in a game") - return - } - - room = h.rooms[ctx.RoomID] - if room == nil { - h.sendError(client, "Room not found") - return - } - - var exists bool - game, exists = room.Games[ctx.GameID] - if !exists { - // Game already gone — just clear context - ctx.GameID = "" - return - } - - clientID := getClientID(client) - phaseBefore := game.state.GamePhase - - if err := game.RemovePlayer(clientID); err != nil { - h.sendError(client, err.Error()) - game = nil - return - } - - gameEnded = phaseBefore != "ended" && phaseBefore != "waiting" && game.state.GamePhase == "ended" - - ctx.GameID = "" - room.LastActivity = time.Now() - }() - - if game == nil { - return - } - - if gameEnded { - h.handleGameEnded(game) - } else { - h.broadcastGameState(game) - } - - if room != nil { - h.broadcastRoomState(room) - } - - slog.Info("Player left game", - "clientAddr", getClientAddr(client)) -} - -// handleCreateGame creates a new game within an existing room -func (h *GolfHub) handleCreateGame(client *hub.Client, roomID string) { - var room *Room - var game *Game - var player *Player - var gameState *GameState - - clientID := getClientID(client) - - func() { - h.mu.Lock() - defer h.mu.Unlock() - - // Validate roomID - if roomID == "" { - h.sendError(client, "Room ID is required") - return - } - - // Check if room exists - var exists bool - room, exists = h.rooms[roomID] - if !exists { - h.sendError(client, "Room not found") - return - } - - // Check if client is in this room - ctx := h.clientContexts[client] - if ctx == nil || ctx.RoomID != roomID { - h.sendError(client, "Must be in the room to create a game") - return - } - - // Generate a unique game ID for this room - gameID := h.generateGameID(room) - - // Create the game using existing helper - var err error - game, err = h.createGameInRoom(roomID, gameID) - if err != nil { - h.sendError(client, err.Error()) - return - } - - // Add client to the new game using persistent player ID and name - if ctx.PlayerID == "" { - h.sendError(client, "client context missing player ID") - return - } - - player, err = game.AddPlayer(clientID, ctx.PlayerID, ctx.PlayerID) - if err != nil { - h.sendError(client, err.Error()) - return - } - - // Update client's game context - ctx.GameID = gameID - ctx.PlayerID = player.ID - ctx.LastAction = time.Now() - - room.LastActivity = time.Now() - - // Get the game state while we still hold the lock to ensure consistency - gameState = game.GetStateForPlayer(clientID) - - slog.Info("Game created in room", - "roomID", roomID, - "gameID", gameID, - "playerID", player.ID, - "clientAddr", getClientAddr(client)) - - // Broadcast room state update to show new game while we hold the lock - // This ensures the room state includes the updated game with the player - h.broadcastRoomStateLocked(room) - }() - - // Exit early if we failed to create game/player - if room == nil || game == nil || player == nil || gameState == nil { - return - } - - // Send game joined message - h.sendGameJoined(client, player.ID, gameState) - - // Broadcast updated game state to all players in the game - h.broadcastGameState(game) -} - -// generateGameID generates a unique game ID within a room -func (h *GolfHub) generateGameID(room *Room) string { - for attempts := 0; attempts < 10; attempts++ { - gameID := GenerateGameID() - if _, exists := room.Games[gameID]; !exists { - return gameID - } - } - // Fallback if we somehow have collisions (very unlikely) - return fmt.Sprintf("%s_%d", GenerateGameID(), time.Now().UnixNano()) -} - -// handleJoinGame joins an existing room and specific game -func (h *GolfHub) handleJoinGame(client *hub.Client, roomID string, gameID string) { - var room *Room - var game *Game - var player *Player - var err error - - // Do the joining logic with the lock - func() { - h.mu.Lock() - defer h.mu.Unlock() - - // Validate required parameters - if roomID == "" { - h.sendError(client, "Room ID is required") - return - } - if gameID == "" { - h.sendError(client, "Game ID is required") - return - } - - // Check if client is in the room - ctx := h.clientContexts[client] - if ctx == nil || ctx.RoomID != roomID { - h.sendError(client, "Player not found in room") - return - } - - // Get the room from the rooms map - var roomExists bool - room, roomExists = h.rooms[roomID] - if !roomExists { - h.sendError(client, "Room not found") - return - } - - // Check if game exists - var gameExists bool - game, gameExists = room.Games[gameID] - if !gameExists { - h.sendError(client, "Game does not exist in room") - return - } - - clientID := getClientID(client) - - // Use persistent player ID and name from context - if ctx.PlayerID == "" { - h.sendError(client, "client context missing player ID") - return - } - - player, err = game.AddPlayer(clientID, ctx.PlayerID, ctx.PlayerID) - if err != nil { - h.sendError(client, err.Error()) - return - } - - // Update client's game context - ctx.GameID = gameID - ctx.PlayerID = player.ID - ctx.LastAction = time.Now() - }() - - // Exit if join failed - if room == nil || game == nil || player == nil { - return - } - - // Send game joined message to the player who just joined - clientID := getClientID(client) - h.sendGameJoined(client, player.ID, game.GetStateForPlayer(clientID)) - - // Broadcast updated game state to all players in the game - h.broadcastGameState(game) - - // Broadcast updated state to all players in room - h.broadcastRoomState(room) - - slog.Info("Player joined game", - "roomID", roomID, - "gameID", gameID, - "playerID", player.ID, - "clientAddr", getClientAddr(client)) -} - -// handleStartGame starts a specific game within the current room -func (h *GolfHub) handleStartGame(client *hub.Client) { - h.mu.Lock() - ctx := h.clientContexts[client] - if ctx == nil { - h.mu.Unlock() - h.sendError(client, "Not in a room") - return - } - - room := h.rooms[ctx.RoomID] - if room == nil { - h.mu.Unlock() - h.sendError(client, "Room not found") - return - } - - if ctx.GameID == "" { - h.mu.Unlock() - h.sendError(client, "Not in a specific game") - return - } - - game := room.Games[ctx.GameID] - if game == nil { - h.mu.Unlock() - h.sendError(client, "Game not found") - return - } - - h.mu.Unlock() - - if err := game.StartGame(); err != nil { - h.sendError(client, err.Error()) - return - } - - // Broadcast game started message - h.broadcastToGameLocked(game, &GameStartedMessage{Type: "gameStarted"}) - - // Broadcast updated game state - h.broadcastGameState(game) - - slog.Info("Game started", "gameID", game.state.ID, "roomID", ctx.RoomID) -} - -// handlePeekCard handles card peeking -func (h *GolfHub) handlePeekCard(client *hub.Client, cardIndex int) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - if err := game.PeekCard(getClientID(client), cardIndex); err != nil { - h.sendError(client, err.Error()) - return - } - - // Send updated game state to all players if all have peeked - if game.state.GamePhase == "peeking" { - h.broadcastGameState(game) - } else { - // Send updated game state only to the peeking player with their personalized view - h.sendGameState(client, game.GetStateForPlayer(getClientID(client))) - } -} - -// handleDrawCard handles drawing from deck -func (h *GolfHub) handleDrawCard(client *hub.Client) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - if err := game.DrawCard(getClientID(client)); err != nil { - h.sendError(client, err.Error()) - return - } - - // Broadcast updated state - h.broadcastGameState(game) -} - -// handleTakeFromDiscard handles taking from discard pile -func (h *GolfHub) handleTakeFromDiscard(client *hub.Client) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - if err := game.TakeFromDiscard(getClientID(client)); err != nil { - h.sendError(client, err.Error()) - return - } - - // Broadcast updated state - h.broadcastGameState(game) -} - -// handleSwapCard handles card swapping -func (h *GolfHub) handleSwapCard(client *hub.Client, cardIndex int) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - oldPlayerIndex := game.state.CurrentPlayerIndex - - if err := game.SwapCard(getClientID(client), cardIndex); err != nil { - h.sendError(client, err.Error()) - return - } - - // Broadcast updated state - h.broadcastGameState(game) - - // Check if turn changed - if oldPlayerIndex != game.state.CurrentPlayerIndex { - h.broadcastTurnChanged(game) - } - - // Check if game ended - if game.state.GamePhase == "ended" { - h.handleGameEnded(game) - } -} - -// handleDiscardDrawn handles discarding the drawn card -func (h *GolfHub) handleDiscardDrawn(client *hub.Client) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - oldPlayerIndex := game.state.CurrentPlayerIndex - - if err := game.DiscardDrawn(getClientID(client)); err != nil { - h.sendError(client, err.Error()) - return - } - - // Broadcast updated state - h.broadcastGameState(game) - - // Check if turn changed - if oldPlayerIndex != game.state.CurrentPlayerIndex { - h.broadcastTurnChanged(game) - } - - // Check if game ended - if game.state.GamePhase == "ended" { - h.handleGameEnded(game) - } -} - -// handleKnock handles knocking -func (h *GolfHub) handleKnock(client *hub.Client) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - player := game.GetPlayerByClientID(getClientID(client)) - if player == nil { - h.sendError(client, "Player not found") - return - } - - if err := game.Knock(getClientID(client)); err != nil { - h.sendError(client, err.Error()) - return - } - - // Broadcast player knocked message - h.broadcastToGameLocked(game, &PlayerKnockedMessage{ - Type: "playerKnocked", - PlayerName: player.Name, - }) - - // Broadcast updated state - h.broadcastGameState(game) -} - -// Helper methods - -func (h *GolfHub) getClientGame(client *hub.Client) *Game { - ctx := h.clientContexts[client] - if ctx == nil || ctx.GameID == "" { - return nil - } - - room := h.rooms[ctx.RoomID] - if room == nil { - return nil - } - - return room.Games[ctx.GameID] -} - -// getClientRoom returns the room for a given client using ClientContext -func (h *GolfHub) getClientRoom(client *hub.Client) *Room { - ctx := h.clientContexts[client] - if ctx == nil || ctx.RoomID == "" { - return nil - } - return h.rooms[ctx.RoomID] -} - -// createGameInRoom creates a new game within a room -func (h *GolfHub) createGameInRoom(roomID string, gameID string) (*Game, error) { - room, exists := h.rooms[roomID] - if !exists { - return nil, fmt.Errorf("room not found") - } - - connectedPlayers := make([]*Player, 0) - - // Create new game - idGenerator := &players.WhimsicalIDGenerator{} - game := NewGameInRoom(gameID, roomID, connectedPlayers, idGenerator) - - room.Games[gameID] = game - room.LastActivity = time.Now() - - return game, nil -} - -// removeGameFromRoom removes a completed game from the room -func (h *GolfHub) removeGameFromRoom(roomID string, gameID string) error { - room, exists := h.rooms[roomID] - if !exists { - return fmt.Errorf("room not found") - } - - game, exists := room.Games[gameID] - if !exists { - return fmt.Errorf("game not found") - } - - // Only remove if game is ended - if game.state.GamePhase == "ended" { - delete(room.Games, gameID) - room.LastActivity = time.Now() - } - - return nil -} - -func (h *GolfHub) sendError(client *hub.Client, message string) { - msg := &ErrorMessage{ - Type: "error", - Message: message, - } - h.sendJSON(client, msg) -} - -func (h *GolfHub) sendGameJoined(client *hub.Client, playerID string, state *GameState) { - msg := &GameJoinedMessage{ - Type: "gameJoined", - PlayerID: playerID, - GameState: state, - } - h.sendJSON(client, msg) -} - -func (h *GolfHub) sendGameState(client *hub.Client, state *GameState) { - msg := &GameStateUpdateMessage{ - Type: "gameState", - GameState: state, - } - h.sendJSON(client, msg) -} - -func (h *GolfHub) broadcastGameState(game *Game) { - // Create a slice to hold client-state pairs to avoid holding lock during sends - type clientStatePair struct { - client *hub.Client - state *GameState - } - var pairs []clientStatePair - - // Collect all clients and their personalized states - h.mu.RLock() - for client, ctx := range h.clientContexts { - if ctx != nil && ctx.GameID == game.state.ID { - if room, exists := h.rooms[ctx.RoomID]; exists { - if gameInRoom, gameExists := room.Games[ctx.GameID]; gameExists && gameInRoom.state.ID == game.state.ID { - personalizedState := game.GetStateForPlayer(getClientID(client)) - pairs = append(pairs, clientStatePair{client: client, state: personalizedState}) - } - } - } - } - h.mu.RUnlock() - - // Send messages without holding the lock - for _, pair := range pairs { - msg := &GameStateUpdateMessage{ - Type: "gameState", - GameState: pair.state, - } - h.sendJSON(pair.client, msg) - } -} - -func (h *GolfHub) broadcastTurnChanged(game *Game) { - if len(game.state.Players) == 0 { - return - } - currentPlayer := game.state.Players[game.state.CurrentPlayerIndex] - msg := &TurnChangedMessage{ - Type: "turnChanged", - PlayerName: currentPlayer.Name, - } - h.broadcastToGameLocked(game, msg) -} - -func (h *GolfHub) broadcastGameEnded(game *Game) { - winners := game.GetWinners() - if len(winners) == 0 { - return - } - names := make([]string, len(winners)) - for i, winner := range winners { - names[i] = winner.Name - } - - msg := &GameEndedMessage{ - Type: "gameEnded", - Winner: strings.Join(names, " & "), - Winners: names, - FinalScores: game.GetFinalScores(), - } - h.broadcastToGameLocked(game, msg) -} - -func (h *GolfHub) broadcastToGame(game *Game, message interface{}) { - h.mu.RLock() - defer h.mu.RUnlock() - - h.broadcastToGameLocked(game, message) -} - -// handleGameEnded processes game completion and updates room statistics -func (h *GolfHub) handleGameEnded(game *Game) { - // First broadcast the game ended message - h.broadcastGameEnded(game) - - // If this game belongs to a room, update room statistics - roomID := game.GetRoomID() - if roomID != "" { - h.mu.Lock() - err := h.completeGameInRoom(roomID, game.state.ID) - if err != nil { - slog.Error("Failed to complete game in room", - "error", err, - "roomID", roomID, - "gameID", game.state.ID) - } else { - // Broadcast updated room state - if room, exists := h.rooms[roomID]; exists { - h.mu.Unlock() - h.broadcastRoomState(room) - - slog.Info("Game completed and room stats updated", - "roomID", roomID, - "gameID", game.state.ID) - return - } - } - h.mu.Unlock() - } -} - -func (h *GolfHub) broadcastToGameLocked(game *Game, message interface{}) { - for client, ctx := range h.clientContexts { - if ctx != nil && ctx.GameID == game.state.ID { - if room, exists := h.rooms[ctx.RoomID]; exists { - if gameInRoom, gameExists := room.Games[ctx.GameID]; gameExists && gameInRoom.state.ID == game.state.ID { - h.sendJSON(client, message) - } - } - } - } -} - -func (h *GolfHub) sendJSON(client *hub.Client, message interface{}) { - data, err := json.Marshal(message) - if err != nil { - slog.Error("Failed to marshal message", - "error", err, - "type", message) - return - } - - select { - case client.Send <- data: - // Successfully sent message - default: - // Buffer full - force disconnect - slog.Warn("Client send buffer full, forcing disconnect", - "clientAddr", getClientAddr(client), - "messageType", fmt.Sprintf("%T", message)) - close(client.Send) - h.mu.Lock() - delete(h.clientContexts, client) - h.mu.Unlock() - } -} - -// handleHideCards handles hiding cards after peek countdown -func (h *GolfHub) handleHideCards(client *hub.Client) { - h.mu.RLock() - game := h.getClientGame(client) - h.mu.RUnlock() - - if game == nil { - h.sendError(client, "Not in a game") - return - } - - // Hide the cards - game.HidePeekedCards() - - // Broadcast updated state to all players - h.broadcastGameState(game) -} - -// Utility functions - -func getClientID(client *hub.Client) string { - if client.ID != "" { - return client.ID - } - if client.Conn != nil { - return client.Conn.RemoteAddr().String() - } - // For testing: use the client's memory address as a unique ID - return fmt.Sprintf("test-client-%p", client) -} - -func getClientAddr(client *hub.Client) string { - if client.Conn != nil { - return client.Conn.RemoteAddr().String() - } - return "test-client" -} - -// Room Management Methods - -// createRoom creates a new room with the given client as the first player -func (h *GolfHub) createRoom(client *hub.Client) *Room { - roomID := GenerateRoomID() - clientID := getClientID(client) - - // Use persistent player ID from context - ctx := h.clientContexts[client] - if ctx == nil || ctx.PlayerID == "" { - // This should not happen if client was properly registered - return nil - } - - player := &Player{ - ID: ctx.PlayerID, - Name: ctx.PlayerID, - ClientID: clientID, - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: make([]int, 0), - IsReady: false, - HasPeeked: false, - TotalScore: 0, - GamesPlayed: 0, - GamesWon: 0, - IsConnected: true, - JoinedAt: time.Now(), - } - - room := &Room{ - ID: roomID, - Players: []*Player{player}, - Games: make(map[string]*Game), - GameHistory: make([]*GameResult, 0), - CreatedAt: time.Now(), - LastActivity: time.Now(), - } - - return room -} - -// addPlayerToRoom adds a player to an existing room -func (h *GolfHub) addPlayerToRoom(roomID string, client *hub.Client) (*Player, error) { - room, exists := h.rooms[roomID] - if !exists { - return nil, fmt.Errorf("room not found") - } - - if len(room.Players) >= 4 { - return nil, fmt.Errorf("room is full") - } - - clientID := getClientID(client) - - // Check if player is already in room by clientID - for _, player := range room.Players { - if player.ClientID == clientID { - player.IsConnected = true - room.LastActivity = time.Now() - return player, nil - } - } - - // Check if player is already in room by playerID (reconnect case) - ctx := h.clientContexts[client] - if ctx == nil || ctx.PlayerID == "" { - return nil, fmt.Errorf("client context not found or missing player ID") - } - - for _, player := range room.Players { - if player.ID == ctx.PlayerID { - player.ClientID = clientID - player.IsConnected = true - room.LastActivity = time.Now() - return player, nil - } - } - - // Create new player using persistent player ID from context - player := &Player{ - ID: ctx.PlayerID, - Name: ctx.PlayerID, - ClientID: clientID, - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: make([]int, 0), - IsReady: false, - HasPeeked: false, - TotalScore: 0, - GamesPlayed: 0, - GamesWon: 0, - IsConnected: true, - JoinedAt: time.Now(), - } - - room.Players = append(room.Players, player) - room.LastActivity = time.Now() - - return player, nil -} - -// startNewGameInRoom creates a new game within a room with a generated game ID -func (h *GolfHub) startNewGameInRoom(roomID string) (*Game, error) { - gameID := GenerateGameID() - return h.createGameInRoom(roomID, gameID) -} - -// completeGameInRoom handles game completion and updates room stats -func (h *GolfHub) completeGameInRoom(roomID string, gameID string) error { - room, exists := h.rooms[roomID] - if !exists { - return fmt.Errorf("room not found") - } - - game, exists := room.Games[gameID] - if !exists { - return fmt.Errorf("game not found") - } - - if game.state.GamePhase != "ended" { - return fmt.Errorf("game is not completed") - } - - gameResult := game.GetGameResult() - if gameResult == nil { - return fmt.Errorf("failed to get game result") - } - - // Update room history - room.GameHistory = append(room.GameHistory, gameResult) - room.LastActivity = time.Now() - - // Update player statistics. Winners is the typed list — on a shared win - // (non-knocker tie, issue #1187 phase 0) every winner gets the credit, - // and Winner is just the joined display string. - winnerSet := make(map[string]bool, len(gameResult.Winners)) - for _, name := range gameResult.Winners { - winnerSet[name] = true - } - for _, finalScore := range gameResult.FinalScores { - for _, roomPlayer := range room.Players { - if roomPlayer.Name == finalScore.PlayerName { - roomPlayer.TotalScore += finalScore.Score - roomPlayer.GamesPlayed++ - if winnerSet[finalScore.PlayerName] { - roomPlayer.GamesWon++ - } - break - } - } - } - - // Remove completed game from active games - delete(room.Games, gameID) - - return nil -} - -// broadcastRoomState broadcasts room state to all players in the room -func (h *GolfHub) broadcastRoomState(room *Room) { - // Create a slice to hold client-room pairs to avoid holding lock during sends - type clientRoomPair struct { - client *hub.Client - room *Room - } - var pairs []clientRoomPair - - // Collect all clients in this room - h.mu.RLock() - for client, ctx := range h.clientContexts { - if ctx != nil && ctx.RoomID == room.ID { - pairs = append(pairs, clientRoomPair{client: client, room: room}) - } - } - h.mu.RUnlock() - - // Send messages without holding the lock - for _, pair := range pairs { - msg := &RoomStateUpdateMessage{ - Type: "roomStateUpdate", - RoomState: pair.room, - } - h.sendJSON(pair.client, msg) - } -} - -// broadcastRoomStateLocked broadcasts room state to all players in the room -// This version assumes the caller already holds the mutex -func (h *GolfHub) broadcastRoomStateLocked(room *Room) { - // Create a slice to hold clients - var clients []*hub.Client - - // Collect all clients in this room (we already hold the lock) - for client, ctx := range h.clientContexts { - if ctx != nil && ctx.RoomID == room.ID { - clients = append(clients, client) - } - } - - // Pre-serialize the room state while holding the lock - msg := &RoomStateUpdateMessage{ - Type: "roomStateUpdate", - RoomState: room, - } - data, err := json.Marshal(msg) - if err != nil { - slog.Error("Failed to marshal room state message", - "error", err, - "roomID", room.ID) - return - } - - // Send the pre-serialized message to all clients - for _, client := range clients { - // Temporarily release the lock to send the message - h.mu.Unlock() - select { - case client.Send <- data: - default: - close(client.Send) - // We can't safely delete from clientContexts here since we don't have the lock - // This will be cleaned up by handleUnregister - } - h.mu.Lock() - } -} - -// New Room-Based Message Handlers - -// handleStartNewGame starts a new game within the current room -func (h *GolfHub) handleStartNewGame(client *hub.Client) { - h.mu.Lock() - room := h.getClientRoom(client) - if room == nil { - h.mu.Unlock() - h.sendError(client, "Not in a room") - return - } - - // Get the previous game ID from client context - ctx := h.clientContexts[client] - previousGameID := "" - if ctx != nil { - previousGameID = ctx.GameID - } - - // Start new game in room - game, err := h.startNewGameInRoom(room.ID) - if err != nil { - h.mu.Unlock() - h.sendError(client, err.Error()) - return - } - h.mu.Unlock() - - // Broadcast new game started message with game IDs - h.broadcastToRoom(room, &NewGameStartedMessage{ - Type: "newGameStarted", - GameID: game.state.ID, - PreviousGameID: previousGameID, - }) - - // Broadcast updated room state - h.broadcastRoomState(room) - - slog.Info("New game started in room", - "roomID", room.ID, - "gameID", game.state.ID, - "previousGameID", previousGameID) -} - -// handleGetRoomState sends the current room state to the client -func (h *GolfHub) handleGetRoomState(client *hub.Client) { - h.mu.RLock() - room := h.getClientRoom(client) - h.mu.RUnlock() - - if room == nil { - h.sendError(client, "Not in a room") - return - } - - // Send room state update - msg := &RoomStateUpdateMessage{ - Type: "roomStateUpdate", - RoomState: room, - } - h.sendJSON(client, msg) -} - -// sendRoomJoined sends room joined message to client -func (h *GolfHub) sendRoomJoined(client *hub.Client, playerID string, room *Room) { - msg := &RoomJoinedMessage{ - Type: "roomJoined", - PlayerID: playerID, - RoomState: room, - } - h.sendJSON(client, msg) -} - -// broadcastToRoom broadcasts a message to all clients in a room -func (h *GolfHub) broadcastToRoom(room *Room, message interface{}) { - h.mu.RLock() - defer h.mu.RUnlock() - - for client, ctx := range h.clientContexts { - if ctx != nil && ctx.RoomID == room.ID { - h.sendJSON(client, message) - } - } -} diff --git a/domains/games/apis/games_ws_backend/golf/golf_hub_test.go b/domains/games/apis/games_ws_backend/golf/golf_hub_test.go deleted file mode 100644 index e09993506..000000000 --- a/domains/games/apis/games_ws_backend/golf/golf_hub_test.go +++ /dev/null @@ -1,2142 +0,0 @@ -package golf - -import ( - "encoding/json" - "sync" - "testing" - "time" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/hub" - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -// mockClient simulates a websocket client for testing -type mockClient struct { - id string - messages [][]byte - send chan []byte - closed bool - mu sync.Mutex -} - -func newMockClient(id string) *mockClient { - return &mockClient{ - id: id, - messages: make([][]byte, 0), - send: make(chan []byte, 256), - } -} - -func (m *mockClient) collectMessages() { - go func() { - for msg := range m.send { - m.mu.Lock() - if !m.closed { - m.messages = append(m.messages, msg) - } - m.mu.Unlock() - } - }() -} - -func (m *mockClient) getMessages() [][]byte { - m.mu.Lock() - defer m.mu.Unlock() - result := make([][]byte, len(m.messages)) - copy(result, m.messages) - return result -} - -func (m *mockClient) clearMessages() { - m.mu.Lock() - defer m.mu.Unlock() - m.messages = nil -} - -func (m *mockClient) close() { - m.mu.Lock() - m.closed = true - m.mu.Unlock() - close(m.send) -} - -// authenticateMockClient sends an authenticate message and clears the response. -// Must be called after Register and before any other game messages. -func authenticateMockClient(golfHub hub.Hub, hubClient *hub.Client, mockCli *mockClient) { - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"authenticate","sessionToken":""}`), - Sender: hubClient, - }) - time.Sleep(10 * time.Millisecond) - mockCli.clearMessages() -} - -func TestParseIncomingMessage(t *testing.T) { - tests := []struct { - name string - json string - msgType string - gameID string - index int - }{ - { - name: "create game", - json: `{"type": "createGame"}`, - msgType: "createGame", - }, - { - name: "join game", - json: `{"type": "joinGame", "roomId": "ROOM1", "gameId": "ABC123"}`, - msgType: "joinGame", - gameID: "ABC123", - }, - { - name: "peek card", - json: `{"type": "peekCard", "cardIndex": 3}`, - msgType: "peekCard", - index: 3, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - msg, err := ParseIncomingMessage([]byte(test.json)) - if err != nil { - t.Fatalf("Failed to parse message: %v", err) - } - - if msg.Type != test.msgType { - t.Errorf("Expected type %s, got %s", test.msgType, msg.Type) - } - - if test.gameID != "" && msg.GameID != test.gameID { - t.Errorf("Expected gameID %s, got %s", test.gameID, msg.GameID) - } - - if test.msgType == "peekCard" && msg.CardIndex != test.index { - t.Errorf("Expected cardIndex %d, got %d", test.index, msg.CardIndex) - } - }) - } -} - -// Auth Tests - -func TestHub_AuthenticationRequired(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - - golfHub.Register(hubClient1) - time.Sleep(10 * time.Millisecond) - - // Try to create room without authenticating - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - messages := client1.getMessages() - if len(messages) != 1 { - t.Fatalf("Expected 1 message, got %d", len(messages)) - } - - var errMsg ErrorMessage - json.Unmarshal(messages[0], &errMsg) - if errMsg.Type != "error" || errMsg.Message != "Must authenticate first" { - t.Errorf("Expected 'Must authenticate first' error, got: %s", errMsg.Message) - } - - client1.close() -} - -func TestHub_AuthenticationFlow(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - - golfHub.Register(hubClient1) - time.Sleep(10 * time.Millisecond) - - // Authenticate with empty token (new session) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"authenticate","sessionToken":""}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - messages := client1.getMessages() - if len(messages) != 1 { - t.Fatalf("Expected 1 message, got %d", len(messages)) - } - - var authMsg AuthenticatedMessage - json.Unmarshal(messages[0], &authMsg) - - if authMsg.Type != "authenticated" { - t.Errorf("Expected authenticated message, got %s", authMsg.Type) - } - if authMsg.SessionToken == "" { - t.Error("Session token should not be empty") - } - if authMsg.PlayerID == "" { - t.Error("Player ID should not be empty") - } - if authMsg.Reconnected { - t.Error("Should not be a reconnection") - } - - client1.close() -} - -func TestHub_TokenReconnection(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // First client connects and authenticates - client1 := newMockClient("client1") - client1.collectMessages() - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - - golfHub.Register(hubClient1) - time.Sleep(10 * time.Millisecond) - - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"authenticate","sessionToken":""}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Extract token and playerID - messages := client1.getMessages() - var authMsg AuthenticatedMessage - json.Unmarshal(messages[0], &authMsg) - token := authMsg.SessionToken - playerID := authMsg.PlayerID - - client1.clearMessages() - - // Create a room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - messages = client1.getMessages() - var roomJoined RoomJoinedMessage - json.Unmarshal(messages[0], &roomJoined) - roomID := roomJoined.RoomState.ID - - // Disconnect - client1.mu.Lock() - client1.closed = true - client1.mu.Unlock() - golfHub.Unregister(hubClient1) - time.Sleep(50 * time.Millisecond) - - // Reconnect with stored token - client2 := newMockClient("client2") - client2.collectMessages() - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"authenticate","sessionToken":"` + token + `"}`), - Sender: hubClient2, - }) - time.Sleep(100 * time.Millisecond) // Extra time for async state restoration - - messages2 := client2.getMessages() - if len(messages2) < 1 { - t.Fatal("Expected at least 1 message on reconnect") - } - - // First message should be authenticated with reconnected=true - var reconnectAuth AuthenticatedMessage - json.Unmarshal(messages2[0], &reconnectAuth) - - if reconnectAuth.Type != "authenticated" { - t.Errorf("Expected authenticated message, got %s", reconnectAuth.Type) - } - if !reconnectAuth.Reconnected { - t.Error("Should be marked as reconnection") - } - if reconnectAuth.PlayerID != playerID { - t.Errorf("Expected playerID %s, got %s", playerID, reconnectAuth.PlayerID) - } - - // Should also receive roomJoined with same room - foundRoom := false - for _, msg := range messages2 { - var parsed map[string]interface{} - json.Unmarshal(msg, &parsed) - if parsed["type"] == "roomJoined" { - if rs, ok := parsed["roomState"].(map[string]interface{}); ok { - if rs["id"] == roomID { - foundRoom = true - } - } - } - } - if !foundRoom { - t.Errorf("Expected to be restored to room %s on reconnect", roomID) - } - - client2.close() -} - -func TestHub_AlgValidation(t *testing.T) { - tm := NewTokenManagerWithSecret([]byte("test-secret")) - - token, err := tm.CreateToken("player-1", 24*time.Hour) - if err != nil { - t.Fatalf("Failed to create token: %v", err) - } - - // Valid token should work - playerID, err := tm.ValidateToken(token) - if err != nil { - t.Fatalf("Valid token should validate: %v", err) - } - if playerID != "player-1" { - t.Errorf("Expected player-1, got %s", playerID) - } - - // Tampered token should fail - _, err = tm.ValidateToken(token + "tampered") - if err == nil { - t.Error("Tampered token should fail validation") - } - - // Token from different secret should fail - tm2 := NewTokenManagerWithSecret([]byte("other-secret")) - _, err = tm2.ValidateToken(token) - if err == nil { - t.Error("Token from different secret should fail") - } -} - -// Hub Integration Tests - -func TestHub_CreateAndJoinRoom(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create mock clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - // Convert to hub clients - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - // Register clients - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Client 1 creates room - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Check client 1 received roomJoined message - messages1 := client1.getMessages() - if len(messages1) != 1 { - t.Fatalf("Expected 1 message for client1, got %d", len(messages1)) - } - - var joinedMsg RoomJoinedMessage - if err := json.Unmarshal(messages1[0], &joinedMsg); err != nil { - t.Fatalf("Failed to parse roomJoined message: %v", err) - } - - if joinedMsg.Type != "roomJoined" { - t.Errorf("Expected roomJoined message, got %s", joinedMsg.Type) - } - - roomID := joinedMsg.RoomState.ID - if len(roomID) != 6 { - t.Errorf("Expected 6-character room ID, got %s", roomID) - } - - // Client 2 joins the room with a specific room ID - client2.clearMessages() - joinMsg := `{"type": "joinRoom", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(joinMsg), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Check client 2 received roomJoined message (and possibly a room state broadcast) - messages2 := client2.getMessages() - if len(messages2) == 0 { - t.Fatal("Expected at least 1 message for client2") - } - - var joined2Msg RoomJoinedMessage - if err := json.Unmarshal(messages2[0], &joined2Msg); err != nil { - t.Fatalf("Failed to parse roomJoined message: %v", err) - } - - if joined2Msg.Type != "roomJoined" { - t.Errorf("Expected roomJoined got %s", joined2Msg.Type) - } - - if joined2Msg.RoomState.ID != roomID { - t.Errorf("Expected to join room %s, got %s", roomID, joined2Msg.RoomState.ID) - } - - if len(joined2Msg.RoomState.Players) != 2 { - t.Errorf("Expected 2 players in room, got %d", len(joined2Msg.RoomState.Players)) - } - - client1.close() - client2.close() -} - -func TestHub_CreateAndJoinGame(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create mock clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - // Convert to hub clients - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - // Register clients - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Client 1 creates room - createRoomMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createRoomMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Check client 1 received roomJoined message - messages1 := client1.getMessages() - if len(messages1) != 1 { - t.Fatalf("Expected 1 message for client1, got %d", len(messages1)) - } - - var createdRoomMsg RoomJoinedMessage - if err := json.Unmarshal(messages1[0], &createdRoomMsg); err != nil { - t.Fatalf("Failed to parse roomJoined message: %v", err) - } - - if createdRoomMsg.Type != "roomJoined" { - t.Errorf("Expected roomJoined message, got %s", createdRoomMsg.Type) - } - - roomID := createdRoomMsg.RoomState.ID - if len(roomID) != 6 { - t.Errorf("Expected 6-character room ID, got %s", roomID) - } - - // Client 2 joins the room with a specific room ID - client2.clearMessages() - joinRoomMsg := `{"type": "joinRoom", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(joinRoomMsg), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Check client 2 received roomJoined message (and possibly a room state broadcast) - messages2 := client2.getMessages() - if len(messages2) == 0 { - t.Fatal("Expected at least 1 message for client2") - } - - var joinedRoomMsg RoomJoinedMessage - if err := json.Unmarshal(messages2[0], &joinedRoomMsg); err != nil { - t.Fatalf("Failed to parse roomJoined message: %v", err) - } - - if joinedRoomMsg.RoomState.ID != roomID { - t.Errorf("Expected to join room %s, got %s", roomID, joinedRoomMsg.RoomState.ID) - } - - if len(joinedRoomMsg.RoomState.Players) != 2 { - t.Errorf("Expected 2 players in room, got %d", len(joinedRoomMsg.RoomState.Players)) - } - - // Client 1 creates Game - client1.clearMessages() - createGameMsg := `{"type": "createGame", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createGameMsg), - Sender: hubClient1, - }) - time.Sleep(100 * time.Millisecond) - - // Check client 1 received gameJoined message and roomStateUpdate message - gameCreated1 := client1.getMessages() - if len(gameCreated1) < 1 { - t.Fatalf("Expected at least 1 message for client1, got %d", len(gameCreated1)) - } - - // Look for gameJoined message - var createdGameMsg GameJoinedMessage - foundGameJoined := false - for _, msg := range gameCreated1 { - var testMsg GameJoinedMessage - if err := json.Unmarshal(msg, &testMsg); err == nil && testMsg.Type == "gameJoined" { - createdGameMsg = testMsg - foundGameJoined = true - break - } - } - - if !foundGameJoined { - t.Fatal("Expected to find gameJoined message") - } - - if createdGameMsg.Type != "gameJoined" { - t.Errorf("Expected gameJoined message, got %s", createdGameMsg.Type) - } - - gameID := createdGameMsg.GameState.ID - if len(gameID) < 4 { - t.Errorf("Expected game ID with at least 4 characters, got %s", gameID) - } - - // Client 2 joins the game that client 1 created - client2.clearMessages() - joinGameMsg := `{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(joinGameMsg), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Check client 2 received roomStateUpdate message (broadcast when joining game) - messages4 := client2.getMessages() - if len(messages4) == 0 { - t.Fatal("Expected at least 1 message for client2") - } - - // Look for room state update in the messages (since joining a game broadcasts room state) - foundRoomUpdate := false - for _, msg := range messages4 { - var roomUpdateMsg RoomStateUpdateMessage - if err := json.Unmarshal(msg, &roomUpdateMsg); err == nil && roomUpdateMsg.Type == "roomStateUpdate" { - if roomUpdateMsg.RoomState.ID == roomID { - foundRoomUpdate = true - if len(roomUpdateMsg.RoomState.Players) != 2 { - t.Errorf("Expected 2 players in room, got %d", len(roomUpdateMsg.RoomState.Players)) - } - break - } - } - } - - if !foundRoomUpdate { - t.Error("Expected to receive room state update after joining game") - } - - client1.close() - client2.close() -} - -func TestHub_StartGame(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create and register clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Player 1 creates room (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - messages1 := client1.getMessages() - var joinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &joinedMsg) - roomID := joinedMsg.RoomState.ID - - // Player 2 joins the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Player 1 creates game (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get the game ID from player 1's gameJoined message - gameMessages := client1.getMessages() - var gameID string - for _, msg := range gameMessages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - gameID = gameJoinedMsg.GameState.ID - break - } - } - - // Player 2 joins the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Verify player 2 received gameJoined message - player2Messages := client2.getMessages() - foundGameJoined := false - var player2GameJoinedMsg GameJoinedMessage - - for _, msg := range player2Messages { - var testMsg struct { - Type string `json:"type"` - } - if err := json.Unmarshal(msg, &testMsg); err == nil && testMsg.Type == "gameJoined" { - if err := json.Unmarshal(msg, &player2GameJoinedMsg); err == nil { - foundGameJoined = true - break - } - } - } - - if !foundGameJoined { - t.Fatal("Player 2 should have received gameJoined message when joining the game") - } - - if player2GameJoinedMsg.Type != "gameJoined" { - t.Errorf("Expected gameJoined message for player 2, got %s", player2GameJoinedMsg.Type) - } - - if player2GameJoinedMsg.GameState.ID != gameID { - t.Errorf("Expected game ID %s in gameJoined message, got %s", gameID, player2GameJoinedMsg.GameState.ID) - } - - if len(player2GameJoinedMsg.GameState.Players) != 2 { - t.Errorf("Expected 2 players in game state, got %d", len(player2GameJoinedMsg.GameState.Players)) - } - - // Clear messages - client1.clearMessages() - client2.clearMessages() - - // Start game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "startGame"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Both clients should receive gameStarted message - messages1 = client1.getMessages() - messages2 := client2.getMessages() - - // Look for gameStarted message - foundStarted1 := false - foundStarted2 := false - - for _, msg := range messages1 { - var parsed map[string]interface{} - if err := json.Unmarshal(msg, &parsed); err == nil { - if parsed["type"] == "gameStarted" { - foundStarted1 = true - } - } - } - - for _, msg := range messages2 { - var parsed map[string]interface{} - if err := json.Unmarshal(msg, &parsed); err == nil { - if parsed["type"] == "gameStarted" { - foundStarted2 = true - } - } - } - - if !foundStarted1 { - t.Error("Client 1 did not receive gameStarted message") - } - if !foundStarted2 { - t.Error("Client 2 did not receive gameStarted message") - } - - client1.close() - client2.close() -} - -func TestHub_PeekCard(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Player 1 creates room (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get room ID from player 1's roomJoined message - messages1 := client1.getMessages() - var roomJoinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &roomJoinedMsg) - roomID := roomJoinedMsg.RoomState.ID - - // Player 2 joins the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Player 1 creates game (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get the game ID from player 1's gameJoined message - gameMessages := client1.getMessages() - var gameID string - for _, msg := range gameMessages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - gameID = gameJoinedMsg.GameState.ID - break - } - } - - // Player 2 joins the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Start the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "startGame"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Verify game ID is different from room ID (we already have gameID from earlier) - if gameID == roomID { - t.Fatal("Game ID should be different from room ID") - } - - client1.clearMessages() - - // Peek at card 0 - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "peekCard", "cardIndex": 0}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Should receive updated game state - messages := client1.getMessages() - if len(messages) == 0 { - t.Fatal("Expected game state update after peeking") - } - - // Find the game state message - var gameStateMsg *GameStateUpdateMessage - for _, msg := range messages { - var parsed GameStateUpdateMessage - if err := json.Unmarshal(msg, &parsed); err == nil && parsed.Type == "gameState" { - gameStateMsg = &parsed - break - } - } - - if gameStateMsg == nil { - t.Fatal("Did not receive game state update") - } - - // Check that player has peeked at 1 card - player := gameStateMsg.GameState.Players[0] - if len(player.RevealedCards) != 1 { - t.Errorf("Expected 1 revealed card, got %d", len(player.RevealedCards)) - } - - if player.RevealedCards[0] != 0 { - t.Errorf("Expected card 0 to be revealed, got %d", player.RevealedCards[0]) - } - - client1.close() - client2.close() -} - -func TestHub_GameFlow(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create two clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Player 1 creates room (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get room ID from player 1's roomJoined message - messages1 := client1.getMessages() - var roomJoinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &roomJoinedMsg) - roomID := roomJoinedMsg.RoomState.ID - - // Player 2 joins the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Player 1 creates game (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get the game ID from player 1's gameJoined message - gameMessages := client1.getMessages() - var gameID string - for _, msg := range gameMessages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - gameID = gameJoinedMsg.GameState.ID - break - } - } - - // Player 2 joins the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Start game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "startGame"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - client1.clearMessages() - client2.clearMessages() - - // Player 1 draws a card - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "drawCard"}`), - Sender: hubClient1, - }) - time.Sleep(50 * time.Millisecond) - - // Both players should receive updated game state - messages1 = client1.getMessages() - messages2 := client2.getMessages() - - if len(messages1) == 0 { - t.Error("Player 1 did not receive game state update") - } - if len(messages2) == 0 { - t.Error("Player 2 did not receive game state update") - } - - // Player 1 discards the drawn card - client1.clearMessages() - client2.clearMessages() - - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "discardDrawn"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Check for turn change message - messages2 = client2.getMessages() - foundTurnChange := false - for _, msg := range messages2 { - var parsed map[string]interface{} - if err := json.Unmarshal(msg, &parsed); err == nil { - if parsed["type"] == "turnChanged" { - foundTurnChange = true - } - } - } - - if !foundTurnChange { - t.Error("Did not receive turn change notification") - } - - client1.close() - client2.close() -} - -func TestHub_PlayerDisconnect(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create two clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Player 1 creates room (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get room ID from player 1's roomJoined message - messages1 := client1.getMessages() - var roomJoinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &roomJoinedMsg) - roomID := roomJoinedMsg.RoomState.ID - - // Player 2 joins the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Player 1 creates game (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get the game ID from player 1's gameJoined message - gameMessages := client1.getMessages() - var gameID string - for _, msg := range gameMessages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - gameID = gameJoinedMsg.GameState.ID - break - } - } - - // Player 2 joins the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - client2.clearMessages() - - // Player 1 disconnects - golfHub.Unregister(hubClient1) - time.Sleep(10 * time.Millisecond) - - // Player 2 should receive some kind of update after player 1 disconnect - messages2 := client2.getMessages() - if len(messages2) == 0 { - t.Error("Player 2 did not receive update after player 1 disconnect") - } - - // With the new multi-game architecture, we should receive a room state update - // since the disconnecting player affects the room's player list - var foundUpdate bool - for _, msg := range messages2 { - var roomStateMsg RoomStateUpdateMessage - if err := json.Unmarshal(msg, &roomStateMsg); err == nil && roomStateMsg.Type == "roomStateUpdate" { - foundUpdate = true - // Verify the room still exists and has the remaining player - if len(roomStateMsg.RoomState.Players) != 2 { - t.Errorf("Expected 2 players in room (one disconnected), got %d", len(roomStateMsg.RoomState.Players)) - } - break - } - } - - if !foundUpdate { - t.Error("Expected to receive room state update after player disconnect") - } - - client2.close() -} - -func TestHub_InvalidMessages(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - - golfHub.Register(hubClient1) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - - tests := []struct { - name string - msg string - }{ - { - name: "invalid JSON", - msg: `{"type":"createGame"`, - }, - { - name: "unknown message type", - msg: `{"type":"unknownType"}`, - }, - { - name: "join non-existent room", - msg: `{"type":"joinGame","roomId":"XXXXXX","gameId":"GAME1"}`, - }, - { - name: "start game when not in one", - msg: `{"type":"startGame"}`, - }, - { - name: "draw card when not in game", - msg: `{"type":"drawCard"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client1.clearMessages() - - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(tt.msg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - messages := client1.getMessages() - // Should receive error message - foundError := false - for _, msg := range messages { - var parsed ErrorMessage - if err := json.Unmarshal(msg, &parsed); err == nil && parsed.Type == "error" { - foundError = true - break - } - } - - if !foundError && tt.name != "invalid JSON" { - t.Errorf("Expected error message for %s", tt.name) - } - }) - } - - client1.close() -} - -// TestHub_MultiGameSupport tests the core multi-game functionality -func TestHub_MultiGameSupport(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create mock clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - client3 := newMockClient("client3") - client3.collectMessages() - - // Convert to hub clients - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - hubClient3 := &hub.Client{Hub: golfHub, Send: client3.send} - - // Register clients - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - golfHub.Register(hubClient3) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - authenticateMockClient(golfHub, hubClient3, client3) - - // Client 1 creates a room - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Extract room ID from client1's response - messages1 := client1.getMessages() - if len(messages1) != 1 { - t.Fatalf("Expected 1 message for client1, got %d", len(messages1)) - } - - var joinedMsg RoomJoinedMessage - if err := json.Unmarshal(messages1[0], &joinedMsg); err != nil { - t.Fatalf("Failed to parse roomJoined message: %v", err) - } - roomID := joinedMsg.RoomState.ID - - // Client 2 joins the room first - joinRoomMsg2 := `{"type": "joinRoom", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(joinRoomMsg2), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Client 3 joins the room first - joinRoomMsg3 := `{"type": "joinRoom", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(joinRoomMsg3), - Sender: hubClient3, - }) - time.Sleep(10 * time.Millisecond) - - // Client 2 creates game "GAME1" - createGame1Msg := `{"type": "createGame", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createGame1Msg), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Client 3 creates game "GAME2" - createGame2Msg := `{"type": "createGame", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createGame2Msg), - Sender: hubClient3, - }) - time.Sleep(10 * time.Millisecond) - - // Verify clients are in different games - hub := golfHub.(*GolfHub) - hub.mu.RLock() - context1 := hub.clientContexts[hubClient1] - context2 := hub.clientContexts[hubClient2] - context3 := hub.clientContexts[hubClient3] - - if context1.RoomID != roomID || context2.RoomID != roomID || context3.RoomID != roomID { - t.Error("All clients should be in the same room") - } - - if context1.GameID != "" { - t.Error("Client1 should not be in a specific game yet (created room, not joined game)") - } - - // Client2 and Client3 should be in different games (whatever IDs were auto-generated) - if context2.GameID == "" { - t.Error("Client2 should be in a game after creating one") - } - - if context3.GameID == "" { - t.Error("Client3 should be in a game after creating one") - } - - if context2.GameID == context3.GameID { - t.Error("Client2 and Client3 should be in different games") - } - - // Check that multiple games exist in the room - room := hub.rooms[roomID] - if len(room.Games) != 2 { - t.Errorf("Expected 2 games in room, got %d", len(room.Games)) - } - - // Verify the specific games exist (using the actual generated IDs) - if _, exists := room.Games[context2.GameID]; !exists { - t.Errorf("Game %s should exist in room", context2.GameID) - } - - if _, exists := room.Games[context3.GameID]; !exists { - t.Errorf("Game %s should exist in room", context3.GameID) - } - hub.mu.RUnlock() - - client1.close() - client2.close() - client3.close() -} - -// TestHub_GameIsolation tests that games are properly isolated -func TestHub_GameIsolation(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create mock clients - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - client3 := newMockClient("client3") - client3.collectMessages() - client4 := newMockClient("client4") - client4.collectMessages() - - // Convert to hub clients - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - hubClient3 := &hub.Client{Hub: golfHub, Send: client3.send} - hubClient4 := &hub.Client{Hub: golfHub, Send: client4.send} - - // Register clients - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - golfHub.Register(hubClient3) - golfHub.Register(hubClient4) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - authenticateMockClient(golfHub, hubClient3, client3) - authenticateMockClient(golfHub, hubClient4, client4) - - // Client 1 creates a room - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Extract room ID - messages1 := client1.getMessages() - var joinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &joinedMsg) - roomID := joinedMsg.RoomState.ID - - // All other clients join the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient3, - }) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient4, - }) - time.Sleep(20 * time.Millisecond) - - // Create two separate games - // Game 1: Client1 creates, Client2 joins - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get game1 ID from client1's response - game1Messages := client1.getMessages() - var game1ID string - for _, msg := range game1Messages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - game1ID = gameJoinedMsg.GameState.ID - break - } - } - - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + game1ID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Game 2: Client3 creates, Client4 joins - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient3, - }) - time.Sleep(10 * time.Millisecond) - - // Get game2 ID from client3's response - game2Messages := client3.getMessages() - var game2ID string - for _, msg := range game2Messages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - game2ID = gameJoinedMsg.GameState.ID - break - } - } - - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + game2ID + `"}`), - Sender: hubClient4, - }) - time.Sleep(20 * time.Millisecond) - - // Clear messages - client1.clearMessages() - client2.clearMessages() - client3.clearMessages() - client4.clearMessages() - - // Start game 1 - startMsg := `{"type": "startGame"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(startMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Only clients in game 1 should receive game started message - messages1After := client1.getMessages() - messages2After := client2.getMessages() - messages3After := client3.getMessages() - messages4After := client4.getMessages() - - if len(messages1After) == 0 || len(messages2After) == 0 { - t.Error("Clients 1 and 2 should have received game started messages") - } - - if len(messages3After) > 0 || len(messages4After) > 0 { - t.Error("Clients 3 and 4 should not have received any messages (they're in a different game)") - } - - client1.close() - client2.close() - client3.close() - client4.close() -} - -// TestHub_RequiredGameID tests that gameId is now required for joining -func TestHub_RequiredGameID(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Client 1 creates room - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Extract room ID - messages1 := client1.getMessages() - var joinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &joinedMsg) - roomID := joinedMsg.RoomState.ID - - // Try to join without gameId - should fail - joinMsgNoGameID := `{"type": "joinGame", "roomId": "` + roomID + `"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(joinMsgNoGameID), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Check that client2 received an error - messages2 := client2.getMessages() - if len(messages2) != 1 { - t.Fatalf("Expected 1 error message for client2, got %d", len(messages2)) - } - - var errorMsg ErrorMessage - if err := json.Unmarshal(messages2[0], &errorMsg); err != nil { - t.Fatalf("Failed to parse error message: %v", err) - } - - if errorMsg.Type != "error" { - t.Errorf("Expected error message, got %s", errorMsg.Type) - } - - if errorMsg.Message != "Game ID is required" { - t.Errorf("Expected 'Game ID is required' error, got '%s'", errorMsg.Message) - } - - client1.close() - client2.close() -} - -// TestHub_GameCleanup tests that completed games are cleaned up -func TestHub_GameCleanup(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Player 1 creates room (automatically joins) - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - messages1 := client1.getMessages() - var joinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &joinedMsg) - roomID := joinedMsg.RoomState.ID - - // Player 2 joins the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Player 1 creates game (automatically joins) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get the game ID from player 1's gameJoined message - gameMessages := client1.getMessages() - var gameID string - for _, msg := range gameMessages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - gameID = gameJoinedMsg.GameState.ID - break - } - } - - // Player 2 joins the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(20 * time.Millisecond) - - // Verify game exists - hub := golfHub.(*GolfHub) - hub.mu.RLock() - room := hub.rooms[roomID] - if len(room.Games) != 1 { - t.Errorf("Expected 1 game before completion, got %d", len(room.Games)) - } - game := room.Games[gameID] - if game == nil { - t.Fatal("Game should exist before completion") - } - hub.mu.RUnlock() - - // Simulate game completion by setting game phase to "ended" - game.mu.Lock() - game.state.GamePhase = "ended" - game.mu.Unlock() - - // Trigger game completion - hub.mu.Lock() - err := hub.completeGameInRoom(roomID, gameID) - hub.mu.Unlock() - - if err != nil { - t.Fatalf("Game completion failed: %v", err) - } - - // Verify game was removed from active games - hub.mu.RLock() - room = hub.rooms[roomID] - if len(room.Games) != 0 { - t.Errorf("Expected 0 games after completion, got %d", len(room.Games)) - } - - // Verify game was added to history - if len(room.GameHistory) != 1 { - t.Errorf("Expected 1 game in history, got %d", len(room.GameHistory)) - } - hub.mu.RUnlock() - - client1.close() - client2.close() -} - -// Double Join Prevention Tests - -func TestHub_JoinGameTwice(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - client2 := newMockClient("client2") - client2.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - - // Client 1 creates room - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Extract room ID - messages1 := client1.getMessages() - var joinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &joinedMsg) - roomID := joinedMsg.RoomState.ID - - // Client 2 joins the room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Client 1 creates a game - client1.clearMessages() - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "createGame", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Get game ID from client1's response - game1Messages := client1.getMessages() - var gameID string - for _, msg := range game1Messages { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - gameID = gameJoinedMsg.GameState.ID - break - } - } - - if gameID == "" { - t.Fatal("Failed to get game ID from client1") - } - - // Client 2 joins the game - client2.clearMessages() - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Verify client 2 successfully joined - messages2 := client2.getMessages() - foundGameJoined := false - for _, msg := range messages2 { - var gameJoinedMsg GameJoinedMessage - if err := json.Unmarshal(msg, &gameJoinedMsg); err == nil && gameJoinedMsg.Type == "gameJoined" { - foundGameJoined = true - break - } - } - if !foundGameJoined { - t.Fatal("Client 2 should have received gameJoined message") - } - - // Now client 2 tries to join the same game again - client2.clearMessages() - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinGame", "roomId": "` + roomID + `", "gameId": "` + gameID + `"}`), - Sender: hubClient2, - }) - time.Sleep(10 * time.Millisecond) - - // Client 2 should receive an error message - messages2 = client2.getMessages() - foundError := false - var errorMsg ErrorMessage - for _, msg := range messages2 { - if err := json.Unmarshal(msg, &errorMsg); err == nil && errorMsg.Type == "error" { - foundError = true - break - } - } - - if !foundError { - t.Error("Expected error message when player tries to join game twice") - } - - if errorMsg.Message != "player already in game" { - t.Errorf("Expected 'player already in game' error, got: %s", errorMsg.Message) - } - - // Verify that the game still has only 2 players - hub := golfHub.(*GolfHub) - hub.mu.RLock() - room := hub.rooms[roomID] - game := room.Games[gameID] - if len(game.state.Players) != 2 { - t.Errorf("Expected 2 players in game after double-join attempt, got %d", len(game.state.Players)) - } - hub.mu.RUnlock() - - client1.close() - client2.close() -} - -func TestHub_JoinRoomTwice(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - client1 := newMockClient("client1") - client1.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - - golfHub.Register(hubClient1) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - - // Client 1 creates room (automatically joins) - createMsg := `{"type": "createRoom"}` - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(createMsg), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Extract room ID - messages1 := client1.getMessages() - var joinedMsg RoomJoinedMessage - json.Unmarshal(messages1[0], &joinedMsg) - roomID := joinedMsg.RoomState.ID - - // Verify client 1 successfully created and joined the room - if joinedMsg.Type != "roomJoined" { - t.Fatal("Client 1 should have received roomJoined message") - } - - if len(joinedMsg.RoomState.Players) != 1 { - t.Errorf("Expected 1 player in room, got %d", len(joinedMsg.RoomState.Players)) - } - - // Now client 1 tries to join the same room again - client1.clearMessages() - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type": "joinRoom", "roomId": "` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - // Client 1 should receive an error message - messages1 = client1.getMessages() - foundError := false - var errorMsg ErrorMessage - for _, msg := range messages1 { - if err := json.Unmarshal(msg, &errorMsg); err == nil && errorMsg.Type == "error" { - foundError = true - break - } - } - - if !foundError { - t.Error("Expected error message when player tries to join room twice") - } - - if errorMsg.Message != "player already in room" { - t.Errorf("Expected 'player already in room' error, got: %s", errorMsg.Message) - } - - // Verify that the room still has only 1 player - hub := golfHub.(*GolfHub) - hub.mu.RLock() - room := hub.rooms[roomID] - if len(room.Players) != 1 { - t.Errorf("Expected 1 player in room after double-join attempt, got %d", len(room.Players)) - } - hub.mu.RUnlock() - - client1.close() -} - -// TestGetClientID_UsesIDFieldOverRemoteAddr verifies that when clients connect -// through a proxy, their server-assigned UUID is used for identification rather -// than RemoteAddr. Without this, two clients sharing the same proxy IP would -// collide and be treated as the same player. -func TestGetClientID_UsesIDFieldOverRemoteAddr(t *testing.T) { - t.Run("prefers ID field when set", func(t *testing.T) { - client := &hub.Client{ID: "server-assigned-uuid", Send: make(chan []byte, 1)} - if got := getClientID(client); got != "server-assigned-uuid" { - t.Errorf("expected server-assigned-uuid, got %s", got) - } - }) - - t.Run("two clients with same RemoteAddr get distinct IDs via ID field", func(t *testing.T) { - // Simulates the proxy collision scenario: both clients have nil Conn - // but differ only by their server-assigned ID. - client1 := &hub.Client{ID: "uuid-1", Send: make(chan []byte, 1)} - client2 := &hub.Client{ID: "uuid-2", Send: make(chan []byte, 1)} - if getClientID(client1) == getClientID(client2) { - t.Error("clients with different IDs must not share a client ID") - } - }) - - t.Run("falls back to test-client pointer when ID and Conn are both unset", func(t *testing.T) { - client := &hub.Client{Send: make(chan []byte, 1)} - id := getClientID(client) - if id == "" { - t.Error("expected non-empty fallback ID for test client") - } - }) -} - -func TestHub_LeaveGame(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}) - go golfHub.Run() - - // Create 3 clients - client1 := newMockClient("c1") - client1.collectMessages() - client2 := newMockClient("c2") - client2.collectMessages() - client3 := newMockClient("c3") - client3.collectMessages() - - hubClient1 := &hub.Client{Hub: golfHub, Send: client1.send} - hubClient2 := &hub.Client{Hub: golfHub, Send: client2.send} - hubClient3 := &hub.Client{Hub: golfHub, Send: client3.send} - - golfHub.Register(hubClient1) - golfHub.Register(hubClient2) - golfHub.Register(hubClient3) - time.Sleep(10 * time.Millisecond) - authenticateMockClient(golfHub, hubClient1, client1) - authenticateMockClient(golfHub, hubClient2, client2) - authenticateMockClient(golfHub, hubClient3, client3) - - // Client 1 creates room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"createRoom"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - - msgs := client1.getMessages() - var roomMsg RoomJoinedMessage - json.Unmarshal(msgs[0], &roomMsg) - roomID := roomMsg.RoomState.ID - client1.clearMessages() - - // Clients 2 and 3 join room - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"joinRoom","roomId":"` + roomID + `"}`), - Sender: hubClient2, - }) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"joinRoom","roomId":"` + roomID + `"}`), - Sender: hubClient3, - }) - time.Sleep(10 * time.Millisecond) - client1.clearMessages() - client2.clearMessages() - client3.clearMessages() - - // Client 1 creates game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"createGame","roomId":"` + roomID + `"}`), - Sender: hubClient1, - }) - time.Sleep(100 * time.Millisecond) - - msgs = client1.getMessages() - var gameMsg GameJoinedMessage - for _, m := range msgs { - if json.Unmarshal(m, &gameMsg) == nil && gameMsg.Type == "gameJoined" { - break - } - } - gameID := gameMsg.GameState.ID - client1.clearMessages() - client2.clearMessages() - client3.clearMessages() - - // Clients 2 and 3 join game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"joinGame","roomId":"` + roomID + `","gameId":"` + gameID + `"}`), - Sender: hubClient2, - }) - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"joinGame","roomId":"` + roomID + `","gameId":"` + gameID + `"}`), - Sender: hubClient3, - }) - time.Sleep(100 * time.Millisecond) - client1.clearMessages() - client2.clearMessages() - client3.clearMessages() - - // Start game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"startGame"}`), - Sender: hubClient1, - }) - time.Sleep(10 * time.Millisecond) - client1.clearMessages() - client2.clearMessages() - client3.clearMessages() - - t.Run("leave game returns player to room", func(t *testing.T) { - // Client 3 leaves the game - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"leaveGame"}`), - Sender: hubClient3, - }) - time.Sleep(100 * time.Millisecond) - - // Client 3's context should have no game - golfHub.(*GolfHub).mu.RLock() - ctx3 := golfHub.(*GolfHub).clientContexts[hubClient3] - golfHub.(*GolfHub).mu.RUnlock() - - if ctx3 == nil { - t.Fatal("Expected client3 context to still exist") - } - if ctx3.GameID != "" { - t.Errorf("Expected empty GameID after leaving, got %s", ctx3.GameID) - } - if ctx3.RoomID != roomID { - t.Errorf("Expected RoomID %s, got %s", roomID, ctx3.RoomID) - } - }) - - t.Run("game still active with 2 players", func(t *testing.T) { - golfHub.(*GolfHub).mu.RLock() - room := golfHub.(*GolfHub).rooms[roomID] - game := room.Games[gameID] - golfHub.(*GolfHub).mu.RUnlock() - - if game == nil { - t.Fatal("Expected game to still exist") - } - if game.state.GamePhase == "ended" { - t.Error("Game should not have ended — still 2 players") - } - }) - - t.Run("leave game ends game when fewer than 2 players remain", func(t *testing.T) { - client1.clearMessages() - client2.clearMessages() - - // Client 2 leaves — only 1 player left, game should end - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"leaveGame"}`), - Sender: hubClient2, - }) - time.Sleep(100 * time.Millisecond) - - // Check that client 1 received a gameEnded message - msgs := client1.getMessages() - foundGameEnded := false - for _, m := range msgs { - var parsed map[string]interface{} - if json.Unmarshal(m, &parsed) == nil && parsed["type"] == "gameEnded" { - foundGameEnded = true - break - } - } - if !foundGameEnded { - t.Error("Expected client1 to receive gameEnded message") - } - }) - - t.Run("leave game when not in a game returns error", func(t *testing.T) { - client3.clearMessages() - golfHub.GameMessage(hub.GameMessageData{ - Message: []byte(`{"type":"leaveGame"}`), - Sender: hubClient3, - }) - time.Sleep(10 * time.Millisecond) - - msgs := client3.getMessages() - foundError := false - for _, m := range msgs { - var parsed map[string]interface{} - if json.Unmarshal(m, &parsed) == nil && parsed["type"] == "error" { - foundError = true - break - } - } - if !foundError { - t.Error("Expected error when leaving game while not in one") - } - }) -} - -// Stats credit for shared wins (issue #1187 phase 0): every tied winner's -// GamesWon increments. Under the old exact-match against the display string -// ("Alice & Bob"), nobody got credit on a tie. -func TestCompleteGameInRoom_SharedWinStats(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}).(*GolfHub) - - game := NewGame("GAME01", &players.DeterministicIDGenerator{}) - game.AddPlayer("client1", "p1", "Alice") - game.AddPlayer("client2", "p2", "Bob") - game.AddPlayer("client3", "p3", "Carol") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Alice and Bob tie at zero; Carol scores 39. Nobody knocked. - game.state.Players[0].Cards = []*Card{ - {Rank: "A", Suit: "♠"}, {Rank: "A", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, {Rank: "3", Suit: "♣"}, - } - game.state.Players[1].Cards = []*Card{ - {Rank: "5", Suit: "♠"}, {Rank: "5", Suit: "♥"}, - {Rank: "9", Suit: "♦"}, {Rank: "9", Suit: "♣"}, - } - game.state.Players[2].Cards = []*Card{ - {Rank: "K", Suit: "♠"}, {Rank: "Q", Suit: "♥"}, - {Rank: "10", Suit: "♦"}, {Rank: "9", Suit: "♣"}, - } - game.state.GamePhase = "ended" - game.calculateFinalScores() - - alice := &Player{Name: "Alice"} - bob := &Player{Name: "Bob"} - carol := &Player{Name: "Carol"} - room := &Room{ - ID: "ROOM01", - Players: []*Player{alice, bob, carol}, - Games: map[string]*Game{"GAME01": game}, - } - golfHub.rooms["ROOM01"] = room - - if err := golfHub.completeGameInRoom("ROOM01", "GAME01"); err != nil { - t.Fatalf("completeGameInRoom failed: %v", err) - } - - if alice.GamesWon != 1 || bob.GamesWon != 1 { - t.Errorf("Both shared winners must get credit, got Alice=%d Bob=%d", - alice.GamesWon, bob.GamesWon) - } - if carol.GamesWon != 0 { - t.Errorf("Loser must not get win credit, got %d", carol.GamesWon) - } - for _, p := range []*Player{alice, bob, carol} { - if p.GamesPlayed != 1 { - t.Errorf("Expected GamesPlayed 1 for %s, got %d", p.Name, p.GamesPlayed) - } - } - if carol.TotalScore != 39 { - t.Errorf("Expected Carol's total score 39, got %d", carol.TotalScore) - } - - if len(room.GameHistory) != 1 { - t.Fatalf("Expected 1 game in history, got %d", len(room.GameHistory)) - } - result := room.GameHistory[0] - if result.Winner != "Alice & Bob" { - t.Errorf("Expected display winner 'Alice & Bob', got %q", result.Winner) - } - if len(result.Winners) != 2 || result.Winners[0] != "Alice" || result.Winners[1] != "Bob" { - t.Errorf("Expected Winners == [Alice Bob], got %v", result.Winners) - } - if len(room.Games) != 0 { - t.Errorf("Expected game removed from active games, got %d", len(room.Games)) - } -} - -// The knocker-alone rule at the stats layer: a knocker who ties keeps the -// sole credit, and the tied non-knocker gets none. -func TestCompleteGameInRoom_KnockerTieSoloCredit(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}).(*GolfHub) - - game := NewGame("GAME01", &players.DeterministicIDGenerator{}) - game.AddPlayer("client1", "p1", "Alice") - game.AddPlayer("client2", "p2", "Bob") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - game.state.Players[0].Cards = []*Card{ - {Rank: "A", Suit: "♠"}, {Rank: "A", Suit: "♥"}, - {Rank: "3", Suit: "♦"}, {Rank: "3", Suit: "♣"}, - } - game.state.Players[1].Cards = []*Card{ - {Rank: "5", Suit: "♠"}, {Rank: "5", Suit: "♥"}, - {Rank: "9", Suit: "♦"}, {Rank: "9", Suit: "♣"}, - } - game.state.KnockedPlayerID = &game.state.Players[1].ID - game.state.GamePhase = "ended" - game.calculateFinalScores() - - alice := &Player{Name: "Alice"} - bob := &Player{Name: "Bob"} - room := &Room{ - ID: "ROOM01", - Players: []*Player{alice, bob}, - Games: map[string]*Game{"GAME01": game}, - } - golfHub.rooms["ROOM01"] = room - - if err := golfHub.completeGameInRoom("ROOM01", "GAME01"); err != nil { - t.Fatalf("completeGameInRoom failed: %v", err) - } - - if bob.GamesWon != 1 { - t.Errorf("Knocker must get the sole credit, got %d", bob.GamesWon) - } - if alice.GamesWon != 0 { - t.Errorf("Tied non-knocker must not get credit when the knocker ties, got %d", alice.GamesWon) - } - if result := room.GameHistory[0]; result.Winner != "Bob" || len(result.Winners) != 1 { - t.Errorf("Expected solo winner Bob, got %q / %v", result.Winner, result.Winners) - } -} - -// Negative paths: unknown room, unknown game, and a game still in progress -// must all error without touching stats or history. -func TestCompleteGameInRoom_Errors(t *testing.T) { - golfHub := NewGolfHub(&players.DeterministicIDGenerator{}).(*GolfHub) - - game := NewGame("GAME01", &players.DeterministicIDGenerator{}) - game.AddPlayer("client1", "p1", "Alice") - game.AddPlayer("client2", "p2", "Bob") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - alice := &Player{Name: "Alice"} - bob := &Player{Name: "Bob"} - room := &Room{ - ID: "ROOM01", - Players: []*Player{alice, bob}, - Games: map[string]*Game{"GAME01": game}, - } - golfHub.rooms["ROOM01"] = room - - if err := golfHub.completeGameInRoom("NOROOM", "GAME01"); err == nil { - t.Error("Expected error for unknown room") - } - if err := golfHub.completeGameInRoom("ROOM01", "NOGAME"); err == nil { - t.Error("Expected error for unknown game") - } - if err := golfHub.completeGameInRoom("ROOM01", "GAME01"); err == nil { - t.Error("Expected error for a game still in progress") - } - - if alice.GamesPlayed != 0 || bob.GamesPlayed != 0 || alice.GamesWon != 0 || bob.GamesWon != 0 { - t.Error("Failed completion must not touch player stats") - } - if len(room.GameHistory) != 0 { - t.Errorf("Failed completion must not append history, got %d entries", len(room.GameHistory)) - } - if len(room.Games) != 1 { - t.Errorf("Failed completion must not remove the game, got %d games", len(room.Games)) - } -} diff --git a/domains/games/apis/games_ws_backend/golf/integration_test.go b/domains/games/apis/games_ws_backend/golf/integration_test.go deleted file mode 100644 index f2842c2f8..000000000 --- a/domains/games/apis/games_ws_backend/golf/integration_test.go +++ /dev/null @@ -1,1405 +0,0 @@ -package golf - -import ( - "encoding/json" - "fmt" - "sync" - "testing" - "time" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/hub" - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -// TestClient provides a more feature-rich mock client for integration testing -type TestClient struct { - id string - messages [][]byte - send chan []byte - closed bool - mu sync.Mutex - lastMessageTime time.Time -} - -func NewTestClient(id string) *TestClient { - return &TestClient{ - id: id, - messages: make([][]byte, 0), - send: make(chan []byte, 256), - lastMessageTime: time.Now(), - } -} - -func (tc *TestClient) StartCollecting() { - go func() { - for msg := range tc.send { - tc.mu.Lock() - if !tc.closed { - tc.messages = append(tc.messages, msg) - tc.lastMessageTime = time.Now() - } - tc.mu.Unlock() - } - }() -} - -func (tc *TestClient) GetMessages() [][]byte { - tc.mu.Lock() - defer tc.mu.Unlock() - result := make([][]byte, len(tc.messages)) - copy(result, tc.messages) - return result -} - -func (tc *TestClient) ClearMessages() { - tc.mu.Lock() - defer tc.mu.Unlock() - tc.messages = nil -} - -func (tc *TestClient) RemoveMessagesByType(msgType string) { - tc.mu.Lock() - defer tc.mu.Unlock() - filtered := make([][]byte, 0, len(tc.messages)) - for _, msg := range tc.messages { - var parsed map[string]interface{} - if json.Unmarshal(msg, &parsed) == nil && parsed["type"] == msgType { - continue - } - filtered = append(filtered, msg) - } - tc.messages = filtered -} - -func (tc *TestClient) Close() { - tc.mu.Lock() - defer tc.mu.Unlock() - if !tc.closed { - tc.closed = true - close(tc.send) - } -} - -func (tc *TestClient) GetLastMessage() ([]byte, bool) { - tc.mu.Lock() - defer tc.mu.Unlock() - if len(tc.messages) == 0 { - return nil, false - } - return tc.messages[len(tc.messages)-1], true -} - -func (tc *TestClient) WaitForMessages(expectedCount int, timeout time.Duration) bool { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - tc.mu.Lock() - count := len(tc.messages) - tc.mu.Unlock() - if count >= expectedCount { - return true - } - time.Sleep(5 * time.Millisecond) - } - return false -} - -func (tc *TestClient) FindMessageByType(msgType string) ([]byte, bool) { - tc.mu.Lock() - defer tc.mu.Unlock() - for _, msg := range tc.messages { - var parsed map[string]interface{} - if err := json.Unmarshal(msg, &parsed); err == nil { - if parsed["type"] == msgType { - return msg, true - } - } - } - return nil, false -} - -// TestEnvironment manages the test environment with hub and multiple clients -type TestEnvironment struct { - hub *GolfHub - clients map[string]*TestClient - hubClients map[string]*hub.Client - // sessionTokens maps client id → JWT session token for reconnection testing - sessionTokens map[string]string - running bool - mu sync.RWMutex -} - -func NewTestEnvironment() *TestEnvironment { - env := &TestEnvironment{ - hub: NewGolfHub(&players.DeterministicIDGenerator{}).(*GolfHub), - clients: make(map[string]*TestClient), - hubClients: make(map[string]*hub.Client), - sessionTokens: make(map[string]string), - } - go env.hub.Run() - env.running = true - return env -} - -// CreateClient creates a new client and authenticates it. -// This handles the authenticate handshake automatically. -func (env *TestEnvironment) CreateClient(id string) *TestClient { - return env.createClientWithToken(id, "") -} - -// CreateReconnectingClient creates a new client that authenticates with an existing session token. -func (env *TestEnvironment) CreateReconnectingClient(id string, token string) *TestClient { - return env.createClientWithToken(id, token) -} - -func (env *TestEnvironment) createClientWithToken(id string, token string) *TestClient { - env.mu.Lock() - - testClient := NewTestClient(id) - testClient.StartCollecting() - - hubClient := &hub.Client{Hub: env.hub, Send: testClient.send} - env.hub.Register(hubClient) - - env.clients[id] = testClient - env.hubClients[id] = hubClient - env.mu.Unlock() - - // Send authenticate message - authMsg := fmt.Sprintf(`{"type":"authenticate","sessionToken":"%s"}`, token) - env.hub.GameMessage(hub.GameMessageData{ - Message: []byte(authMsg), - Sender: hubClient, - }) - - // Wait for authenticated response (and any reconnect restore messages - // that now arrive synchronously with it) - testClient.WaitForMessages(1, 200*time.Millisecond) - // Brief pause to let any additional synchronous messages arrive - time.Sleep(20 * time.Millisecond) - - // Extract and store session token - if authResp, found := testClient.FindMessageByType("authenticated"); found { - var parsed map[string]interface{} - if err := json.Unmarshal(authResp, &parsed); err == nil { - if tok, ok := parsed["sessionToken"].(string); ok { - env.mu.Lock() - env.sessionTokens[id] = tok - env.mu.Unlock() - } - } - } - - // Remove only the authenticated message, preserving any reconnect - // restore messages (roomJoined, gameJoined) for the test to inspect. - testClient.RemoveMessagesByType("authenticated") - - return testClient -} - -// GetSessionToken returns the stored session token for a client. -func (env *TestEnvironment) GetSessionToken(id string) string { - env.mu.RLock() - defer env.mu.RUnlock() - return env.sessionTokens[id] -} - -func (env *TestEnvironment) SendMessage(clientID string, message string) error { - env.mu.RLock() - hubClient, exists := env.hubClients[clientID] - env.mu.RUnlock() - - if !exists { - return fmt.Errorf("client %s not found", clientID) - } - - env.hub.GameMessage(hub.GameMessageData{ - Message: []byte(message), - Sender: hubClient, - }) - - return nil -} - -func (env *TestEnvironment) Cleanup() { - env.mu.Lock() - defer env.mu.Unlock() - - for _, client := range env.clients { - client.Close() // Close() is now idempotent - } - env.clients = make(map[string]*TestClient) - env.hubClients = make(map[string]*hub.Client) - env.sessionTokens = make(map[string]string) -} - -func (env *TestEnvironment) WaitForStabilization() { - time.Sleep(50 * time.Millisecond) -} - -// State validation helpers -func (env *TestEnvironment) ValidateRoomState(roomID string) error { - env.hub.mu.RLock() - defer env.hub.mu.RUnlock() - - room, exists := env.hub.rooms[roomID] - if !exists { - return fmt.Errorf("room %s does not exist", roomID) - } - - // Validate room invariants - if len(room.Players) > 4 { - return fmt.Errorf("room has too many players: %d", len(room.Players)) - } - - if len(room.Players) == 0 { - return fmt.Errorf("room should not have 0 players") - } - - // Validate each game in the room - for gameID, game := range room.Games { - if err := env.ValidateGameState(gameID, game); err != nil { - return fmt.Errorf("invalid game %s in room: %w", gameID, err) - } - } - - return nil -} - -func (env *TestEnvironment) ValidateGameState(gameID string, game *Game) error { - game.mu.RLock() - defer game.mu.RUnlock() - - // Validate game invariants - if len(game.state.Players) > 4 { - return fmt.Errorf("game has too many players: %d", len(game.state.Players)) - } - - if len(game.state.Players) > 0 && game.state.CurrentPlayerIndex >= len(game.state.Players) { - return fmt.Errorf("currentPlayerIndex %d out of bounds for %d players", - game.state.CurrentPlayerIndex, len(game.state.Players)) - } - - // Validate game phase transitions - validPhases := map[string]bool{ - "waiting": true, "playing": true, "peeking": true, "knocked": true, "ended": true, - } - if !validPhases[game.state.GamePhase] { - return fmt.Errorf("invalid game phase: %s", game.state.GamePhase) - } - - // Validate player card counts in started games - if game.state.GamePhase != "waiting" { - for _, player := range game.state.Players { - if len(player.Cards) != 4 { - return fmt.Errorf("player %s has %d cards, expected 4", player.Name, len(player.Cards)) - } - - // Validate revealed cards indices - for _, idx := range player.RevealedCards { - if idx < 0 || idx > 3 { - return fmt.Errorf("player %s has invalid revealed card index: %d", player.Name, idx) - } - } - - if len(player.RevealedCards) > 2 { - return fmt.Errorf("player %s has too many revealed cards: %d", player.Name, len(player.RevealedCards)) - } - } - } - - return nil -} - -func (env *TestEnvironment) GetRoomID(clientID string) (string, error) { - env.hub.mu.RLock() - defer env.hub.mu.RUnlock() - - for client, ctx := range env.hub.clientContexts { - if getClientID(client) == clientID { - return ctx.RoomID, nil - } - } - return "", fmt.Errorf("client %s not found or not in room", clientID) -} - -func (env *TestEnvironment) GetGameID(clientID string) (string, error) { - env.hub.mu.RLock() - defer env.hub.mu.RUnlock() - - for client, ctx := range env.hub.clientContexts { - if getClientID(client) == clientID { - return ctx.GameID, nil - } - } - return "", fmt.Errorf("client %s not found or not in game", clientID) -} - -// Integration Tests - -func TestIntegration_CompleteRoomLifecycle(t *testing.T) { - env := NewTestEnvironment() - defer env.Cleanup() - - // Create clients - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - _ = env.CreateClient("charlie") // charlie for testing multiple joins - - // Test room creation - err := env.SendMessage(alice.id, `{"type": "createRoom"}`) - if err != nil { - t.Fatalf("Failed to create room: %v", err) - } - - if !alice.WaitForMessages(1, 100*time.Millisecond) { - t.Fatal("Alice didn't receive room creation response") - } - - // Extract room ID - msg, _ := alice.FindMessageByType("roomJoined") - var roomJoined RoomJoinedMessage - if err := json.Unmarshal(msg, &roomJoined); err != nil { - t.Fatalf("Failed to parse roomJoined: %v", err) - } - roomID := roomJoined.RoomState.ID - - // Validate initial room state - if err := env.ValidateRoomState(roomID); err != nil { - t.Fatalf("Invalid room state after creation: %v", err) - } - - if len(roomJoined.RoomState.Players) != 1 { - t.Errorf("Expected 1 player in new room, got %d", len(roomJoined.RoomState.Players)) - } - - if roomJoined.PlayerID == "" { - t.Error("PlayerID should be set in roomJoined message") - } - - // Test joining room - err = env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Failed to join room: %v", err) - } - - if !bob.WaitForMessages(1, 100*time.Millisecond) { - t.Fatal("Bob didn't receive room join response") - } - - // Validate room state after join - if err := env.ValidateRoomState(roomID); err != nil { - t.Fatalf("Invalid room state after join: %v", err) - } - - // Test third player joining - charlie := env.CreateClient("charlie") - err = env.SendMessage(charlie.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Failed to join room: %v", err) - } - - env.WaitForStabilization() - - // Validate final room state - if err := env.ValidateRoomState(roomID); err != nil { - t.Fatalf("Invalid room state after third join: %v", err) - } - - // Test leaving room - err = env.SendMessage(bob.id, fmt.Sprintf(`{"type": "leaveRoom", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Failed to leave room: %v", err) - } - - env.WaitForStabilization() - - // Validate room state after leave - if err := env.ValidateRoomState(roomID); err != nil { - t.Fatalf("Invalid room state after leave: %v", err) - } -} - -func TestIntegration_GameCreationAndJoining(t *testing.T) { - env := NewTestEnvironment() - defer env.Cleanup() - - // Setup room with 3 players - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - charlie := env.CreateClient("charlie") - - // Alice creates room - env.SendMessage(alice.id, `{"type": "createRoom"}`) - alice.WaitForMessages(1, 100*time.Millisecond) - - msg, _ := alice.FindMessageByType("roomJoined") - var roomJoined RoomJoinedMessage - json.Unmarshal(msg, &roomJoined) - roomID := roomJoined.RoomState.ID - - // Others join room - env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - env.SendMessage(charlie.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - env.WaitForStabilization() - - // Test game creation - alice.ClearMessages() - err := env.SendMessage(alice.id, fmt.Sprintf(`{"type": "createGame", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Failed to create game: %v", err) - } - - if !alice.WaitForMessages(1, 500*time.Millisecond) { // gameJoined (roomStateUpdate may not always be sent) - t.Fatal("Alice didn't receive game creation responses") - } - - // Extract game ID - gameMsg, found := alice.FindMessageByType("gameJoined") - if !found { - t.Fatal("Alice didn't receive gameJoined message") - } - - var gameJoined GameJoinedMessage - if err := json.Unmarshal(gameMsg, &gameJoined); err != nil { - t.Fatalf("Failed to parse gameJoined: %v", err) - } - gameID := gameJoined.GameState.ID - - // Validate game state - env.hub.mu.RLock() - room := env.hub.rooms[roomID] - game := room.Games[gameID] - env.hub.mu.RUnlock() - - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Invalid game state after creation: %v", err) - } - - if len(gameJoined.GameState.Players) != 1 { - t.Errorf("Expected 1 player in new game, got %d", len(gameJoined.GameState.Players)) - } - - // Test joining existing game - bob.ClearMessages() - err = env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinGame", "roomId": "%s", "gameId": "%s"}`, roomID, gameID)) - if err != nil { - t.Fatalf("Failed to join game: %v", err) - } - - if !bob.WaitForMessages(2, 200*time.Millisecond) { // gameJoined + roomStateUpdate - t.Fatal("Bob didn't receive game join responses") - } - - // Validate game state after join - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Invalid game state after join: %v", err) - } - - // Test creating second game in same room - charlie.ClearMessages() - err = env.SendMessage(charlie.id, fmt.Sprintf(`{"type": "createGame", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Failed to create second game: %v", err) - } - - if !charlie.WaitForMessages(2, 200*time.Millisecond) { - t.Fatal("Charlie didn't receive second game creation responses") - } - - // Validate multiple games exist - env.hub.mu.RLock() - room = env.hub.rooms[roomID] - gameCount := len(room.Games) - env.hub.mu.RUnlock() - - if gameCount != 2 { - t.Errorf("Expected 2 games in room, got %d", gameCount) - } -} - -func TestIntegration_CompleteGameFlow(t *testing.T) { - env := NewTestEnvironment() - defer env.Cleanup() - - // Setup game with 2 players - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - - // Create room and game - env.SendMessage(alice.id, `{"type": "createRoom"}`) - alice.WaitForMessages(1, 100*time.Millisecond) - - msg, found := alice.FindMessageByType("roomJoined") - if !found { - t.Fatal("Alice didn't receive roomJoined message") - } - var roomJoined RoomJoinedMessage - json.Unmarshal(msg, &roomJoined) - roomID := roomJoined.RoomState.ID - - env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - env.WaitForStabilization() - - // Clear messages before game creation to avoid confusion - alice.ClearMessages() - - env.SendMessage(alice.id, fmt.Sprintf(`{"type": "createGame", "roomId": "%s"}`, roomID)) - alice.WaitForMessages(1, 500*time.Millisecond) // Wait for gameJoined message - - gameMsg, found := alice.FindMessageByType("gameJoined") - if !found { - t.Fatal("Alice didn't receive gameJoined message") - } - var gameJoined GameJoinedMessage - if err := json.Unmarshal(gameMsg, &gameJoined); err != nil { - t.Fatalf("Failed to unmarshal gameJoined: %v", err) - } - gameID := gameJoined.GameState.ID - - env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinGame", "roomId": "%s", "gameId": "%s"}`, roomID, gameID)) - env.WaitForStabilization() - - // Start game - alice.ClearMessages() - bob.ClearMessages() - - err := env.SendMessage(alice.id, `{"type": "startGame"}`) - if err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Both players should receive gameStarted - if !alice.WaitForMessages(2, 200*time.Millisecond) { // gameStarted + gameState - t.Fatal("Alice didn't receive game start messages") - } - if !bob.WaitForMessages(2, 200*time.Millisecond) { - t.Fatal("Bob didn't receive game start messages") - } - - // Validate game state after start - env.hub.mu.RLock() - room := env.hub.rooms[roomID] - game := room.Games[gameID] - env.hub.mu.RUnlock() - - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Invalid game state after start: %v", err) - } - - if game.state.GamePhase != "playing" { - t.Errorf("Expected playing phase, got %s", game.state.GamePhase) - } - - // Test peeking phase - alice.ClearMessages() - err = env.SendMessage(alice.id, `{"type": "peekCard", "cardIndex": 0}`) - if err != nil { - t.Fatalf("Failed to peek card: %v", err) - } - - if !alice.WaitForMessages(1, 100*time.Millisecond) { - t.Fatal("Alice didn't receive peek response") - } - - // Validate state after peek - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Invalid game state after peek: %v", err) - } - - // Test turn-based gameplay - alice.ClearMessages() - bob.ClearMessages() - - err = env.SendMessage(alice.id, `{"type": "drawCard"}`) - if err != nil { - t.Fatalf("Failed to draw card: %v", err) - } - - env.WaitForStabilization() - - // Validate state after draw - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Invalid game state after draw: %v", err) - } - - // Complete turn - err = env.SendMessage(alice.id, `{"type": "discardDrawn"}`) - if err != nil { - t.Fatalf("Failed to discard: %v", err) - } - - env.WaitForStabilization() - - // Validate turn advancement - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Invalid game state after discard: %v", err) - } - - game.mu.RLock() - currentPlayerIndex := game.state.CurrentPlayerIndex - game.mu.RUnlock() - - if currentPlayerIndex != 1 { - t.Errorf("Expected turn to advance to player 1, got %d", currentPlayerIndex) - } -} - -func TestIntegration_ConcurrentClientActions(t *testing.T) { - env := NewTestEnvironment() - defer env.Cleanup() - - // Create multiple clients - clients := make([]*TestClient, 4) - clientIDs := []string{"alice", "bob", "charlie", "diana"} - - for i, id := range clientIDs { - clients[i] = env.CreateClient(id) - } - - // Only Alice (client 0) creates a room, others will join it - var wg sync.WaitGroup - var targetRoomID string - - // Alice creates room - err := env.SendMessage(clientIDs[0], `{"type": "createRoom"}`) - if err != nil { - t.Fatalf("Alice failed to create room: %v", err) - } - - if !clients[0].WaitForMessages(1, 200*time.Millisecond) { - t.Fatal("Alice didn't receive room creation response") - } - - msg, found := clients[0].FindMessageByType("roomJoined") - if !found { - t.Fatal("Alice didn't receive roomJoined message") - } - - var roomJoined RoomJoinedMessage - if err := json.Unmarshal(msg, &roomJoined); err != nil { - t.Fatalf("Alice failed to parse roomJoined: %v", err) - } - - targetRoomID = roomJoined.RoomState.ID - - // Validate Alice's room was created successfully - if err := env.ValidateRoomState(targetRoomID); err != nil { - t.Fatalf("Invalid room state after creation: %v", err) - } - - // Clear messages for other clients - for i := 1; i < 4; i++ { - clients[i].ClearMessages() - } - - // Clients 1, 2, 3 all try to join Alice's room simultaneously - for i := 1; i < 4; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - - err := env.SendMessage(clientIDs[idx], fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, targetRoomID)) - if err != nil { - t.Errorf("Client %s failed to join room: %v", clientIDs[idx], err) - } - }(i) - } - - wg.Wait() - env.WaitForStabilization() - - // Validate final room state - if err := env.ValidateRoomState(targetRoomID); err != nil { - t.Fatalf("Invalid room state after concurrent joins: %v", err) - } - - // Check that room has expected number of players - env.hub.mu.RLock() - room := env.hub.rooms[targetRoomID] - playerCount := len(room.Players) - env.hub.mu.RUnlock() - - if playerCount != 4 { - t.Errorf("Expected 4 players in target room, got %d", playerCount) - } -} - -func TestIntegration_NegativeTestCases(t *testing.T) { - env := NewTestEnvironment() - defer env.Cleanup() - - alice := env.CreateClient("alice") - _ = env.CreateClient("bob") // bob is used in setup functions - - testCases := []struct { - name string - setup func() - message string - expectError bool - errorText string - }{ - { - name: "Join non-existent room", - setup: func() { alice.ClearMessages() }, - message: `{"type": "joinRoom", "roomId": "INVALID"}`, - expectError: true, - errorText: "room not found", - }, - { - name: "Join non-existent game", - setup: func() { - env.SendMessage(alice.id, `{"type": "createRoom"}`) - alice.WaitForMessages(1, 100*time.Millisecond) - // Clear messages after room creation, before the invalid game join - alice.ClearMessages() - }, - message: `{"type": "joinGame", "roomId": "PLACEHOLDER", "gameId": "INVALID"}`, - expectError: true, - errorText: "Game does not exist in room", - }, - { - name: "Start game without being in one", - setup: func() { alice.ClearMessages() }, - message: `{"type": "startGame"}`, - expectError: true, - errorText: "Room not found", - }, - { - name: "Draw card without being in game", - setup: func() { alice.ClearMessages() }, - message: `{"type": "drawCard"}`, - expectError: true, - errorText: "Not in a game", - }, - { - name: "Invalid card index", - setup: func() { alice.ClearMessages() }, - message: `{"type": "peekCard", "cardIndex": 5}`, - expectError: true, - errorText: "Not in a game", - }, - { - name: "Unknown message type", - setup: func() { alice.ClearMessages() }, - message: `{"type": "unknownAction"}`, - expectError: true, - errorText: "Unknown message type: unknownAction", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Ensure test isolation by creating fresh environment for each test - testEnv := NewTestEnvironment() - defer testEnv.Cleanup() - - testAlice := testEnv.CreateClient("alice") - - // Update the setup function to use the test-specific environment - originalSetup := tc.setup - tc.setup = func() { - alice = testAlice - env = testEnv - originalSetup() - } - - tc.setup() - - message := tc.message - // Special handling for join non-existent game test - if tc.name == "Join non-existent game" { - // We need to get room ID from somewhere since messages were cleared - // Let's look at the hub state directly - testEnv.hub.mu.RLock() - var roomID string - for id := range testEnv.hub.rooms { - roomID = id - break // Get the first (and only) room - } - testEnv.hub.mu.RUnlock() - - if roomID != "" { - message = fmt.Sprintf(`{"type": "joinGame", "roomId": "%s", "gameId": "INVALID"}`, roomID) - } - } - - err := testEnv.SendMessage(testAlice.id, message) - if err != nil { - t.Fatalf("Failed to send message: %v", err) - } - - if !testAlice.WaitForMessages(1, 100*time.Millisecond) { - if tc.expectError { - t.Fatal("Expected error message but got none") - } - return - } - - if tc.expectError { - errorMsg, found := testAlice.FindMessageByType("error") - if !found { - t.Fatal("Expected error message but didn't find one") - } - - var errParsed ErrorMessage - if err := json.Unmarshal(errorMsg, &errParsed); err != nil { - t.Fatalf("Failed to parse error message: %v", err) - } - - if errParsed.Message != tc.errorText { - t.Errorf("Expected error '%s', got '%s'", tc.errorText, errParsed.Message) - } - } - }) - } -} - -func TestIntegration_StateValidationEdgeCases(t *testing.T) { - env := NewTestEnvironment() - defer env.Cleanup() - - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - - // Create room and game - env.SendMessage(alice.id, `{"type": "createRoom"}`) - alice.WaitForMessages(1, 100*time.Millisecond) - - msg, _ := alice.FindMessageByType("roomJoined") - var roomJoined RoomJoinedMessage - json.Unmarshal(msg, &roomJoined) - roomID := roomJoined.RoomState.ID - - env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - env.WaitForStabilization() - - // Clear messages before game creation - alice.ClearMessages() - - env.SendMessage(alice.id, fmt.Sprintf(`{"type": "createGame", "roomId": "%s"}`, roomID)) - alice.WaitForMessages(1, 500*time.Millisecond) - - gameMsg, found := alice.FindMessageByType("gameJoined") - if !found { - t.Fatal("Alice didn't receive gameJoined message") - } - var gameJoined GameJoinedMessage - if err := json.Unmarshal(gameMsg, &gameJoined); err != nil { - t.Fatalf("Failed to unmarshal gameJoined: %v", err) - } - gameID := gameJoined.GameState.ID - - env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinGame", "roomId": "%s", "gameId": "%s"}`, roomID, gameID)) - env.WaitForStabilization() - - // Test that we can't peek more than 2 cards - env.SendMessage(alice.id, `{"type": "startGame"}`) - env.WaitForStabilization() - - alice.ClearMessages() - - // Peek at first card - should succeed - env.SendMessage(alice.id, `{"type": "peekCard", "cardIndex": 0}`) - if !alice.WaitForMessages(1, 100*time.Millisecond) { - t.Fatal("First peek failed") - } - - alice.ClearMessages() - - // Peek at second card - should succeed - env.SendMessage(alice.id, `{"type": "peekCard", "cardIndex": 1}`) - if !alice.WaitForMessages(1, 100*time.Millisecond) { - t.Fatal("Second peek failed") - } - - alice.ClearMessages() - - // Try to peek at third card - should fail - env.SendMessage(alice.id, `{"type": "peekCard", "cardIndex": 2}`) - if !alice.WaitForMessages(1, 100*time.Millisecond) { - t.Fatal("Expected error for third peek") - } - - errorMsg, found := alice.FindMessageByType("error") - if !found { - t.Fatal("Expected error message for third peek") - } - - var errParsed ErrorMessage - json.Unmarshal(errorMsg, &errParsed) - - if errParsed.Message != "already peeked at 2 cards" { - t.Errorf("Expected 'already peeked at 2 cards' error, got '%s'", errParsed.Message) - } - - // Validate game state is still consistent - env.hub.mu.RLock() - room := env.hub.rooms[roomID] - game := room.Games[gameID] - env.hub.mu.RUnlock() - - if err := env.ValidateGameState(gameID, game); err != nil { - t.Fatalf("Game state validation failed after peek limit test: %v", err) - } -} - -func TestIntegration_PlayerReconnection(t *testing.T) { - // This test validates JWT-based reconnection: a player disconnects and - // reconnects using their stored session token, preserving their identity - // and room membership. - - env := NewTestEnvironment() - defer env.Cleanup() - - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - - // Save Bob's session token for reconnection - bobToken := env.GetSessionToken("bob") - if bobToken == "" { - t.Fatal("Bob should have a session token after authentication") - } - - // Get Bob's player ID - env.hub.mu.RLock() - var bobPlayerID string - for _, ctx := range env.hub.clientContexts { - if ctx.PlayerID != "" { - // DeterministicIDGenerator produces "player-1", "player-2", etc. - // Bob is the second client created - } - } - bobHubClient := env.hubClients["bob"] - if bobHubClient != nil { - if ctx, ok := env.hub.clientContexts[bobHubClient]; ok { - bobPlayerID = ctx.PlayerID - } - } - env.hub.mu.RUnlock() - - t.Logf("Bob's playerID: %s, token: %s...", bobPlayerID, bobToken[:20]) - - // Create room - env.SendMessage(alice.id, `{"type": "createRoom"}`) - alice.WaitForMessages(1, 100*time.Millisecond) - - msg, _ := alice.FindMessageByType("roomJoined") - var roomJoined RoomJoinedMessage - json.Unmarshal(msg, &roomJoined) - roomID := roomJoined.RoomState.ID - - // Bob joins room - env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - env.WaitForStabilization() - - // Validate room state before disconnect - if err := env.ValidateRoomState(roomID); err != nil { - t.Fatalf("Invalid room state before disconnect: %v", err) - } - - env.hub.mu.RLock() - room := env.hub.rooms[roomID] - playerCountBefore := len(room.Players) - env.hub.mu.RUnlock() - t.Logf("Room has %d players before Bob disconnects", playerCountBefore) - - // Bob disconnects - env.mu.RLock() - bobClient := env.hubClients["bob"] - env.mu.RUnlock() - - bob.mu.Lock() - bob.closed = true - bob.mu.Unlock() - - env.hub.Unregister(bobClient) - env.WaitForStabilization() - - // Check that Bob's session is preserved - env.hub.mu.RLock() - _, hasDisconnectedSession := env.hub.disconnectedSessions[bobPlayerID] - env.hub.mu.RUnlock() - - if !hasDisconnectedSession { - t.Fatal("Bob should have a disconnected session after disconnect") - } - - // Bob reconnects with his stored token - bob2 := env.CreateReconnectingClient("bob2", bobToken) - - // Bob should receive roomJoined and be back in his room - if !bob2.WaitForMessages(1, 500*time.Millisecond) { - t.Fatal("Bob didn't receive reconnection state") - } - - // Check for roomJoined message - roomMsg, found := bob2.FindMessageByType("roomJoined") - if !found { - t.Fatal("Bob should receive roomJoined on reconnect") - } - - var reconnectRoomJoined RoomJoinedMessage - json.Unmarshal(roomMsg, &reconnectRoomJoined) - - // Verify Bob has the same player ID - if reconnectRoomJoined.PlayerID != bobPlayerID { - t.Errorf("Expected Bob's playerID %s after reconnect, got %s", - bobPlayerID, reconnectRoomJoined.PlayerID) - } - - // Verify Bob is back in the same room - if reconnectRoomJoined.RoomState.ID != roomID { - t.Errorf("Expected room %s after reconnect, got %s", - roomID, reconnectRoomJoined.RoomState.ID) - } - - // Validate room state after reconnect - env.WaitForStabilization() - - env.hub.mu.RLock() - room = env.hub.rooms[roomID] - finalPlayerCount := len(room.Players) - - // Find Bob's player by playerID - var bobPlayerAfter *Player - for _, p := range room.Players { - if p.ID == bobPlayerID { - bobPlayerAfter = p - break - } - } - env.hub.mu.RUnlock() - - t.Logf("Room has %d players after Bob's reconnection", finalPlayerCount) - - if bobPlayerAfter == nil { - t.Fatal("Bob's player not found in room after reconnection") - } - - if !bobPlayerAfter.IsConnected { - t.Error("Bob should be marked as connected after reconnection") - } - - // Verify disconnected session was cleaned up - env.hub.mu.RLock() - _, stillDisconnected := env.hub.disconnectedSessions[bobPlayerID] - env.hub.mu.RUnlock() - - if stillDisconnected { - t.Error("Bob's disconnected session should be cleaned up after reconnect") - } -} - -func TestIntegration_RoomStatePlayerCountAfterGameCreation(t *testing.T) { - // This test specifically validates the bug fix for the room state showing 0 players - // when a game is created. Other players in the room should see the correct player count. - env := NewTestEnvironment() - defer env.Cleanup() - - // Create two clients - Alice will create the game, Bob will observe the room state update - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - - // Alice creates room - err := env.SendMessage(alice.id, `{"type": "createRoom"}`) - if err != nil { - t.Fatalf("Alice failed to create room: %v", err) - } - - if !alice.WaitForMessages(1, 200*time.Millisecond) { - t.Fatal("Alice didn't receive room creation response") - } - - // Get room ID from Alice's message - msg, found := alice.FindMessageByType("roomJoined") - if !found { - t.Fatal("Alice didn't receive roomJoined message") - } - - var roomJoined RoomJoinedMessage - if err := json.Unmarshal(msg, &roomJoined); err != nil { - t.Fatalf("Failed to parse roomJoined: %v", err) - } - roomID := roomJoined.RoomState.ID - - // Bob joins the room - err = env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Bob failed to join room: %v", err) - } - - if !bob.WaitForMessages(1, 200*time.Millisecond) { - t.Fatal("Bob didn't receive room join response") - } - - // Clear Bob's messages so we only see the room state update from game creation - bob.ClearMessages() - - // Alice creates a game - this should trigger a roomStateUpdate for Bob - err = env.SendMessage(alice.id, fmt.Sprintf(`{"type": "createGame", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Alice failed to create game: %v", err) - } - - // Wait for Bob to receive the room state update - if !bob.WaitForMessages(1, 500*time.Millisecond) { - t.Fatal("Bob didn't receive room state update after Alice created game") - } - - // Find the roomStateUpdate message Bob received - roomStateMsg, found := bob.FindMessageByType("roomStateUpdate") - if !found { - t.Fatal("Bob didn't receive roomStateUpdate message") - } - - // Parse the room state update as a raw JSON structure first - var rawMsg map[string]interface{} - if err := json.Unmarshal(roomStateMsg, &rawMsg); err != nil { - t.Fatalf("Failed to parse roomStateUpdate as raw JSON: %v", err) - } - - // Navigate to the games structure - roomState, ok := rawMsg["roomState"].(map[string]interface{}) - if !ok { - t.Fatal("roomState field not found or not an object") - } - - games, ok := roomState["games"].(map[string]interface{}) - if !ok { - t.Fatal("games field not found or not an object") - } - - if len(games) == 0 { - t.Fatal("No games found in room state") - } - - // Get the first (and only) game - var gameData map[string]interface{} - var gameID string - for id, g := range games { - gameData = g.(map[string]interface{}) - gameID = id - break - } - - // This is the key assertion - the game in the room state should show 1 player (Alice) - // This was the bug: it would show 0 players due to race condition in room state broadcasting - players, ok := gameData["players"].([]interface{}) - if !ok { - t.Fatal("players field not found or not an array") - } - - playerCount := len(players) - if playerCount != 1 { - t.Errorf("Game %s shows %d players in room state, expected 1 (the creator). This indicates the race condition bug is not fixed.", gameID, playerCount) - - // Print debug information if the test fails - roomStateJSON, _ := json.MarshalIndent(roomState, "", " ") - t.Logf("Full room state: %s", roomStateJSON) - } else { - // The key fix is validated: game shows 1 player instead of 0 - // Note: Game player IDs are different from room player IDs by design - t.Logf("✅ SUCCESS: Game correctly shows 1 player (the creator)") - - if len(players) > 0 { - gamePlayer := players[0].(map[string]interface{}) - if gamePlayerID, ok := gamePlayer["id"].(string); ok { - t.Logf("Game player ID: %s", gamePlayerID) - } - } - } - - // Additional validation: game phase should be "waiting" - gamePhase, ok := gameData["gamePhase"].(string) - if !ok { - t.Fatal("gamePhase field not found or not a string") - } - if gamePhase != "waiting" { - t.Errorf("Expected game phase to be 'waiting', got '%s'", gamePhase) - } -} - -func TestIntegration_StartNewGameFlow(t *testing.T) { - // This test validates the startNewGame functionality and the newGameStarted message - // with gameId and previousGameId fields - env := NewTestEnvironment() - defer env.Cleanup() - - // Create two clients - alice := env.CreateClient("alice") - bob := env.CreateClient("bob") - - // Alice creates room - err := env.SendMessage(alice.id, `{"type": "createRoom"}`) - if err != nil { - t.Fatalf("Alice failed to create room: %v", err) - } - - if !alice.WaitForMessages(1, 200*time.Millisecond) { - t.Fatal("Alice didn't receive room creation response") - } - - // Get room ID - msg, found := alice.FindMessageByType("roomJoined") - if !found { - t.Fatal("Alice didn't receive roomJoined message") - } - - var roomJoined RoomJoinedMessage - if err := json.Unmarshal(msg, &roomJoined); err != nil { - t.Fatalf("Failed to parse roomJoined: %v", err) - } - roomID := roomJoined.RoomState.ID - - // Bob joins the room - err = env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinRoom", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Bob failed to join room: %v", err) - } - env.WaitForStabilization() - - // Alice creates first game - alice.ClearMessages() - err = env.SendMessage(alice.id, fmt.Sprintf(`{"type": "createGame", "roomId": "%s"}`, roomID)) - if err != nil { - t.Fatalf("Alice failed to create first game: %v", err) - } - - if !alice.WaitForMessages(1, 200*time.Millisecond) { - t.Fatal("Alice didn't receive first game creation response") - } - - // Get first game ID - gameMsg, found := alice.FindMessageByType("gameJoined") - if !found { - t.Fatal("Alice didn't receive gameJoined message for first game") - } - - var gameJoined GameJoinedMessage - if err := json.Unmarshal(gameMsg, &gameJoined); err != nil { - t.Fatalf("Failed to parse gameJoined: %v", err) - } - firstGameID := gameJoined.GameState.ID - - // Bob joins the first game - err = env.SendMessage(bob.id, fmt.Sprintf(`{"type": "joinGame", "roomId": "%s", "gameId": "%s"}`, roomID, firstGameID)) - if err != nil { - t.Fatalf("Bob failed to join first game: %v", err) - } - env.WaitForStabilization() - - // Start and play the first game to completion - err = env.SendMessage(alice.id, `{"type": "startGame"}`) - if err != nil { - t.Fatalf("Failed to start first game: %v", err) - } - env.WaitForStabilization() - - // Simulate game completion (simple knock scenario) - err = env.SendMessage(alice.id, `{"type": "peekCard", "cardIndex": 0}`) - if err != nil { - t.Fatalf("Alice failed to peek: %v", err) - } - env.WaitForStabilization() - - err = env.SendMessage(alice.id, `{"type": "knock"}`) - if err != nil { - t.Fatalf("Alice failed to knock: %v", err) - } - env.WaitForStabilization() - - // Clear messages before starting new game - alice.ClearMessages() - bob.ClearMessages() - - // Alice starts a new game - this should trigger newGameStarted message - err = env.SendMessage(alice.id, `{"type": "startNewGame"}`) - if err != nil { - t.Fatalf("Alice failed to start new game: %v", err) - } - - // Both players should receive the newGameStarted message - if !alice.WaitForMessages(2, 500*time.Millisecond) { // newGameStarted + roomStateUpdate - t.Fatal("Alice didn't receive new game started messages") - } - if !bob.WaitForMessages(2, 500*time.Millisecond) { - t.Fatal("Bob didn't receive new game started messages") - } - - // Find and validate the newGameStarted message from Alice's perspective - newGameMsg, found := alice.FindMessageByType("newGameStarted") - if !found { - t.Fatal("Alice didn't receive newGameStarted message") - } - - var newGameStarted NewGameStartedMessage - if err := json.Unmarshal(newGameMsg, &newGameStarted); err != nil { - t.Fatalf("Failed to parse newGameStarted: %v", err) - } - - // Validate the newGameStarted message fields - if newGameStarted.Type != "newGameStarted" { - t.Errorf("Expected type 'newGameStarted', got '%s'", newGameStarted.Type) - } - - if newGameStarted.GameID == "" { - t.Error("GameID should not be empty in newGameStarted message") - } - - if newGameStarted.PreviousGameID != firstGameID { - t.Errorf("Expected previousGameId '%s', got '%s'", firstGameID, newGameStarted.PreviousGameID) - } - - // Validate that Bob receives the same message - bobNewGameMsg, found := bob.FindMessageByType("newGameStarted") - if !found { - t.Fatal("Bob didn't receive newGameStarted message") - } - - var bobNewGameStarted NewGameStartedMessage - if err := json.Unmarshal(bobNewGameMsg, &bobNewGameStarted); err != nil { - t.Fatalf("Failed to parse Bob's newGameStarted: %v", err) - } - - // Both messages should be identical - if bobNewGameStarted.GameID != newGameStarted.GameID { - t.Errorf("Game IDs don't match: Alice got '%s', Bob got '%s'", newGameStarted.GameID, bobNewGameStarted.GameID) - } - - if bobNewGameStarted.PreviousGameID != newGameStarted.PreviousGameID { - t.Errorf("Previous game IDs don't match: Alice got '%s', Bob got '%s'", newGameStarted.PreviousGameID, bobNewGameStarted.PreviousGameID) - } - - // Validate room state contains the new game - env.hub.mu.RLock() - room := env.hub.rooms[roomID] - gameCount := len(room.Games) - _, newGameExists := room.Games[newGameStarted.GameID] - env.hub.mu.RUnlock() - - if gameCount != 2 { - t.Errorf("Expected 2 games in room after startNewGame, got %d", gameCount) - } - - if !newGameExists { - t.Errorf("New game with ID '%s' not found in room", newGameStarted.GameID) - } - - // Validate the new game is empty (no players initially) - env.hub.mu.RLock() - newGame := room.Games[newGameStarted.GameID] - env.hub.mu.RUnlock() - - newGame.mu.RLock() - newGamePlayerCount := len(newGame.state.Players) - newGame.mu.RUnlock() - - if newGamePlayerCount != 0 { - t.Errorf("New game should have 0 players initially, got %d", newGamePlayerCount) - } - - t.Logf("✅ SUCCESS: startNewGame flow validated") - t.Logf(" - Previous game ID: %s", firstGameID) - t.Logf(" - New game ID: %s", newGameStarted.GameID) - t.Logf(" - Room now has %d games", gameCount) -} \ No newline at end of file diff --git a/domains/games/apis/games_ws_backend/golf/state_transitions_test.go b/domains/games/apis/games_ws_backend/golf/state_transitions_test.go deleted file mode 100644 index 73784a990..000000000 --- a/domains/games/apis/games_ws_backend/golf/state_transitions_test.go +++ /dev/null @@ -1,1192 +0,0 @@ -package golf - -import ( - "fmt" - "testing" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -// Helper function to add test players with consistent IDs -func addTestPlayer(g *Game, clientID string) (*Player, error) { - playerID := fmt.Sprintf("TestPlayer%s", clientID) - return g.AddPlayer(clientID, playerID, playerID) -} - -// TestStateTransitions_GamePhases tests all valid game phase transitions -func TestStateTransitions_GamePhases(t *testing.T) { - tests := []struct { - name string - setupFunc func() *Game - action func(*Game) error - expectedPhase string - expectError bool - errorContains string - }{ - // Waiting -> Playing transitions - { - name: "waiting to playing - valid with 2 players", - setupFunc: func() *Game { - g := NewGame("TEST1", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - return g - }, - action: func(g *Game) error { - return g.StartGame() - }, - expectedPhase: "playing", - expectError: false, - }, - { - name: "waiting to playing - invalid with 1 player", - setupFunc: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - return g - }, - action: func(g *Game) error { - return g.StartGame() - }, - expectedPhase: "waiting", - expectError: true, - errorContains: "at least 2 players", - }, - { - name: "waiting to playing - invalid with 0 players", - setupFunc: func() *Game { - return NewGame("TEST3", &players.DeterministicIDGenerator{}) - }, - action: func(g *Game) error { - return g.StartGame() - }, - expectedPhase: "waiting", - expectError: true, - errorContains: "at least 2 players", - }, - // Playing -> Knocked transitions - { - name: "playing to knocked - valid knock", - setupFunc: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.Knock("p1") - }, - expectedPhase: "knocked", - expectError: false, - }, - { - name: "playing to knocked - invalid knock after draw", - setupFunc: func() *Game { - g := NewGame("TEST5", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.Knock("p1") - }, - expectedPhase: "playing", - expectError: true, - errorContains: "cannot knock after drawing", - }, - // Knocked -> Ended transitions - { - name: "knocked to ended - when knocker's turn comes again", - setupFunc: func() *Game { - g := NewGame("TEST6", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.Knock("p1") // p1 knocks, turn goes to p2 - g.DrawCard("p2") - return g - }, - action: func(g *Game) error { - return g.DiscardDrawn("p2") // p2 finishes turn, game should end - }, - expectedPhase: "ended", - expectError: false, - }, - // Invalid transitions - { - name: "ended state - no transitions allowed", - setupFunc: func() *Game { - g := NewGame("TEST7", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.Knock("p1") - g.DrawCard("p2") - g.DiscardDrawn("p2") - return g - }, - action: func(g *Game) error { - return g.DrawCard("p1") - }, - expectedPhase: "ended", - expectError: true, - errorContains: "game not in playing phase", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setupFunc() - err := tt.action(game) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error containing '%s', got nil", tt.errorContains) - } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - } - - if game.state.GamePhase != tt.expectedPhase { - t.Errorf("Expected phase '%s', got '%s'", tt.expectedPhase, game.state.GamePhase) - } - }) - } -} - -// TestStateTransitions_DrawOperations tests all draw pile and discard pile operations -func TestStateTransitions_DrawOperations(t *testing.T) { - tests := []struct { - name string - setupFunc func() *Game - action func(*Game) error - expectError bool - errorContains string - validate func(*testing.T, *Game) - }{ - // Draw from draw pile - { - name: "draw from pile - valid on player's turn", - setupFunc: func() *Game { - g := NewGame("TEST1", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.DrawCard("p1") - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if g.state.DrawnCard == nil { - t.Error("Expected drawn card to be set") - } - if g.state.DrawPile >= 44 { - t.Error("Draw pile should decrease") - } - }, - }, - { - name: "draw from pile - invalid when not your turn", - setupFunc: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.DrawCard("p2") // p1's turn - }, - expectError: true, - errorContains: "not your turn", - }, - { - name: "draw from pile - invalid when already drawn", - setupFunc: func() *Game { - g := NewGame("TEST3", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.DrawCard("p1") - }, - expectError: true, - errorContains: "already have a drawn card", - }, - { - name: "draw from pile - invalid when game not started", - setupFunc: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - return g - }, - action: func(g *Game) error { - return g.DrawCard("p1") - }, - expectError: true, - errorContains: "game not in playing phase", - }, - { - name: "draw from pile - invalid when game is over", - setupFunc: func() *Game { - g := NewGame("TEST5", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.state.GamePhase = "ended" - return g - }, - action: func(g *Game) error { - return g.DrawCard("p1") - }, - expectError: true, - errorContains: "game not in playing phase", - }, - // Take from discard pile - { - name: "take from discard - valid on player's turn", - setupFunc: func() *Game { - g := NewGame("TEST6", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.TakeFromDiscard("p1") - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if g.state.DrawnCard == nil { - t.Error("Expected drawn card to be set") - } - if len(g.state.DiscardPile) != 0 { - t.Error("Discard pile should be empty after taking") - } - }, - }, - { - name: "take from discard - invalid when not your turn", - setupFunc: func() *Game { - g := NewGame("TEST7", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.TakeFromDiscard("p2") - }, - expectError: true, - errorContains: "not your turn", - }, - { - name: "take from discard - invalid when already have drawn card", - setupFunc: func() *Game { - g := NewGame("TEST8", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.TakeFromDiscard("p1") - }, - expectError: true, - errorContains: "already have a drawn card", - }, - { - name: "take from discard - invalid when discard is empty", - setupFunc: func() *Game { - g := NewGame("TEST9", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.state.DiscardPile = []*Card{} // Empty it - return g - }, - action: func(g *Game) error { - return g.TakeFromDiscard("p1") - }, - expectError: true, - errorContains: "discard pile is empty", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setupFunc() - err := tt.action(game) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error containing '%s', got nil", tt.errorContains) - } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - } - - if tt.validate != nil { - tt.validate(t, game) - } - }) - } -} - -// TestStateTransitions_SwapOperations tests card swapping mechanics -func TestStateTransitions_SwapOperations(t *testing.T) { - tests := []struct { - name string - setupFunc func() *Game - action func(*Game) error - expectError bool - errorContains string - validate func(*testing.T, *Game) - }{ - { - name: "swap card - valid after drawing", - setupFunc: func() *Game { - g := NewGame("TEST1", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.SwapCard("p1", 0) - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if g.state.DrawnCard != nil { - t.Error("Drawn card should be cleared after swap") - } - if g.state.CurrentPlayerIndex != 1 { - t.Error("Turn should advance after swap") - } - if len(g.state.DiscardPile) != 2 { - t.Error("Discard pile should have 2 cards after swap") - } - }, - }, - { - name: "swap card - invalid without drawn card", - setupFunc: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.SwapCard("p1", 0) - }, - expectError: true, - errorContains: "no drawn card to swap", - }, - { - name: "swap card - invalid card index negative", - setupFunc: func() *Game { - g := NewGame("TEST3", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.SwapCard("p1", -1) - }, - expectError: true, - errorContains: "invalid card index", - }, - { - name: "swap card - invalid card index too high", - setupFunc: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.SwapCard("p1", 4) - }, - expectError: true, - errorContains: "invalid card index", - }, - { - name: "swap card - invalid when not your turn", - setupFunc: func() *Game { - g := NewGame("TEST5", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.SwapCard("p2", 0) - }, - expectError: true, - errorContains: "not your turn", - }, - { - name: "discard drawn - valid after drawing", - setupFunc: func() *Game { - g := NewGame("TEST6", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.DiscardDrawn("p1") - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if g.state.DrawnCard != nil { - t.Error("Drawn card should be cleared after discard") - } - if g.state.CurrentPlayerIndex != 1 { - t.Error("Turn should advance after discard") - } - if len(g.state.DiscardPile) != 2 { - t.Error("Discard pile should have 2 cards") - } - }, - }, - { - name: "discard drawn - invalid without drawn card", - setupFunc: func() *Game { - g := NewGame("TEST7", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.DiscardDrawn("p1") - }, - expectError: true, - errorContains: "no drawn card to discard", - }, - { - name: "discard drawn - invalid when not your turn", - setupFunc: func() *Game { - g := NewGame("TEST8", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.DiscardDrawn("p2") - }, - expectError: true, - errorContains: "not your turn", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setupFunc() - err := tt.action(game) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error containing '%s', got nil", tt.errorContains) - } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - } - - if tt.validate != nil { - tt.validate(t, game) - } - }) - } -} - -// TestStateTransitions_KnockMechanics tests all knock-related state transitions -func TestStateTransitions_KnockMechanics(t *testing.T) { - tests := []struct { - name string - setupFunc func() *Game - action func(*Game) error - expectError bool - errorContains string - validate func(*testing.T, *Game) - }{ - { - name: "knock - valid on player's turn before drawing", - setupFunc: func() *Game { - g := NewGame("TEST1", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.Knock("p1") - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if g.state.GamePhase != "knocked" { - t.Error("Game should be in knocked phase") - } - if g.state.KnockedPlayerID == nil || *g.state.KnockedPlayerID != "TestPlayerp1" { - t.Error("Knocked player ID not set correctly") - } - if g.state.CurrentPlayerIndex != 1 { - t.Error("Turn should advance after knock") - } - }, - }, - { - name: "knock - invalid when not your turn", - setupFunc: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.Knock("p2") - }, - expectError: true, - errorContains: "not your turn", - }, - { - name: "knock - invalid after drawing", - setupFunc: func() *Game { - g := NewGame("TEST3", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - return g - }, - action: func(g *Game) error { - return g.Knock("p1") - }, - expectError: true, - errorContains: "cannot knock after drawing", - }, - { - name: "knock - invalid when already knocked", - setupFunc: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - g.StartGame() - g.Knock("p1") // p1 knocks, turn goes to p2 - return g - }, - action: func(g *Game) error { - return g.Knock("p2") - }, - expectError: true, - errorContains: "someone already knocked", - }, - { - name: "knock - invalid when game not started", - setupFunc: func() *Game { - g := NewGame("TEST5", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - return g - }, - action: func(g *Game) error { - return g.Knock("p1") - }, - expectError: true, - errorContains: "game not in playing phase", - }, - { - name: "knock - invalid when game is over", - setupFunc: func() *Game { - g := NewGame("TEST6", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.state.GamePhase = "ended" - return g - }, - action: func(g *Game) error { - return g.Knock("p1") - }, - expectError: true, - errorContains: "game not in playing phase", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setupFunc() - err := tt.action(game) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error containing '%s', got nil", tt.errorContains) - } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - } - - if tt.validate != nil { - tt.validate(t, game) - } - }) - } -} - -// TestStateTransitions_TurnAdvancement tests turn progression through the game -func TestStateTransitions_TurnAdvancement(t *testing.T) { - tests := []struct { - name string - setup func() *Game - validate func(*testing.T, *Game) - }{ - { - name: "turn advances after draw and discard", - setup: func() *Game { - g := NewGame("TEST1", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - // Player 1's turn - g.DrawCard("p1") - g.DiscardDrawn("p1") - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.CurrentPlayerIndex != 1 { - t.Errorf("Expected turn to be player 2 (index 1), got %d", g.state.CurrentPlayerIndex) - } - }, - }, - { - name: "turn advances after draw and swap", - setup: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.DrawCard("p1") - g.SwapCard("p1", 0) - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.CurrentPlayerIndex != 1 { - t.Errorf("Expected turn to be player 2 (index 1), got %d", g.state.CurrentPlayerIndex) - } - }, - }, - { - name: "turn wraps around to first player", - setup: func() *Game { - g := NewGame("TEST3", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - // P1 turn - g.DrawCard("p1") - g.DiscardDrawn("p1") - // P2 turn - g.DrawCard("p2") - g.DiscardDrawn("p2") - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.CurrentPlayerIndex != 0 { - t.Errorf("Expected turn to wrap to player 1 (index 0), got %d", g.state.CurrentPlayerIndex) - } - }, - }, - { - name: "turn advances after knock", - setup: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - g.StartGame() - g.Knock("p1") - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.CurrentPlayerIndex != 1 { - t.Errorf("Expected turn to advance after knock, got %d", g.state.CurrentPlayerIndex) - } - if g.state.KnockedPlayerID == nil { - t.Error("Knocked player ID should be set") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setup() - tt.validate(t, game) - }) - } -} - -// TestStateTransitions_GameEndConditions tests all ways a game can end -func TestStateTransitions_GameEndConditions(t *testing.T) { - tests := []struct { - name string - setup func() *Game - validate func(*testing.T, *Game) - }{ - { - name: "game ends when knocker's turn comes again - 2 players", - setup: func() *Game { - g := NewGame("TEST1", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - g.Knock("p1") // P1 knocks, turn to P2 - g.DrawCard("p2") // P2 takes final turn - g.DiscardDrawn("p2") // P2 ends turn, game should end - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.GamePhase != "ended" { - t.Errorf("Expected game to be ended, got phase %s", g.state.GamePhase) - } - // All cards should be revealed - for _, p := range g.state.Players { - if len(p.RevealedCards) != 4 { - t.Errorf("Player %s should have all 4 cards revealed, has %d", p.Name, len(p.RevealedCards)) - } - } - }, - }, - { - name: "game ends when knocker's turn comes again - 3 players", - setup: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - g.StartGame() - g.Knock("p1") // P1 knocks, turn to P2 - g.DrawCard("p2") // P2 takes turn - g.DiscardDrawn("p2") // Turn to P3 - g.DrawCard("p3") // P3 takes turn - g.DiscardDrawn("p3") // Back to P1 (knocker), game ends - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.GamePhase != "ended" { - t.Errorf("Expected game to be ended, got phase %s", g.state.GamePhase) - } - }, - }, - { - name: "game ends when knocker's turn comes again - 4 players", - setup: func() *Game { - g := NewGame("TEST3", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - addTestPlayer(g, "p4") - g.StartGame() - g.Knock("p1") // P1 knocks, turn to P2 - g.DrawCard("p2") // P2 takes turn - g.DiscardDrawn("p2") // Turn to P3 - g.DrawCard("p3") // P3 takes turn - g.DiscardDrawn("p3") // Turn to P4 - g.DrawCard("p4") // P4 takes turn - g.DiscardDrawn("p4") // Back to P1 (knocker), game ends - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.GamePhase != "ended" { - t.Errorf("Expected game to be ended, got phase %s", g.state.GamePhase) - } - }, - }, - { - name: "game continues if not back to knocker yet", - setup: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - g.StartGame() - g.Knock("p1") // P1 knocks, turn to P2 - g.DrawCard("p2") // P2 takes turn - g.DiscardDrawn("p2") // Turn to P3, game still going - return g - }, - validate: func(t *testing.T, g *Game) { - if g.state.GamePhase != "knocked" { - t.Errorf("Expected game to still be in knocked phase, got %s", g.state.GamePhase) - } - if g.state.CurrentPlayerIndex != 2 { - t.Errorf("Expected turn to be P3 (index 2), got %d", g.state.CurrentPlayerIndex) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setup() - tt.validate(t, game) - }) - } -} - -// TestStateTransitions_PlayerManagement tests adding/removing players -func TestStateTransitions_PlayerManagement(t *testing.T) { - tests := []struct { - name string - setupFunc func() *Game - action func(*Game) error - expectError bool - errorContains string - validate func(*testing.T, *Game) - }{ - { - name: "add player to empty game", - setupFunc: func() *Game { - return NewGame("TEST1", &players.DeterministicIDGenerator{}) - }, - action: func(g *Game) error { - _, err := addTestPlayer(g, "p1") - return err - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if len(g.state.Players) != 1 { - t.Errorf("Expected 1 player, got %d", len(g.state.Players)) - } - }, - }, - { - name: "add 4th player - should succeed", - setupFunc: func() *Game { - g := NewGame("TEST2", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - return g - }, - action: func(g *Game) error { - _, err := addTestPlayer(g, "p4") - return err - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if len(g.state.Players) != 4 { - t.Errorf("Expected 4 players, got %d", len(g.state.Players)) - } - }, - }, - { - name: "add 5th player - should fail", - setupFunc: func() *Game { - g := NewGame("TEST3", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - addTestPlayer(g, "p4") - return g - }, - action: func(g *Game) error { - _, err := addTestPlayer(g, "p5") - return err - }, - expectError: true, - errorContains: "game is full", - }, - { - name: "add player after game started - should fail", - setupFunc: func() *Game { - g := NewGame("TEST4", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - g.StartGame() - return g - }, - action: func(g *Game) error { - _, err := addTestPlayer(g, "p3") - return err - }, - expectError: true, - errorContains: "game already started", - }, - // Note: The Go implementation doesn't track duplicate client IDs - // This is handled at a higher level (hub/websocket layer) - { - name: "remove player from waiting game", - setupFunc: func() *Game { - g := NewGame("TEST6", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - return g - }, - action: func(g *Game) error { - return g.RemovePlayer("p1") - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if len(g.state.Players) != 1 { - t.Errorf("Expected 1 player after removal, got %d", len(g.state.Players)) - } - }, - }, - { - name: "remove non-existent player", - setupFunc: func() *Game { - g := NewGame("TEST7", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - return g - }, - action: func(g *Game) error { - return g.RemovePlayer("p2") - }, - expectError: true, - errorContains: "not found", - }, - { - name: "remove player after game started - should succeed but be careful", - setupFunc: func() *Game { - g := NewGame("TEST8", &players.DeterministicIDGenerator{}) - addTestPlayer(g, "p1") - addTestPlayer(g, "p2") - addTestPlayer(g, "p3") - g.StartGame() - return g - }, - action: func(g *Game) error { - return g.RemovePlayer("p2") - }, - expectError: false, - validate: func(t *testing.T, g *Game) { - if len(g.state.Players) != 2 { - t.Errorf("Expected 2 players after removal, got %d", len(g.state.Players)) - } - // Turn index should be adjusted if necessary - if g.state.CurrentPlayerIndex >= len(g.state.Players) { - t.Error("Current player index out of bounds after removal") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - game := tt.setupFunc() - err := tt.action(game) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error containing '%s', got nil", tt.errorContains) - } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { - t.Errorf("Expected error containing '%s', got '%s'", tt.errorContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error, got: %v", err) - } - } - - if tt.validate != nil { - tt.validate(t, game) - } - }) - } -} - -// TestStateTransitions_CompleteGameFlow tests a complete game from start to finish -func TestStateTransitions_CompleteGameFlow(t *testing.T) { - game := NewGame("COMPLETE", &players.DeterministicIDGenerator{}) - - // Add players - p1, err := addTestPlayer(game, "client1") - if err != nil { - t.Fatalf("Failed to add player 1: %v", err) - } - if p1.Name != "TestPlayerclient1" { - t.Errorf("Expected TestPlayerclient1, got %s", p1.Name) - } - - p2, err := addTestPlayer(game, "client2") - if err != nil { - t.Fatalf("Failed to add player 2: %v", err) - } - if p2.Name != "TestPlayerclient2" { - t.Errorf("Expected TestPlayerclient2, got %s", p2.Name) - } - - // Verify waiting state - if game.state.GamePhase != "waiting" { - t.Errorf("Expected waiting phase, got %s", game.state.GamePhase) - } - - // Start game - err = game.StartGame() - if err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - // Verify playing state - if game.state.GamePhase != "playing" { - t.Errorf("Expected playing phase, got %s", game.state.GamePhase) - } - - // Each player has 4 cards - for _, player := range game.state.Players { - if len(player.Cards) != 4 { - t.Errorf("Player %s should have 4 cards, has %d", player.Name, len(player.Cards)) - } - } - - // Discard pile has 1 card - if len(game.state.DiscardPile) != 1 { - t.Errorf("Discard pile should have 1 card, has %d", len(game.state.DiscardPile)) - } - - // Player 1 peeks at 2 cards - err = game.PeekCard("client1", 0) - if err != nil { - t.Fatalf("Failed to peek at card 0: %v", err) - } - err = game.PeekCard("client1", 1) - if err != nil { - t.Fatalf("Failed to peek at card 1: %v", err) - } - - // Can't peek at 3rd card - err = game.PeekCard("client1", 2) - if err == nil { - t.Error("Should not be able to peek at 3rd card") - } - - // Player 1 takes turn - draw and discard - err = game.DrawCard("client1") - if err != nil { - t.Fatalf("Failed to draw card: %v", err) - } - - if game.state.DrawnCard == nil { - t.Error("Should have drawn card") - } - - err = game.DiscardDrawn("client1") - if err != nil { - t.Fatalf("Failed to discard: %v", err) - } - - // Should be player 2's turn - if game.state.CurrentPlayerIndex != 1 { - t.Errorf("Should be player 2's turn (index 1), got %d", game.state.CurrentPlayerIndex) - } - - // Player 2 takes from discard - err = game.TakeFromDiscard("client2") - if err != nil { - t.Fatalf("Failed to take from discard: %v", err) - } - - err = game.SwapCard("client2", 2) - if err != nil { - t.Fatalf("Failed to swap card: %v", err) - } - - // Back to player 1 - if game.state.CurrentPlayerIndex != 0 { - t.Errorf("Should be player 1's turn (index 0), got %d", game.state.CurrentPlayerIndex) - } - - // Player 1 knocks - err = game.Knock("client1") - if err != nil { - t.Fatalf("Failed to knock: %v", err) - } - - if game.state.GamePhase != "knocked" { - t.Errorf("Should be in knocked phase, got %s", game.state.GamePhase) - } - - // Player 2 takes final turn - err = game.DrawCard("client2") - if err != nil { - t.Fatalf("Failed to draw on final turn: %v", err) - } - - err = game.DiscardDrawn("client2") - if err != nil { - t.Fatalf("Failed to discard on final turn: %v", err) - } - - // Game should be ended - if game.state.GamePhase != "ended" { - t.Errorf("Game should be ended, got phase %s", game.state.GamePhase) - } - - // All cards revealed - for _, player := range game.state.Players { - if len(player.RevealedCards) != 4 { - t.Errorf("Player %s should have all 4 cards revealed", player.Name) - } - } - - // Winner should be determined - winner := game.GetWinner() - if winner == nil { - t.Error("Should have a winner") - } -} - -// Helper function to check if a string contains a substring -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(substr) == 0 || - (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr) != -1)) -} - -func findSubstring(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - return -1 -} diff --git a/domains/games/apis/games_ws_backend/golf/state_validation.go b/domains/games/apis/games_ws_backend/golf/state_validation.go deleted file mode 100644 index d40fc42ce..000000000 --- a/domains/games/apis/games_ws_backend/golf/state_validation.go +++ /dev/null @@ -1,417 +0,0 @@ -package golf - -import ( - "fmt" - "sync" -) - -// StateValidator provides validation for game and room state invariants -type StateValidator struct { - mu sync.RWMutex -} - -// NewStateValidator creates a new state validator -func NewStateValidator() *StateValidator { - return &StateValidator{} -} - -// ValidateRoomInvariants checks that a room maintains valid state -func (sv *StateValidator) ValidateRoomInvariants(room *Room) error { - if room == nil { - return fmt.Errorf("room is nil") - } - - // Check player count constraints - if len(room.Players) == 0 { - return fmt.Errorf("room has no players") - } - - if len(room.Players) > 4 { - return fmt.Errorf("room has too many players: %d (max 4)", len(room.Players)) - } - - // Check player uniqueness - clientIDs := make(map[string]bool) - playerIDs := make(map[string]bool) - - for _, player := range room.Players { - if player == nil { - return fmt.Errorf("room contains nil player") - } - - // Check for duplicate client IDs - if clientIDs[player.ClientID] { - return fmt.Errorf("duplicate client ID in room: %s", player.ClientID) - } - clientIDs[player.ClientID] = true - - // Check for duplicate player IDs - if playerIDs[player.ID] { - return fmt.Errorf("duplicate player ID in room: %s", player.ID) - } - playerIDs[player.ID] = true - - // Validate player stats - if player.GamesPlayed < 0 { - return fmt.Errorf("player %s has negative games played: %d", player.ID, player.GamesPlayed) - } - - if player.GamesWon < 0 { - return fmt.Errorf("player %s has negative games won: %d", player.ID, player.GamesWon) - } - - if player.GamesWon > player.GamesPlayed { - return fmt.Errorf("player %s has more wins than games played: %d > %d", - player.ID, player.GamesWon, player.GamesPlayed) - } - } - - // Validate games in room - gameIDs := make(map[string]bool) - for gameID, game := range room.Games { - if gameIDs[gameID] { - return fmt.Errorf("duplicate game ID in room: %s", gameID) - } - gameIDs[gameID] = true - - if err := sv.ValidateGameInvariants(game); err != nil { - return fmt.Errorf("invalid game %s in room: %w", gameID, err) - } - - // Validate game belongs to room - if game.GetRoomID() != room.ID { - return fmt.Errorf("game %s reports different room ID: %s != %s", - gameID, game.GetRoomID(), room.ID) - } - } - - // Validate game history - for i, result := range room.GameHistory { - if result == nil { - return fmt.Errorf("room has nil game result at index %d", i) - } - - if result.GameID == "" { - return fmt.Errorf("game result at index %d has empty game ID", i) - } - - if result.Winner == "" { - return fmt.Errorf("game result at index %d has empty winner", i) - } - - if len(result.FinalScores) == 0 { - return fmt.Errorf("game result at index %d has no final scores", i) - } - } - - return nil -} - -// ValidateGameInvariants checks that a game maintains valid state -func (sv *StateValidator) ValidateGameInvariants(game *Game) error { - if game == nil { - return fmt.Errorf("game is nil") - } - - game.mu.RLock() - defer game.mu.RUnlock() - - if game.state == nil { - return fmt.Errorf("game state is nil") - } - - // Validate player count - if len(game.state.Players) > 4 { - return fmt.Errorf("game has too many players: %d (max 4)", len(game.state.Players)) - } - - // Validate current player index - if len(game.state.Players) > 0 { - if game.state.CurrentPlayerIndex < 0 { - return fmt.Errorf("current player index is negative: %d", game.state.CurrentPlayerIndex) - } - - if game.state.CurrentPlayerIndex >= len(game.state.Players) { - return fmt.Errorf("current player index %d out of bounds for %d players", - game.state.CurrentPlayerIndex, len(game.state.Players)) - } - } - - // Validate game phase - validPhases := map[string]bool{ - "waiting": true, "playing": true, "peeking": true, "knocked": true, "ended": true, - } - if !validPhases[game.state.GamePhase] { - return fmt.Errorf("invalid game phase: %s", game.state.GamePhase) - } - - // Phase-specific validations - switch game.state.GamePhase { - case "waiting": - if len(game.state.DiscardPile) != 0 { - return fmt.Errorf("waiting game should have empty discard pile, has %d cards", - len(game.state.DiscardPile)) - } - - if game.state.DrawPile != 0 { - return fmt.Errorf("waiting game should have draw pile = 0, has %d", game.state.DrawPile) - } - - case "playing", "peeking", "knocked", "ended": - if len(game.state.Players) < 2 { - return fmt.Errorf("started game must have at least 2 players, has %d", - len(game.state.Players)) - } - - // Validate deck state - expectedDeckSize := 52 - (len(game.state.Players) * 4) - len(game.state.DiscardPile) - if game.state.DrawnCard != nil { - expectedDeckSize-- - } - - if game.state.DrawPile != expectedDeckSize { - return fmt.Errorf("draw pile size %d doesn't match expected %d", - game.state.DrawPile, expectedDeckSize) - } - } - - // Validate players in started games - if game.state.GamePhase != "waiting" { - playerIDs := make(map[string]bool) - - for _, player := range game.state.Players { - if player == nil { - return fmt.Errorf("game contains nil player") - } - - // Check for duplicate player IDs - if playerIDs[player.ID] { - return fmt.Errorf("duplicate player ID in game: %s", player.ID) - } - playerIDs[player.ID] = true - - // Validate card count - if len(player.Cards) != 4 { - return fmt.Errorf("player %s has %d cards, expected 4", player.ID, len(player.Cards)) - } - - // Validate revealed cards - if len(player.RevealedCards) > 2 { - return fmt.Errorf("player %s has %d revealed cards, max 2", - player.ID, len(player.RevealedCards)) - } - - // Check revealed card indices are valid - for _, idx := range player.RevealedCards { - if idx < 0 || idx > 3 { - return fmt.Errorf("player %s has invalid revealed card index: %d", player.ID, idx) - } - } - - // Check for duplicate revealed indices - revealedSet := make(map[int]bool) - for _, idx := range player.RevealedCards { - if revealedSet[idx] { - return fmt.Errorf("player %s has duplicate revealed card index: %d", player.ID, idx) - } - revealedSet[idx] = true - } - - // Validate score is non-negative - if player.Score < 0 { - return fmt.Errorf("player %s has negative score: %d", player.ID, player.Score) - } - } - } - - // Validate knocked state - if game.state.GamePhase == "knocked" || game.state.GamePhase == "ended" { - if game.state.KnockedPlayerID == nil { - return fmt.Errorf("knocked/ended game must have KnockedPlayerID set") - } - - // Check knocked player exists - foundKnocker := false - for _, player := range game.state.Players { - if player.ID == *game.state.KnockedPlayerID { - foundKnocker = true - break - } - } - - if !foundKnocker { - return fmt.Errorf("knocked player ID %s not found in game", *game.state.KnockedPlayerID) - } - } else { - if game.state.KnockedPlayerID != nil { - return fmt.Errorf("non-knocked game should not have KnockedPlayerID set") - } - } - - // Validate discard pile - if len(game.state.DiscardPile) < 0 { - return fmt.Errorf("discard pile cannot have negative size") - } - - // In started games, discard pile should have at least 1 card initially - if game.state.GamePhase == "playing" && len(game.state.DiscardPile) == 0 && game.state.DrawnCard == nil { - return fmt.Errorf("playing game should have cards in discard pile or drawn card") - } - - return nil -} - -// ValidateHubState checks the overall hub state for consistency -func (sv *StateValidator) ValidateHubState(hub *GolfHub) error { - if hub == nil { - return fmt.Errorf("hub is nil") - } - - hub.mu.RLock() - defer hub.mu.RUnlock() - - // Validate rooms - roomIDs := make(map[string]bool) - for roomID, room := range hub.rooms { - if roomIDs[roomID] { - return fmt.Errorf("duplicate room ID: %s", roomID) - } - roomIDs[roomID] = true - - if err := sv.ValidateRoomInvariants(room); err != nil { - return fmt.Errorf("invalid room %s: %w", roomID, err) - } - - if room.ID != roomID { - return fmt.Errorf("room ID mismatch: map key %s vs room.ID %s", roomID, room.ID) - } - } - - // Validate client contexts - for client, ctx := range hub.clientContexts { - if client == nil { - return fmt.Errorf("hub has nil client in contexts") - } - - if ctx == nil { - return fmt.Errorf("hub has nil context for client") - } - - // If client is in a room, room must exist - if ctx.RoomID != "" { - if _, exists := hub.rooms[ctx.RoomID]; !exists { - return fmt.Errorf("client context references non-existent room: %s", ctx.RoomID) - } - - // If client is in a game, game must exist in the room - if ctx.GameID != "" { - room := hub.rooms[ctx.RoomID] - if _, exists := room.Games[ctx.GameID]; !exists { - return fmt.Errorf("client context references non-existent game %s in room %s", - ctx.GameID, ctx.RoomID) - } - } - } - - // Validate timestamps - if ctx.JoinedAt.After(ctx.LastAction) { - return fmt.Errorf("client joined after last action: %v > %v", ctx.JoinedAt, ctx.LastAction) - } - } - - return nil -} - -// ValidateGameTransition checks if a game state transition is valid -func (sv *StateValidator) ValidateGameTransition(oldPhase, newPhase string, playerCount int) error { - validTransitions := map[string][]string{ - "waiting": {"playing"}, - "playing": {"peeking", "knocked"}, - "peeking": {"playing"}, - "knocked": {"ended"}, - "ended": {}, // Terminal state - } - - validNext, exists := validTransitions[oldPhase] - if !exists { - return fmt.Errorf("unknown game phase: %s", oldPhase) - } - - for _, valid := range validNext { - if valid == newPhase { - return nil - } - } - - return fmt.Errorf("invalid phase transition: %s -> %s", oldPhase, newPhase) -} - -// ValidateCardOperation checks if a card operation is valid for the current game state -func (sv *StateValidator) ValidateCardOperation(game *Game, operation string, playerID string, cardIndex int) error { - if game == nil { - return fmt.Errorf("game is nil") - } - - game.mu.RLock() - defer game.mu.RUnlock() - - // Find the player - var player *Player - for _, p := range game.state.Players { - if p.ClientID == playerID { - player = p - break - } - } - - if player == nil { - return fmt.Errorf("player %s not found in game", playerID) - } - - // Operation-specific validations - switch operation { - case "peek": - if game.state.GamePhase != "playing" && game.state.GamePhase != "peeking" { - return fmt.Errorf("can only peek during playing phase") - } - - if len(player.RevealedCards) >= 2 { - return fmt.Errorf("player already peeked at maximum number of cards") - } - - if cardIndex < 0 || cardIndex > 3 { - return fmt.Errorf("invalid card index for peek: %d", cardIndex) - } - - // Check if already peeked at this card - for _, idx := range player.RevealedCards { - if idx == cardIndex { - return fmt.Errorf("already peeked at card %d", cardIndex) - } - } - - case "swap": - if game.state.GamePhase != "playing" && game.state.GamePhase != "knocked" { - return fmt.Errorf("can only swap during playing/knocked phases") - } - - if game.state.DrawnCard == nil { - return fmt.Errorf("no drawn card to swap") - } - - if cardIndex < 0 || cardIndex > 3 { - return fmt.Errorf("invalid card index for swap: %d", cardIndex) - } - - // Check if it's player's turn - currentPlayer := game.state.Players[game.state.CurrentPlayerIndex] - if currentPlayer.ClientID != playerID { - return fmt.Errorf("not player's turn") - } - - default: - return fmt.Errorf("unknown card operation: %s", operation) - } - - return nil -} \ No newline at end of file diff --git a/domains/games/apis/games_ws_backend/golf/types.go b/domains/games/apis/games_ws_backend/golf/types.go deleted file mode 100644 index 9b496732c..000000000 --- a/domains/games/apis/games_ws_backend/golf/types.go +++ /dev/null @@ -1,388 +0,0 @@ -package golf - -import ( - "encoding/json" - "fmt" - "math/rand" - "time" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/hub" -) - -// Room represents a persistent room where multiple games can be played -type Room struct { - ID string `json:"id"` - Players []*Player `json:"players"` - Games map[string]*Game `json:"games"` // Active games mapped by game ID - GameHistory []*GameResult `json:"gameHistory"` - CreatedAt time.Time `json:"createdAt"` - LastActivity time.Time `json:"lastActivity"` -} - -// MarshalJSON implements custom JSON marshaling for Room -// This ensures that Games are serialized using their GetState() method -func (r *Room) MarshalJSON() ([]byte, error) { - // Create a temporary struct for JSON serialization - type Alias Room - - // Convert Games map to GameState map. Room serialization reaches every - // room member (and any future spectator), so it must carry NO private - // state: GetPublicState strips card faces, revealed indexes, and the - // held drawn card for in-progress games (issue #1187 phase 0 — the - // room-state redaction leak). - gameStates := make(map[string]*GameState) - for gameID, game := range r.Games { - if game != nil { - gameStates[gameID] = game.GetPublicState() - } - } - - // Create the JSON representation - return json.Marshal(&struct { - *Alias - Games map[string]*GameState `json:"games"` - }{ - Alias: (*Alias)(r), - Games: gameStates, - }) -} - -// ClientContext holds the complete context for a client including room and game state -type ClientContext struct { - RoomID string `json:"roomId"` // Room the client is in (empty if not in room) - GameID string `json:"gameId"` // Game the client is in (empty if not in specific game) - PlayerID string `json:"playerId"` // Player ID for faster lookups - JoinedAt time.Time `json:"joinedAt"` // When client joined the room - LastAction time.Time `json:"lastAction"` // Last action timestamp -} - -// GameResult stores the outcome of a completed game. Winner is the display -// string ("A & B" on a shared win); Winners is the typed list (issue #1187 -// phase 0 — non-knocker ties are shared wins). -type GameResult struct { - GameID string `json:"gameId"` - Winner string `json:"winner"` - Winners []string `json:"winners"` - FinalScores []*FinalScore `json:"finalScores"` - CompletedAt time.Time `json:"completedAt"` -} - -// Card represents a playing card -type Card struct { - Rank string `json:"rank"` - Suit string `json:"suit"` -} - -// Player represents a player in the game and room -type Player struct { - // Game-specific fields - ID string `json:"id"` - Name string `json:"name"` - Cards []*Card `json:"cards"` - Score int `json:"score"` - RevealedCards []int `json:"revealedCards"` - IsReady bool `json:"isReady"` - HasPeeked bool `json:"hasPeeked"` - - // Room/persistence fields - ClientID string `json:"clientId"` - TotalScore int `json:"totalScore"` // Running total across all games - GamesPlayed int `json:"gamesPlayed"` - GamesWon int `json:"gamesWon"` - IsConnected bool `json:"isConnected"` - JoinedAt time.Time `json:"joinedAt"` -} - -// GameState represents the full game state -type GameState struct { - ID string `json:"id"` - Players []*Player `json:"players"` - CurrentPlayerIndex int `json:"currentPlayerIndex"` - DrawPile int `json:"drawPile"` - DiscardPile []*Card `json:"discardPile"` - GamePhase string `json:"gamePhase"` // waiting, playing, peeking, knocked, ended - KnockedPlayerID *string `json:"knockedPlayerID"` - DrawnCard *Card `json:"drawnCard"` - PeekedAtDrawPile bool `json:"peekedAtDrawPile"` - AllPlayersPeeked bool `json:"allPlayersPeeked"` -} - -// Client-to-server message types -type CreateRoomMessage struct { - Type string `json:"type"` -} - -type CreateGameMessage struct { - Type string `json:"type"` - RoomID string `json:"roomId"` -} - -type JoinGameMessage struct { - Type string `json:"type"` - RoomID string `json:"roomId"` - GameID string `json:"gameId"` // Required - must specify which game to join -} - -type StartGameMessage struct { - Type string `json:"type"` -} - -type PeekCardMessage struct { - Type string `json:"type"` - CardIndex int `json:"cardIndex"` -} - -type DrawCardMessage struct { - Type string `json:"type"` -} - -type TakeFromDiscardMessage struct { - Type string `json:"type"` -} - -type SwapCardMessage struct { - Type string `json:"type"` - CardIndex int `json:"cardIndex"` -} - -type DiscardDrawnMessage struct { - Type string `json:"type"` -} - -type KnockMessage struct { - Type string `json:"type"` -} - -type HideCardsMessage struct { - Type string `json:"type"` -} - -type StartNewGameMessage struct { - Type string `json:"type"` -} - -type GetRoomStateMessage struct { - Type string `json:"type"` -} - -// New message types for multi-game support -type ListGamesMessage struct { - Type string `json:"type"` -} - -type CreateGameInRoomMessage struct { - Type string `json:"type"` -} - -type LeaveGameMessage struct { - Type string `json:"type"` -} - -// Server-to-client message types -type GameJoinedMessage struct { - Type string `json:"type"` - PlayerID string `json:"playerId"` - GameState *GameState `json:"gameState"` -} - -type GameStateUpdateMessage struct { - Type string `json:"type"` - GameState *GameState `json:"gameState"` -} - -type ErrorMessage struct { - Type string `json:"type"` - Message string `json:"message"` -} - -type GameStartedMessage struct { - Type string `json:"type"` -} - -type TurnChangedMessage struct { - Type string `json:"type"` - PlayerName string `json:"playerName"` -} - -type PlayerKnockedMessage struct { - Type string `json:"type"` - PlayerName string `json:"playerName"` -} - -type FinalScore struct { - PlayerName string `json:"playerName"` - Score int `json:"score"` -} - -// GameEndedMessage announces the end of a game. Winner is the display -// string ("A & B" on a shared win) so existing clients render ties -// unchanged; Winners is the typed list. -type GameEndedMessage struct { - Type string `json:"type"` - Winner string `json:"winner"` - Winners []string `json:"winners"` - FinalScores []*FinalScore `json:"finalScores"` -} - -type RoomJoinedMessage struct { - Type string `json:"type"` - PlayerID string `json:"playerId"` - RoomState *Room `json:"roomState"` -} - -type RoomStateUpdateMessage struct { - Type string `json:"type"` - RoomState *Room `json:"roomState"` -} - -type NewGameStartedMessage struct { - Type string `json:"type"` - GameID string `json:"gameId"` - PreviousGameID string `json:"previousGameId,omitempty"` -} - -// Server-to-client message types for multi-game support -type GameListMessage struct { - Type string `json:"type"` - Games map[string]*Game `json:"games"` // Games in current room -} - -type GameListUpdateMessage struct { - Type string `json:"type"` - Action string `json:"action"` // "added", "removed", "updated" - GameID string `json:"gameId"` -} - -// Authentication message types -type AuthenticatedMessage struct { - Type string `json:"type"` - SessionToken string `json:"sessionToken"` - PlayerID string `json:"playerId"` - Reconnected bool `json:"reconnected"` -} - -// PlayerSession tracks a player's persistent session state across connections. -type PlayerSession struct { - PlayerID string - Client *hub.Client // current client, nil when disconnected - ClientID string // getClientID(Client) cached for game lookups - RoomID string - GameID string - DisconnectedAt *time.Time // nil when connected - JoinedAt time.Time - LastAction time.Time -} - -// Generic message for parsing -type IncomingMessage struct { - Type string `json:"type"` - RoomID string `json:"roomId,omitempty"` - GameID string `json:"gameId,omitempty"` - CardIndex int `json:"cardIndex,omitempty"` - SessionToken string `json:"sessionToken,omitempty"` -} - -// Card constants -var ( - Suits = []string{"♠", "♥", "♦", "♣"} - Ranks = []string{"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} -) - -// GetCardValue returns the point value of a card -func GetCardValue(card *Card) int { - switch card.Rank { - case "A": - return 1 - case "J": - return 0 // Jack is worth 0 points - case "Q", "K": - return 10 - default: - // Parse numeric ranks - value := 0 - fmt.Sscanf(card.Rank, "%d", &value) - return value - } -} - -// CreateDeck creates a standard 52-card deck -func CreateDeck() []*Card { - deck := make([]*Card, 0, 52) - for _, suit := range Suits { - for _, rank := range Ranks { - deck = append(deck, &Card{Rank: rank, Suit: suit}) - } - } - return deck -} - -// ShuffleDeck shuffles a deck of cards -func ShuffleDeck(deck []*Card) { - r := rand.New(rand.NewSource(time.Now().UnixNano())) - r.Shuffle(len(deck), func(i, j int) { - deck[i], deck[j] = deck[j], deck[i] - }) -} - -// GenerateGameID generates a 6-character uppercase alphanumeric game ID -func GenerateGameID() string { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - b := make([]byte, 6) - for i := range b { - b[i] = charset[rand.Intn(len(charset))] - } - return string(b) -} - -// GenerateRoomID generates a 6-character uppercase alphanumeric room ID -func GenerateRoomID() string { - return GenerateGameID() // Same format as game ID -} - -// GeneratePlayerName generates a simple player name -func GeneratePlayerName(playerNumber int) string { - return fmt.Sprintf("Player %d", playerNumber) -} - -// ValidateCardIndex checks if a card index is valid -func ValidateCardIndex(index int) error { - if index < 0 || index > 3 { - return fmt.Errorf("invalid card index: %d", index) - } - return nil -} - -// CalculatePlayerScore calculates the score for a player's revealed cards -func CalculatePlayerScore(player *Player) int { - score := 0 - for _, idx := range player.RevealedCards { - if idx >= 0 && idx < len(player.Cards) && player.Cards[idx] != nil { - score += GetCardValue(player.Cards[idx]) - } - } - return score -} - -// CreateHiddenCards creates an array of 4 nil cards for a player -func CreateHiddenCards() []*Card { - return make([]*Card, 4) -} - -// ParseIncomingMessage parses a generic incoming message -func ParseIncomingMessage(data []byte) (*IncomingMessage, error) { - var msg IncomingMessage - if err := json.Unmarshal(data, &msg); err != nil { - return nil, fmt.Errorf("failed to parse message: %w", err) - } - return &msg, nil -} - -// GetPlayerByClientID returns the player associated with a client ID -func (r *Room) GetPlayerByClientID(clientID string) *Player { - for _, player := range r.Players { - if player.ClientID == clientID { - return player - } - } - return nil -} diff --git a/domains/games/apis/games_ws_backend/golf/types_test.go b/domains/games/apis/games_ws_backend/golf/types_test.go deleted file mode 100644 index 0385192fe..000000000 --- a/domains/games/apis/games_ws_backend/golf/types_test.go +++ /dev/null @@ -1,722 +0,0 @@ -package golf - -import ( - "encoding/json" - "testing" - - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" -) - -// Message Creation and Serialization Tests - -func TestCreateGameMessage(t *testing.T) { - msg := CreateGameMessage{ - Type: "createGame", - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal CreateGameMessage: %v", err) - } - - var parsed CreateGameMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal CreateGameMessage: %v", err) - } - - if parsed.Type != "createGame" { - t.Errorf("Expected type 'createGame', got %s", parsed.Type) - } -} - -func TestJoinGameMessage(t *testing.T) { - msg := JoinGameMessage{ - Type: "joinGame", - GameID: "ABC123", - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal JoinGameMessage: %v", err) - } - - var parsed JoinGameMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal JoinGameMessage: %v", err) - } - - if parsed.Type != "joinGame" { - t.Errorf("Expected type 'joinGame', got %s", parsed.Type) - } - if parsed.GameID != "ABC123" { - t.Errorf("Expected gameID 'ABC123', got %s", parsed.GameID) - } -} - -func TestGameJoinedMessage(t *testing.T) { - gameState := &GameState{ - ID: "TEST123", - Players: []*Player{ - { - ID: "player1", - Name: "Player 1", - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: []int{}, - IsReady: false, - }, - }, - CurrentPlayerIndex: 0, - DrawPile: 44, - DiscardPile: []*Card{{Rank: "7", Suit: "♥"}}, - GamePhase: "waiting", - KnockedPlayerID: nil, - DrawnCard: nil, - } - - msg := GameJoinedMessage{ - Type: "gameJoined", - PlayerID: "player1", - GameState: gameState, - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal GameJoinedMessage: %v", err) - } - - var parsed GameJoinedMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal GameJoinedMessage: %v", err) - } - - if parsed.Type != "gameJoined" { - t.Errorf("Expected type 'gameJoined', got %s", parsed.Type) - } - if parsed.PlayerID != "player1" { - t.Errorf("Expected playerID 'player1', got %s", parsed.PlayerID) - } - if parsed.GameState.ID != "TEST123" { - t.Errorf("Expected game ID 'TEST123', got %s", parsed.GameState.ID) - } -} - -func TestErrorMessage(t *testing.T) { - msg := ErrorMessage{ - Type: "error", - Message: "Not your turn", - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal ErrorMessage: %v", err) - } - - var parsed ErrorMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal ErrorMessage: %v", err) - } - - if parsed.Type != "error" { - t.Errorf("Expected type 'error', got %s", parsed.Type) - } - if parsed.Message != "Not your turn" { - t.Errorf("Expected message 'Not your turn', got %s", parsed.Message) - } -} - -func TestTurnChangedMessage(t *testing.T) { - msg := TurnChangedMessage{ - Type: "turnChanged", - PlayerName: "Player 2", - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal TurnChangedMessage: %v", err) - } - - var parsed TurnChangedMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal TurnChangedMessage: %v", err) - } - - if parsed.Type != "turnChanged" { - t.Errorf("Expected type 'turnChanged', got %s", parsed.Type) - } - if parsed.PlayerName != "Player 2" { - t.Errorf("Expected playerName 'Player 2', got %s", parsed.PlayerName) - } -} - -func TestGameEndedMessage(t *testing.T) { - msg := GameEndedMessage{ - Type: "gameEnded", - Winner: "Player 1", - FinalScores: []*FinalScore{ - {PlayerName: "Player 1", Score: 8}, - {PlayerName: "Player 2", Score: 15}, - }, - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal GameEndedMessage: %v", err) - } - - var parsed GameEndedMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal GameEndedMessage: %v", err) - } - - if parsed.Type != "gameEnded" { - t.Errorf("Expected type 'gameEnded', got %s", parsed.Type) - } - if parsed.Winner != "Player 1" { - t.Errorf("Expected winner 'Player 1', got %s", parsed.Winner) - } - if len(parsed.FinalScores) != 2 { - t.Fatalf("Expected 2 final scores, got %d", len(parsed.FinalScores)) - } - if parsed.FinalScores[0].Score != 8 { - t.Errorf("Expected Player 1 score 8, got %d", parsed.FinalScores[0].Score) - } -} - -func TestCardSerialization(t *testing.T) { - card := &Card{ - Rank: "K", - Suit: "♠", - } - - data, err := json.Marshal(card) - if err != nil { - t.Fatalf("Failed to marshal Card: %v", err) - } - - var parsed Card - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal Card: %v", err) - } - - if parsed.Rank != "K" { - t.Errorf("Expected rank 'K', got %s", parsed.Rank) - } - if parsed.Suit != "♠" { - t.Errorf("Expected suit '♠', got %s", parsed.Suit) - } -} - -func TestPlayerSerialization(t *testing.T) { - player := &Player{ - ID: "player123", - Name: "Test Player", - Cards: []*Card{ - {Rank: "A", Suit: "♥"}, - {Rank: "7", Suit: "♦"}, - nil, - nil, - }, - Score: 8, - RevealedCards: []int{0, 1}, - IsReady: true, - } - - data, err := json.Marshal(player) - if err != nil { - t.Fatalf("Failed to marshal Player: %v", err) - } - - var parsed Player - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal Player: %v", err) - } - - if parsed.ID != "player123" { - t.Errorf("Expected ID 'player123', got %s", parsed.ID) - } - if parsed.Name != "Test Player" { - t.Errorf("Expected name 'Test Player', got %s", parsed.Name) - } - if len(parsed.Cards) != 4 { - t.Fatalf("Expected 4 cards, got %d", len(parsed.Cards)) - } - if parsed.Cards[0] == nil || parsed.Cards[0].Rank != "A" { - t.Error("First card not serialized correctly") - } - if parsed.Cards[2] != nil { - t.Error("Nil card should remain nil") - } - if len(parsed.RevealedCards) != 2 { - t.Errorf("Expected 2 revealed cards, got %d", len(parsed.RevealedCards)) - } -} - -func TestGameStateSerialization(t *testing.T) { - knockedID := "player1" - gameState := &GameState{ - ID: "GAME123", - Players: []*Player{ - { - ID: "player1", - Name: "Player 1", - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: []int{}, - IsReady: true, - }, - { - ID: "player2", - Name: "Player 2", - Cards: CreateHiddenCards(), - Score: 0, - RevealedCards: []int{0, 3}, - IsReady: true, - }, - }, - CurrentPlayerIndex: 1, - DrawPile: 38, - DiscardPile: []*Card{ - {Rank: "Q", Suit: "♣"}, - }, - GamePhase: "knocked", - KnockedPlayerID: &knockedID, - DrawnCard: &Card{Rank: "5", Suit: "♠"}, - } - - data, err := json.Marshal(gameState) - if err != nil { - t.Fatalf("Failed to marshal GameState: %v", err) - } - - var parsed GameState - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal GameState: %v", err) - } - - if parsed.ID != "GAME123" { - t.Errorf("Expected ID 'GAME123', got %s", parsed.ID) - } - if len(parsed.Players) != 2 { - t.Fatalf("Expected 2 players, got %d", len(parsed.Players)) - } - if parsed.CurrentPlayerIndex != 1 { - t.Errorf("Expected current player index 1, got %d", parsed.CurrentPlayerIndex) - } - if parsed.DrawPile != 38 { - t.Errorf("Expected draw pile 38, got %d", parsed.DrawPile) - } - if len(parsed.DiscardPile) != 1 { - t.Errorf("Expected 1 card in discard pile, got %d", len(parsed.DiscardPile)) - } - if parsed.GamePhase != "knocked" { - t.Errorf("Expected game phase 'knocked', got %s", parsed.GamePhase) - } - if parsed.KnockedPlayerID == nil || *parsed.KnockedPlayerID != "player1" { - t.Error("Knocked player ID not serialized correctly") - } - if parsed.DrawnCard == nil || parsed.DrawnCard.Rank != "5" { - t.Error("Drawn card not serialized correctly") - } -} - -func TestMessageRoundTrip(t *testing.T) { - // Test that we can parse our own generated messages - tests := []struct { - name string - msg interface{} - typ string - }{ - { - name: "create game", - msg: &CreateGameMessage{Type: "createGame"}, - typ: "createGame", - }, - { - name: "join game", - msg: &JoinGameMessage{Type: "joinGame", GameID: "XYZ789"}, - typ: "joinGame", - }, - { - name: "start game", - msg: &StartGameMessage{Type: "startGame"}, - typ: "startGame", - }, - { - name: "peek card", - msg: &PeekCardMessage{Type: "peekCard", CardIndex: 2}, - typ: "peekCard", - }, - { - name: "draw card", - msg: &DrawCardMessage{Type: "drawCard"}, - typ: "drawCard", - }, - { - name: "knock", - msg: &KnockMessage{Type: "knock"}, - typ: "knock", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Marshal to JSON - data, err := json.Marshal(tt.msg) - if err != nil { - t.Fatalf("Failed to marshal message: %v", err) - } - - // Parse as incoming message - parsed, err := ParseIncomingMessage(data) - if err != nil { - t.Fatalf("Failed to parse message: %v", err) - } - - if parsed.Type != tt.typ { - t.Errorf("Expected type %s, got %s", tt.typ, parsed.Type) - } - }) - } -} - -func TestCreateDeck(t *testing.T) { - deck := CreateDeck() - - if len(deck) != 52 { - t.Errorf("Expected 52 cards, got %d", len(deck)) - } - - // Check for duplicates - cardMap := make(map[string]bool) - for _, card := range deck { - key := card.Rank + card.Suit - if cardMap[key] { - t.Errorf("Duplicate card found: %s%s", card.Rank, card.Suit) - } - cardMap[key] = true - } - - // Check all suits and ranks are present - suitCount := make(map[string]int) - rankCount := make(map[string]int) - - for _, card := range deck { - suitCount[card.Suit]++ - rankCount[card.Rank]++ - } - - for _, suit := range Suits { - if suitCount[suit] != 13 { - t.Errorf("Expected 13 cards of suit %s, got %d", suit, suitCount[suit]) - } - } - - for _, rank := range Ranks { - if rankCount[rank] != 4 { - t.Errorf("Expected 4 cards of rank %s, got %d", rank, rankCount[rank]) - } - } -} - -func TestShuffleDeck(t *testing.T) { - deck1 := CreateDeck() - deck2 := CreateDeck() - - // Create copies for comparison - original := make([]*Card, 52) - copy(original, deck1) - - ShuffleDeck(deck1) - ShuffleDeck(deck2) - - // Check that deck is shuffled (extremely unlikely to be in same order) - sameOrder1 := true - sameOrder2 := true - - for i := 0; i < 52; i++ { - if original[i].Rank != deck1[i].Rank || original[i].Suit != deck1[i].Suit { - sameOrder1 = false - } - if deck1[i].Rank != deck2[i].Rank || deck1[i].Suit != deck2[i].Suit { - sameOrder2 = false - } - } - - if sameOrder1 { - t.Error("Deck was not shuffled (same as original)") - } - - if !sameOrder2 { - // Good - two shuffles produced different results - } - - // Verify all cards still present - if len(deck1) != 52 { - t.Errorf("Shuffled deck has wrong size: %d", len(deck1)) - } -} - -func TestGeneratePlayerName(t *testing.T) { - tests := []struct { - playerNum int - expected string - }{ - {1, "Player 1"}, - {2, "Player 2"}, - {3, "Player 3"}, - {4, "Player 4"}, - {100, "Player 100"}, - } - - for _, tt := range tests { - name := GeneratePlayerName(tt.playerNum) - if name != tt.expected { - t.Errorf("GeneratePlayerName(%d) = %s, expected %s", - tt.playerNum, name, tt.expected) - } - } -} - -func TestCalculatePlayerScore(t *testing.T) { - player := &Player{ - Cards: []*Card{ - {Rank: "A", Suit: "♠"}, // 1 - {Rank: "5", Suit: "♥"}, // 5 - {Rank: "K", Suit: "♦"}, // 10 - {Rank: "7", Suit: "♣"}, // 7 - }, - RevealedCards: []int{0, 1, 2}, // Total: 1 + 5 + 10 = 16 - } - - score := CalculatePlayerScore(player) - if score != 16 { - t.Errorf("Expected score 16, got %d", score) - } - - // Test with no revealed cards - player.RevealedCards = []int{} - score = CalculatePlayerScore(player) - if score != 0 { - t.Errorf("Expected score 0 with no revealed cards, got %d", score) - } - - // Test with nil cards - player.Cards[1] = nil - player.RevealedCards = []int{0, 1, 2} - score = CalculatePlayerScore(player) - if score != 11 { // 1 + 0 + 10 - t.Errorf("Expected score 11 with nil card, got %d", score) - } -} - -func TestNilHandling(t *testing.T) { - // Test that nil cards are properly handled in JSON - player := &Player{ - ID: "test", - Name: "Test", - Cards: []*Card{nil, nil, nil, nil}, - Score: 0, - } - - data, err := json.Marshal(player) - if err != nil { - t.Fatalf("Failed to marshal player with nil cards: %v", err) - } - - var parsed Player - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal player with nil cards: %v", err) - } - - for i, card := range parsed.Cards { - if card != nil { - t.Errorf("Expected nil card at index %d", i) - } - } -} - -// Room serialization reaches every room member, so in-progress games must be -// redacted (issue #1187 phase 0 — the room-state leak). -func TestRoomMarshalRedactsInProgressGames(t *testing.T) { - game := NewGame("GAME01", &players.DeterministicIDGenerator{}) - if _, err := game.AddPlayer("client1", "p1", "Alice"); err != nil { - t.Fatalf("Failed to add player: %v", err) - } - if _, err := game.AddPlayer("client2", "p2", "Bob"); err != nil { - t.Fatalf("Failed to add player: %v", err) - } - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - if err := game.DrawCard("client1"); err != nil { - t.Fatalf("Failed to draw: %v", err) - } - - room := &Room{ - ID: "ROOM01", - Games: map[string]*Game{"GAME01": game}, - } - data, err := json.Marshal(room) - if err != nil { - t.Fatalf("Failed to marshal room: %v", err) - } - - var parsed struct { - Games map[string]*GameState `json:"games"` - } - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal room: %v", err) - } - state, ok := parsed.Games["GAME01"] - if !ok { - t.Fatal("Expected game GAME01 in serialized room") - } - if state.DrawnCard != nil { - t.Error("Serialized room must not expose the held drawn card") - } - for _, player := range state.Players { - for i, card := range player.Cards { - if card != nil { - t.Errorf("Serialized room must not expose %s's card %d", player.Name, i) - } - } - if len(player.RevealedCards) != 0 { - t.Errorf("Serialized room must not expose %s's revealed indexes", player.Name) - } - } -} - -// The redaction must not eat the fields the lobby renders: game IDs, phase, -// player counts and names, and the public discard pile. -func TestRoomMarshalKeepsPublicGameFields(t *testing.T) { - game := NewGame("GAME01", &players.DeterministicIDGenerator{}) - game.AddPlayer("client1", "p1", "Alice") - game.AddPlayer("client2", "p2", "Bob") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - - room := &Room{ - ID: "ROOM01", - Games: map[string]*Game{"GAME01": game}, - } - data, err := json.Marshal(room) - if err != nil { - t.Fatalf("Failed to marshal room: %v", err) - } - - var parsed struct { - Games map[string]*GameState `json:"games"` - } - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal room: %v", err) - } - state := parsed.Games["GAME01"] - if state == nil { - t.Fatal("Expected game GAME01 in serialized room") - } - if state.GamePhase != "playing" { - t.Errorf("Expected gamePhase 'playing', got %q", state.GamePhase) - } - if len(state.Players) != 2 { - t.Fatalf("Expected 2 players, got %d", len(state.Players)) - } - if state.Players[0].Name != "Alice" || state.Players[1].Name != "Bob" { - t.Errorf("Expected player names preserved, got %q and %q", - state.Players[0].Name, state.Players[1].Name) - } - if len(state.DiscardPile) != 1 { - t.Errorf("Expected the public discard pile intact, got %d cards", len(state.DiscardPile)) - } - if state.DrawPile == 0 { - t.Error("Expected the draw pile count preserved") - } -} - -// Once a game ends everything is public: the room serialization must keep -// card faces and scores (no over-redaction). -func TestRoomMarshalEndedGameKeepsCards(t *testing.T) { - game := NewGame("GAME01", &players.DeterministicIDGenerator{}) - game.AddPlayer("client1", "p1", "Alice") - game.AddPlayer("client2", "p2", "Bob") - if err := game.StartGame(); err != nil { - t.Fatalf("Failed to start game: %v", err) - } - game.state.GamePhase = "ended" - game.calculateFinalScores() - - room := &Room{ - ID: "ROOM01", - Games: map[string]*Game{"GAME01": game}, - } - data, err := json.Marshal(room) - if err != nil { - t.Fatalf("Failed to marshal room: %v", err) - } - - var parsed struct { - Games map[string]*GameState `json:"games"` - } - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal room: %v", err) - } - state := parsed.Games["GAME01"] - if state == nil { - t.Fatal("Expected game GAME01 in serialized room") - } - for _, player := range state.Players { - for i, card := range player.Cards { - if card == nil || card.Rank == "" { - t.Errorf("Ended game should serialize %s's card %d", player.Name, i) - } - } - if len(player.RevealedCards) != 4 { - t.Errorf("Ended game should serialize %s's revealed indexes, got %d", - player.Name, len(player.RevealedCards)) - } - } -} - -// gameEnded on a shared win: winner is the joined display string for legacy -// clients, winners is the typed list. -func TestGameEndedMessageSharedWin(t *testing.T) { - msg := GameEndedMessage{ - Type: "gameEnded", - Winner: "Alice & Bob", - Winners: []string{"Alice", "Bob"}, - FinalScores: []*FinalScore{ - {PlayerName: "Alice", Score: 5}, - {PlayerName: "Bob", Score: 5}, - {PlayerName: "Carol", Score: 12}, - }, - } - - data, err := json.Marshal(msg) - if err != nil { - t.Fatalf("Failed to marshal GameEndedMessage: %v", err) - } - - // The raw wire must carry both fields. - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - t.Fatalf("Failed to unmarshal raw message: %v", err) - } - if _, ok := raw["winner"]; !ok { - t.Error("Wire message must keep the legacy winner field") - } - if _, ok := raw["winners"]; !ok { - t.Error("Wire message must carry the typed winners field") - } - - var parsed GameEndedMessage - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("Failed to unmarshal GameEndedMessage: %v", err) - } - if parsed.Winner != "Alice & Bob" { - t.Errorf("Expected joined display winner, got %q", parsed.Winner) - } - if len(parsed.Winners) != 2 || parsed.Winners[0] != "Alice" || parsed.Winners[1] != "Bob" { - t.Errorf("Expected Winners == [Alice Bob], got %v", parsed.Winners) - } -} diff --git a/domains/games/apis/games_ws_backend/main.go b/domains/games/apis/games_ws_backend/main.go index 956e147c1..ad500a58c 100644 --- a/domains/games/apis/games_ws_backend/main.go +++ b/domains/games/apis/games_ws_backend/main.go @@ -7,9 +7,7 @@ import ( "net/http" "os" - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/golf" "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/hub" - "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/players" "github.com/muchq/moonbase/domains/games/apis/games_ws_backend/thoughts" ) @@ -43,13 +41,6 @@ func main() { hub.ServeWs(thoughtsHub, w, r) }) - // Serve golf backend - golfHub := golf.NewGolfHub(&players.WhimsicalIDGenerator{}) - go golfHub.Run() - http.HandleFunc("/games/v1/golf-ws", func(w http.ResponseWriter, r *http.Request) { - hub.ServeWs(golfHub, w, r) - }) - slog.Info("Server listening", "addr", *addr) err := http.ListenAndServe(*addr, nil) if err != nil { diff --git a/domains/games/apis/golf_hub/README.md b/domains/games/apis/golf_hub/README.md index fc8ef1f1a..f7c671ec9 100644 --- a/domains/games/apis/golf_hub/README.md +++ b/domains/games/apis/golf_hub/README.md @@ -1,11 +1,9 @@ # golf_hub — the Golf game hub on smithy-cpp event streams -The rebuild of `games_ws_backend`'s golf hub (the deployed Go WebSocket -service) on smithy-cpp's Phase 8 streaming stack: a modeled protocol with -generated async handlers (ADR-0021), `SessionRegistry` fan-out with -reconnect grace (ADR-0017/0020/0022), the JSON-text browser wire -(ADR-0018), and ticket auth ahead of the 101. Tracking issue: -MoonBase#1187. +The golf backend behind muchq.com/golf, on smithy-cpp's streaming stack: +a modeled protocol with generated async handlers (ADR-0021), +`SessionRegistry` fan-out with reconnect grace (ADR-0017/0020/0022), the +JSON-text browser wire (ADR-0018), and ticket auth ahead of the 101. ## The model (two namespaces, per #79) @@ -18,25 +16,22 @@ MoonBase#1187. `golf` member in each streaming union. Adding a second game later is one new member per union; the room layer never changes shape. -## The rules (resolved in #1187) +## The rules -Go-hub semantics with the three corrections where `libs/cards/golf` had -the better rule — an exhausted draw pile ends the game, three of a kind -scores exactly one card, non-knocker ties are shared wins (knocker still -takes ties alone). The engine is `libs/cards/golf`'s immutable -`GameState`, reshaped in place: it gained the Go opening (two own-card -peeks per player, a table-wide reveal countdown, `hideCards`), -`removePlayer` for abandoned seats, and kept its draw mechanic — which is -gameplay-equivalent to the Go hub's draw-then-decide (a draw is a peek at -the pile top; take-from-discard commits to a slot in one step because the -discard top is public). +Four-card golf for 2–4 players: each player peeks at two own cards, a +table-wide reveal countdown opens play, a draw is a peek at the pile top, +take-from-discard commits to a slot in one step (the discard top is +public), a knock gives every other player one final turn, an exhausted +draw pile ends the game, three of a kind scores exactly one card, and +non-knocker ties are shared wins (the knocker takes ties alone). The +engine is `libs/cards/golf`'s immutable `GameState`, which also carries +`hideCards` and `removePlayer` for abandoned seats. ## Redaction Every game broadcast is per-recipient (`ViewLocked`): own card faces only at the viewer's peeked indexes, the drawn card only to its holder, other -hands always null slots, scores only at game end — tighter than v1, which -shipped a player their whole hand during peek windows. Room state carries +hands always null slots, scores only at game end. Room state carries lobby-safe summaries only. ## Scaffold notes / deferred @@ -56,9 +51,8 @@ lobby-safe summaries only. are 6-char uppercase codes for permalink compatibility. - Observability: unary requests ride the shared aura chain (#1185); the stream side counts admissions, live sessions, disconnects, grace - expiries, and the command/event flow (`stream_*`, phase 4). -- `ALLOWED_ORIGINS` unset admits all origins (dev parity with the Go - hub's DEV_MODE); production sets the allowlist. -- Deployed behind Caddy at `/games/v2/*` (phase 4, - `deploy/consolidated`); the UI's v2 beta switch is `?golf=v2` (phase - 3). Next (#1187): default flip + retirements (phase 5). + expiries, and the command/event flow (`stream_*`). +- `ALLOWED_ORIGINS` unset admits all origins (local dev); production + sets the allowlist. +- Deployed behind Caddy at `/games/v2/*` (`deploy/consolidated`); the + muchq.com golf UI's only backend. diff --git a/go.mod b/go.mod index 495391018..a9b5c3c44 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/muchq/moonbase go 1.25.0 require ( - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 diff --git a/go.sum b/go.sum index 0582da876..40f84fad4 100644 --- a/go.sum +++ b/go.sum @@ -65,16 +65,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= @@ -89,12 +89,8 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 4dcbf97c9cdcf1ef9a94f96576bc319d5dd84a95 Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Wed, 2 Sep 2026 21:12:22 -0400 Subject: [PATCH 2/2] golf_hub: comments and labels describe one golf backend --- deploy/consolidated/README.md | 4 ++-- deploy/consolidated/compose.yaml | 9 ++++----- domains/games/apis/games_ws_backend/README.md | 4 ++-- domains/games/apis/golf_hub/README.md | 6 +++--- domains/games/apis/golf_hub/golf_hub_main.cc | 4 ++-- domains/games/apis/golf_hub/id_generator.cc | 2 +- domains/games/apis/golf_hub/id_generator.h | 14 ++++++-------- 7 files changed, 20 insertions(+), 23 deletions(-) diff --git a/deploy/consolidated/README.md b/deploy/consolidated/README.md index 323b82f80..31553406a 100644 --- a/deploy/consolidated/README.md +++ b/deploy/consolidated/README.md @@ -113,8 +113,8 @@ To ship a change without restarting the whole stack: ``` SERVICE DESCRIPTION -games_ws_backend Websocket backend for v1 games -golf_hub Golf v2 hub on smithy event streams (/games/v2/*) +games_ws_backend Websocket backend for thoughts +golf_hub Golf hub on smithy event streams (/games/v2/*) mcpserver Model Context Protocol server ... ``` diff --git a/deploy/consolidated/compose.yaml b/deploy/consolidated/compose.yaml index d0af252be..a7f79f098 100644 --- a/deploy/consolidated/compose.yaml +++ b/deploy/consolidated/compose.yaml @@ -47,7 +47,7 @@ services: games_ws_backend: image: ghcr.io/muchq/games_ws_backend:${GAMES_WS_BACKEND_SHA:-${DEPLOY_SHA:-latest}} labels: - com.muchq.description: "Websocket backend for v1 games" + com.muchq.description: "Websocket backend for thoughts" restart: always logging: *default-logging ports: @@ -62,12 +62,11 @@ services: cpus: '0.25' memory: 256M - # The smithy event-stream golf service (/games/v2/*, MoonBase#1187); - # serves the v2 beta while games_ws_backend keeps serving v1. + # The smithy event-stream golf service (/games/v2/*). golf_hub: image: ghcr.io/muchq/golf_hub:${GOLF_HUB_SHA:-${DEPLOY_SHA:-latest}} labels: - com.muchq.description: "Golf v2 hub on smithy event streams (/games/v2/*)" + com.muchq.description: "Golf hub on smithy event streams (/games/v2/*)" restart: always logging: *default-logging ports: @@ -75,7 +74,7 @@ services: environment: PORT: "8089" # CSWSH defense: the play socket admits only the deployed UI's - # origins (parity with the Go hub's allowlist). + # origins. ALLOWED_ORIGINS: https://muchq.com,https://www.muchq.com TRUSTED_PROXY_CIDRS: *caddy_ip # Credentials live in the hub's own database on the shared postgres diff --git a/domains/games/apis/games_ws_backend/README.md b/domains/games/apis/games_ws_backend/README.md index 92517d6f9..1aef5782a 100644 --- a/domains/games/apis/games_ws_backend/README.md +++ b/domains/games/apis/games_ws_backend/README.md @@ -1,10 +1,10 @@ # Game Server -A real-time multiplayer, multitenant WebSocket game server. +A real-time multiplayer WebSocket game server. ## Overview -This server hosts game backends on a single process, each on its own WebSocket endpoint: +Each game backend is registered on its own WebSocket endpoint: - **[Thoughts](thoughts/)** (`/games/v1/thoughts-ws`) — a chill 3D multiplayer vibe, playable at [muchq.com/thoughts](https://muchq.com/thoughts) diff --git a/domains/games/apis/golf_hub/README.md b/domains/games/apis/golf_hub/README.md index f7c671ec9..1035066f7 100644 --- a/domains/games/apis/golf_hub/README.md +++ b/domains/games/apis/golf_hub/README.md @@ -46,9 +46,9 @@ lobby-safe summaries only. token into their seat. Memory stays authoritative single-instance; fan-out is still process-local (step 3). Unset falls back to all-in-memory — dev mode and the test harness. -- Player ids are whimsical (`bouncy-coral-quokka-x9k2`, the Go - generator's word lists) and double as display names. Room and game ids - are 6-char uppercase codes for permalink compatibility. +- Player ids are whimsical (`bouncy-coral-quokka-x9k2`) and double as + display names. Room and game ids + are 6-char uppercase codes that ride in permalinks. - Observability: unary requests ride the shared aura chain (#1185); the stream side counts admissions, live sessions, disconnects, grace expiries, and the command/event flow (`stream_*`). diff --git a/domains/games/apis/golf_hub/golf_hub_main.cc b/domains/games/apis/golf_hub/golf_hub_main.cc index b350240b4..ebf248eeb 100644 --- a/domains/games/apis/golf_hub/golf_hub_main.cc +++ b/domains/games/apis/golf_hub/golf_hub_main.cc @@ -179,8 +179,8 @@ int main() { server.Handler()); // Gate chain: origin allowlist (browser CSWSH defense; unset - // ALLOWED_ORIGINS admits all origins — dev parity with the Go hub's - // DEV_MODE) -> ticket freshness -> the stream router's own refusals. + // ALLOWED_ORIGINS admits all origins, for local dev) -> ticket + // freshness -> the stream router's own refusals. const std::vector allowed_origins = futility::env::ReadList("ALLOWED_ORIGINS"); auto origin_gate = allowed_origins.empty() ? std::function( diff --git a/domains/games/apis/golf_hub/id_generator.cc b/domains/games/apis/golf_hub/id_generator.cc index 97c7b6464..fb24f7d12 100644 --- a/domains/games/apis/golf_hub/id_generator.cc +++ b/domains/games/apis/golf_hub/id_generator.cc @@ -11,7 +11,7 @@ namespace golf_hub { namespace { -// The Go hub's word lists (players.WhimsicalIDGenerator), verbatim. +// The same word lists as games_ws_backend's players.WhimsicalIDGenerator. constexpr std::string_view kAdjectives[] = {"bouncy", "giggly", "sparkly", "fuzzy", "wiggly", "snuggly", "dreamy", "bubbly", "twinkly", "jolly", "quirky", "peppy", "zesty", "frisky", "silly", diff --git a/domains/games/apis/golf_hub/id_generator.h b/domains/games/apis/golf_hub/id_generator.h index 70c43d3bb..606a39295 100644 --- a/domains/games/apis/golf_hub/id_generator.h +++ b/domains/games/apis/golf_hub/id_generator.h @@ -5,10 +5,9 @@ namespace golf_hub { -/// The hub's identifier seam, mirroring the Go hub's PlayerIDGenerator -/// and the cards library's Dealer: production randomness behind a small -/// interface so tests can script every id (including forcing the -/// game-code collision path). +/// The hub's identifier seam, in the shape of the cards library's Dealer: production randomness +/// behind a small interface so tests can script every id (including forcing the game-code collision +/// path). class IdGenerator { public: virtual ~IdGenerator() = default; @@ -21,14 +20,13 @@ class IdGenerator { /// the id must survive a permalink and a "type this code" exchange. virtual std::string RoomId() = 0; - /// A 6-char uppercase alphanumeric game code — the Go hub's format, - /// kept for permalink compatibility. + /// A 6-char uppercase alphanumeric game code, on the same terms as the + /// room code: it rides in permalinks. virtual std::string GameCode() = 0; }; /// Production ids: whimsical player names ("bouncy-coral-quokka-x9k2", -/// the Go hub's word lists, so beta players never see opaque ids), -/// short codes for rooms and games. +/// so players never see opaque ids), short codes for rooms and games. class WhimsicalIdGenerator final : public IdGenerator { public: std::string PlayerId() override;