From 04f6044aec1338a3d9b627e9d784aaa8ad63b449 Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Wed, 2 Sep 2026 21:04:03 -0400 Subject: [PATCH 1/3] golf: the hub event-stream wire is the only golf client The adapter that speaks to golf_hub takes over the GolfNetworkAdapter name; the legacy websocket adapter and plugin, the per-browser switch, the lobby toggle, and the permalink flag are gone. VITE_GOLF_WEBSOCKET_URL overrides the play socket; the session mint is derived from it. --- GOLF.md | 282 ------- README.md | 12 +- WORKING_AGREEMENT.md | 12 +- package.json | 2 +- src/apps/golf/components/GolfGame.module.css | 17 - src/apps/golf/components/GolfGame.tsx | 23 - src/hooks/useGolfGame.ts | 17 +- .../__tests__/golfNetworkPlugin.auth.test.ts | 276 ------- .../golfNetworkPlugin.gameEnded.test.ts | 95 --- src/plugins/golfNetworkPlugin.ts | 475 ------------ src/types/golfAdapter.ts | 28 +- src/utils/__tests__/golfPermalinks.test.ts | 22 - src/utils/__tests__/golfV2.test.ts | 51 -- ...Adapter.test.ts => networkAdapter.test.ts} | 34 +- src/utils/golfNotifications.ts | 4 +- src/utils/golfPermalinks.ts | 17 +- src/utils/golfV2.ts | 45 -- src/utils/golfV2Adapter.ts | 606 --------------- src/utils/networkAdapter.ts | 711 +++++++++++++----- 19 files changed, 582 insertions(+), 2147 deletions(-) delete mode 100644 GOLF.md delete mode 100644 src/plugins/__tests__/golfNetworkPlugin.auth.test.ts delete mode 100644 src/plugins/__tests__/golfNetworkPlugin.gameEnded.test.ts delete mode 100644 src/plugins/golfNetworkPlugin.ts delete mode 100644 src/utils/__tests__/golfV2.test.ts rename src/utils/__tests__/{golfV2Adapter.test.ts => networkAdapter.test.ts} (94%) delete mode 100644 src/utils/golfV2.ts delete mode 100644 src/utils/golfV2Adapter.ts diff --git a/GOLF.md b/GOLF.md deleted file mode 100644 index 8fdbc4b..0000000 --- a/GOLF.md +++ /dev/null @@ -1,282 +0,0 @@ -# Golf Card Game WebSocket API - -The golf game implements a multiplayer 4-card golf card game. Here's the WebSocket API specification for the backend server: - -## Messages FROM Client TO Server - -### 1. Create Game (sent to create a new game room) -```json -{ - "type": "createGame" -} -``` -Note: Server will assign player ID and generate a display name - -### 2. Join Game (sent to join an existing game room) -```json -{ - "type": "joinGame", - "gameId": "ABC123" -} -``` -Note: Server will assign player ID and generate a display name - -### 3. Start Game (sent by any player to start the game) -```json -{ - "type": "startGame" -} -``` -Note: Game requires at least 2 players to start - -### 4. Peek Card (sent during initial phase to peek at own cards) -```json -{ - "type": "peekCard", - "cardIndex": 0 -} -``` -Note: Each player can peek at exactly 2 of their 4 cards at the start - -### 5. Draw Card (sent on player's turn to draw from deck) -```json -{ - "type": "drawCard" -} -``` - -### 6. Take From Discard (sent on player's turn to take the top discard) -```json -{ - "type": "takeFromDiscard" -} -``` - -### 7. Swap Card (sent after drawing to swap with one of player's cards) -```json -{ - "type": "swapCard", - "cardIndex": 3 -} -``` - -### 8. Discard Drawn (sent to discard the drawn card without swapping) -```json -{ - "type": "discardDrawn" -} -``` - -### 9. Knock (sent to signal last round) -```json -{ - "type": "knock" -} -``` -Note: Only available when it's the player's turn and they haven't drawn yet - -### 10. Hide Cards (sent to hide cards after peek countdown) -```json -{ - "type": "hideCards" -} -``` -Note: Sent by the client after a client-side countdown when all players have peeked - -## Messages FROM Server TO Client - -### 1. Game Joined (sent after successfully creating/joining a game) -```json -{ - "type": "gameJoined", - "playerId": "player_abc123", - "gameState": { - "id": "ABC123", - "players": [ - { - "id": "player_abc123", - "name": "Alice", - "cards": [null, null, null, null], - "score": 0, - "revealedCards": [], - "isReady": false, - "hasPeeked": false - } - ], - "currentPlayerIndex": 0, - "drawPile": 52, - "discardPile": [], - "gamePhase": "waiting", - "knockedPlayerId": null, - "drawnCard": null, - "allPlayersPeeked": false - } -} -``` - -### 2. Game State Update (broadcast to all players on any state change) -```json -{ - "type": "gameState", - "gameState": { - "id": "ABC123", - "players": [ - { - "id": "player_abc123", - "name": "Alice", - "cards": [ - {"rank": "7", "suit": "♠"}, - {"rank": "K", "suit": "♥"}, - null, - null - ], - "score": 17, - "revealedCards": [0, 1], - "isReady": true - }, - { - "id": "player_def456", - "name": "Bob", - "cards": [null, null, null, null], - "score": 0, - "revealedCards": [2, 3], - "isReady": true - } - ], - "currentPlayerIndex": 0, - "drawPile": 41, - "discardPile": [{"rank": "3", "suit": "♦"}], - "gamePhase": "playing", - "knockedPlayerId": null, - "drawnCard": null, - "allPlayersPeeked": false - } -} -``` - -### 3. Error (sent when an invalid action is attempted) -```json -{ - "type": "error", - "message": "Not your turn" -} -``` - -### 4. Game Started (broadcast when game begins) -```json -{ - "type": "gameStarted" -} -``` - -### 5. Turn Changed (broadcast when turn changes) -```json -{ - "type": "turnChanged", - "playerName": "Bob" -} -``` - -### 6. Player Knocked (broadcast when a player knocks) -```json -{ - "type": "playerKnocked", - "playerName": "Alice" -} -``` - -### 7. Game Ended (broadcast when game ends) -```json -{ - "type": "gameEnded", - "winner": "Bob", - "finalScores": [ - {"playerName": "Alice", "score": 15}, - {"playerName": "Bob", "score": 8} - ] -} -``` - -## Implementation Notes - -### Game Rules -- **Players**: 2-4 players -- **Cards**: Each player has 4 cards arranged in a 2x2 grid -- **Initial Phase**: Each player must peek at exactly 2 of their cards -- **Objective**: Achieve the lowest score possible -- **Card Values**: - - Aces = 1 point - - 2-10 = Face value - - Jacks, Queens, Kings = 10 points each -- **Turn Actions**: - 1. Draw from deck OR take top discard card - 2. Either swap with one of your cards OR discard the drawn card -- **Knocking**: A player can knock at the start of their turn to trigger the final round -- **Game End**: After a knock, each other player gets one final turn - -### Client Behavior -- **Connection**: WebSocket connection to `ws://localhost:8080` -- **Room Codes**: 6-character alphanumeric codes (uppercase) -- **Player Names**: Max 20 characters -- **Card Grid**: 2x2 layout (indices 0-3) -- **Turn Timer**: Optional - server can implement turn timeouts -- **Auto-disconnect**: Server should handle cleanup on WebSocket close - -### Server Responsibilities -1. **Generate unique 6-character room codes** for new games -2. **Assign unique player IDs** when players join -3. **Generate display names** for players (e.g., "Player 1", "Player 2") -4. **Initialize game state** with shuffled deck and dealt cards -5. **Validate all player actions** (correct turn, valid moves, etc.) -6. **Calculate scores** based on revealed cards -7. **Broadcast state updates** to all players in the game -8. **Handle the knock mechanic** and final round -9. **Determine winner** and final scores -10. **Clean up disconnected players** and abandoned games - -### Game State Management -- **Waiting Phase**: Players join, minimum 2 to start -- **Playing Phase**: Main game loop with turns -- **Knocked Phase**: After a knock, final round for other players -- **Ended Phase**: Game complete, showing final scores - -### Card Management -- **Deck**: Standard 52-card deck -- **Dealing**: 4 cards per player, remaining cards form draw pile -- **Discard Pile**: Starts with one card face-up -- **Hidden Cards**: Server tracks which cards each player has peeked at -- **Drawn Card**: Temporarily held card before swap/discard decision - -### Error Handling -- Validate room codes exist -- Prevent duplicate player names in same game -- Ensure proper turn order -- Validate card indices (0-3) -- Prevent peeking at more than 2 cards -- Prevent actions when not player's turn -- Handle mid-game disconnections - -## Connection Flow - -1. Client establishes WebSocket connection to server -2. Client sends "createGame" or "joinGame" message -3. Server validates and responds with "gameJoined" message -4. Server broadcasts updated "gameState" to all players -5. Players ready up and someone starts the game -6. Initial peeking phase - each player peeks at 2 cards -7. When all players have peeked (allPlayersPeeked=true), client shows 3-second countdown -8. After countdown, client sends "hideCards" message to server -9. Normal gameplay proceeds with turns -10. Game ends when someone knocks and final round completes - -## Architecture - -The golf game uses: -- React components for UI rendering -- WebSocket for real-time multiplayer communication -- TypeScript interfaces for type safety -- CSS modules for component styling -- Turn-based game state management -- Card visibility tracking per player - -The game implements classic 4-card golf rules where players try to achieve the lowest score by swapping cards strategically while having limited information about their hand. \ No newline at end of file diff --git a/README.md b/README.md index 25c653d..0659281 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,13 @@ The nav's **Elsewhere** menu links to apps hosted off muchq.com (see the list). Those are external links, not routes — their code lives in their own repos, not here. -### Golf v1/v2 +### Golf -Golf speaks two wires. The default v1 protocol is documented in [GOLF.md](GOLF.md); the v2 -event-stream hub is a per-browser beta — opt in with `?golf=v2` (sticky via localStorage), -back out with `?golf=v1`, or default a build in with `VITE_GOLF_V2_DEFAULT=true`. Room chat -is v2-only, and the UI reveals it only once the server actually delivers chat on the wire. +Golf speaks the golf_hub event-stream wire (`/games/v2/*` on api.muchq.com; the model and +the protocol are documented with the service in MoonBase, `domains/games/apis/golf_hub`). +`VITE_GOLF_WEBSOCKET_URL` overrides the play socket at build time; the session mint is +derived from it. The UI reveals room chat only once the server actually delivers chat on +the wire. ## 🏗️ Project Structure @@ -84,6 +85,5 @@ Routes are declared in `src/App.tsx`. ## 📄 Documentation -- [GOLF.md](GOLF.md) — Golf multiplayer WebSocket API (v1) - [THOUGHTS.md](THOUGHTS.md) — Thoughts multiplayer WebSocket API - [WORKING_AGREEMENT.md](WORKING_AGREEMENT.md) — How work gets picked up, built, reviewed, and shipped diff --git a/WORKING_AGREEMENT.md b/WORKING_AGREEMENT.md index fb11aca..07cf407 100644 --- a/WORKING_AGREEMENT.md +++ b/WORKING_AGREEMENT.md @@ -5,8 +5,8 @@ down so a future session starts where the last one left off instead of rediscovering the same conventions. This is process, not architecture. Architecture lives in -[README.md](README.md) and the wire-contract docs at the repo root -([GOLF.md](GOLF.md), [THOUGHTS.md](THOUGHTS.md)). +[README.md](README.md) and the wire-contract docs: [THOUGHTS.md](THOUGHTS.md) +at the repo root, and the golf_hub model in MoonBase for golf. Adapted from the MoonBase working agreement ([`MoonBase/docs/WORKING_AGREEMENT.md`](https://github.com/muchq/MoonBase/blob/main/docs/WORKING_AGREEMENT.md)), @@ -154,10 +154,10 @@ observable behavior worth keeping gets a test, at every level that fits: - **component** — the behavior through the real component or hook with Testing Library under jsdom (`src/test/setup.ts` carries the shared shims); - **the consumer's boundary** — this repo is itself a consumer: the golf and - thoughts backends live in MoonBase, and [GOLF.md](GOLF.md) / - [THOUGHTS.md](THOUGHTS.md) are the contracts. Prove wire behavior by - feeding raw JSON messages to the real adapter (as - `src/utils/__tests__/golfV2Adapter.test.ts` does), not through helpers + thoughts backends live in MoonBase, and their models (golf_hub's smithy + model, [THOUGHTS.md](THOUGHTS.md)) are the contracts. Prove wire + behavior by feeding raw JSON messages to the real adapter (as + `src/utils/__tests__/networkAdapter.test.ts` does), not through helpers that mirror the adapter's own assumptions back at it. An untested observable behavior is not a guarantee; it is a coincidence that diff --git a/package.json b/package.json index f15c81d..d4902f1 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:ui": "vitest --ui", "typecheck": "tsc --noEmit", "test:run": "vitest run", - "local-server": "cross-env VITE_THOUGHTS_WEBSOCKET_URL=ws://localhost:2015/games/v1/thoughts-ws VITE_GOLF_WEBSOCKET_URL=ws://localhost:2015/games/v1/golf-ws VITE_THOUGHTS_SIMULATED=false VITE_METRICS_API_URL=http://localhost:2015/metrics/v1 VITE_MITHRIL_API_URL=http://localhost:2015/mithril/v1/wordchain VITE_TRACY_API_URL=http://localhost:2015/portrait/v1/trace VITE_POSTERIZE_API_URL=http://localhost:2015/imagine/v1 VITE_R3DR_API_URL=http://localhost:2015/r3dr/v2 vite", + "local-server": "cross-env VITE_THOUGHTS_WEBSOCKET_URL=ws://localhost:2015/games/v1/thoughts-ws VITE_GOLF_WEBSOCKET_URL=ws://localhost:2015/games/v2/golf/play VITE_THOUGHTS_SIMULATED=false VITE_METRICS_API_URL=http://localhost:2015/metrics/v1 VITE_MITHRIL_API_URL=http://localhost:2015/mithril/v1/wordchain VITE_TRACY_API_URL=http://localhost:2015/portrait/v1/trace VITE_POSTERIZE_API_URL=http://localhost:2015/imagine/v1 VITE_R3DR_API_URL=http://localhost:2015/r3dr/v2 vite", "deploy": "wrangler deploy" }, "dependencies": { diff --git a/src/apps/golf/components/GolfGame.module.css b/src/apps/golf/components/GolfGame.module.css index db173e1..74d30c3 100644 --- a/src/apps/golf/components/GolfGame.module.css +++ b/src/apps/golf/components/GolfGame.module.css @@ -1471,23 +1471,6 @@ transform: translateY(-2px); } -.betaToggle { - margin-top: 1rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - color: rgba(255, 255, 255, 0.6); - font-size: 0.85rem; - font-family: 'Lexend', sans-serif; - cursor: pointer; - user-select: none; -} - -.betaToggle input { - accent-color: #4ade80; -} - .rulesModal { position: fixed; top: 0; diff --git a/src/apps/golf/components/GolfGame.tsx b/src/apps/golf/components/GolfGame.tsx index dca4430..acc86cf 100644 --- a/src/apps/golf/components/GolfGame.tsx +++ b/src/apps/golf/components/GolfGame.tsx @@ -5,7 +5,6 @@ import PermalinkDisplay from './PermalinkDisplay' import RoomChat from './RoomChat' import NewGameNotification from './NewGameNotification' import type { ParsedPermalinkParams } from '../../../utils/golfPermalinks' -import { isGolfV2Enabled, setGolfV2Enabled } from '../../../utils/golfV2' interface Card { rank: string @@ -30,19 +29,6 @@ const GolfGame = ({ onGameIdChange, onPlayerIdChange, onPlayerNameChange, onConn const [showRules, setShowRules] = useState(false) const [showScores, setShowScores] = useState(false) const [showLeaveConfirm, setShowLeaveConfirm] = useState(false) - // Read once at mount: the active adapter was chosen from the same flag, - // and flipping it reloads so the choice and the connection can't skew. - const [v2Beta] = useState(() => isGolfV2Enabled()) - - const toggleV2Beta = () => { - setGolfV2Enabled(!v2Beta) - // Reload without any golf query param: the param outranks the stored - // choice on load, so keeping it would immediately undo the toggle. - const url = new URL(window.location.href) - url.searchParams.delete('golf') - window.location.href = url.toString() - } - // Helper function to get display name (now just use the ID directly) const getDisplayName = (player: Player | null) => { // Since IDs are now whimsical names directly, just use the ID @@ -219,15 +205,6 @@ const GolfGame = ({ onGameIdChange, onPlayerIdChange, onPlayerNameChange, onConn > How to Play - - diff --git a/src/hooks/useGolfGame.ts b/src/hooks/useGolfGame.ts index e36cc83..fc82c7b 100644 --- a/src/hooks/useGolfGame.ts +++ b/src/hooks/useGolfGame.ts @@ -4,8 +4,6 @@ import type { GameState, Player, Room } from '@/types/golf' import type { ChatMessage } from '@/types/golfChat' import { mergeChatMessages } from '@/types/golfChat' import { GolfNetworkAdapter } from '@/utils/networkAdapter' -import { GolfV2NetworkAdapter } from '@/utils/golfV2Adapter' -import { isGolfV2Enabled } from '@/utils/golfV2' import type { GolfGameAdapter } from '@/types/golfAdapter' import type { ParsedPermalinkParams } from '@/utils/golfPermalinks' import { generateRoomPermalink, generateGamePermalink } from '@/utils/golfPermalinks' @@ -493,7 +491,7 @@ export const useGolfGame = ({ // Trim here so what the byte counter measured is what ships; the // server validates again and rejects what a stale client sends. const trimmed = text.trim() - if (!adapter?.sendChat || !trimmed) return + if (!adapter || !trimmed) return adapter.sendChat(trimmed) }, []) @@ -503,10 +501,7 @@ export const useGolfGame = ({ // Initialize network adapter and connect on mount useEffect(() => { - // Create network adapter with callbacks; ?golf=v2 opts this browser - // into the smithy hub (MoonBase#1187 phase 3), ?golf=v1 opts out. - const AdapterClass = isGolfV2Enabled() ? GolfV2NetworkAdapter : GolfNetworkAdapter - const adapter = new AdapterClass({ + const adapter = new GolfNetworkAdapter({ onReconnecting: () => { setIsReconnecting(true) // Safety: clear after 2s in case server has no state to restore @@ -752,9 +747,7 @@ export const useGolfGame = ({ networkAdapterRef.current = adapter - // Connect to server (the v2 adapter resolves its own endpoints) - const websocketUrl = import.meta.env.VITE_GOLF_WEBSOCKET_URL || 'wss://api.muchq.com/games/v1/golf-ws' - adapter.connect(websocketUrl) + adapter.connect() // Cleanup on unmount return () => { @@ -962,8 +955,8 @@ export const useGolfGame = ({ return } - // Compare path AND query: permalinks carry ?golf=v2 while the beta is - // active, and a pathname-only comparison would re-navigate every run. + // The permalink is the whole URL, query included: anything else in + // the address bar is replaced by the canonical link. // If we have both room and game state, ensure URL reflects game if (roomState && gameState && roomState.id && gameState.id) { const expectedUrl = generateGamePermalink(roomState.id, gameState.id) diff --git a/src/plugins/__tests__/golfNetworkPlugin.auth.test.ts b/src/plugins/__tests__/golfNetworkPlugin.auth.test.ts deleted file mode 100644 index d1d38d9..0000000 --- a/src/plugins/__tests__/golfNetworkPlugin.auth.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { GolfNetworkPlugin } from '../golfNetworkPlugin' -import type { NetworkContext } from '@/types/network' - -// Mock localStorage -const localStorageMock = (() => { - let store: Record = {} - - return { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => { - store[key] = value - }, - removeItem: (key: string) => { - delete store[key] - }, - clear: () => { - store = {} - } - } -})() - -Object.defineProperty(globalThis, 'localStorage', { - value: localStorageMock -}) - -describe('GolfNetworkPlugin - Authentication', () => { - let plugin: GolfNetworkPlugin - let mockContext: NetworkContext - let sentMessages: unknown[] - - beforeEach(() => { - // Clear localStorage - localStorageMock.clear() - - // Reset sent messages - sentMessages = [] - - // Create mock context - mockContext = { - send: vi.fn((msg) => { - sentMessages.push(msg) - }), - broadcast: vi.fn(), - getConnectionId: vi.fn(() => 'test-connection'), - getGameState: vi.fn(() => ({ - playerId: null, - gameState: null, - roomState: null, - gameContext: null, - isInLobby: true - })) as NetworkContext['getGameState'], - updateGameState: vi.fn(), - isConnected: vi.fn(() => true) - } - - // Create plugin with callbacks - plugin = new GolfNetworkPlugin({ - onRoomJoined: vi.fn(), - onGameJoined: vi.fn(), - onGameStateUpdate: vi.fn(), - onRoomStateUpdate: vi.fn(), - onNotification: vi.fn(), - onGameEnded: vi.fn(), - onNewGameStarted: vi.fn() - }) - }) - - describe('onConnect', () => { - it('should send authenticate message with empty token for new session', () => { - // Call onConnect - plugin.onConnect(mockContext) - - // Verify authenticate message was sent - expect(sentMessages).toHaveLength(1) - expect(sentMessages[0]).toMatchObject({ - type: 'authenticate', - sessionToken: '' - }) - }) - - it('should send authenticate message with stored token for reconnection', () => { - // Store a token in localStorage - const storedToken = 'test-session-token-12345' - localStorage.setItem('golf_session_token', storedToken) - - // Call onConnect - plugin.onConnect(mockContext) - - // Verify authenticate message was sent with stored token - expect(sentMessages).toHaveLength(1) - expect(sentMessages[0]).toMatchObject({ - type: 'authenticate', - sessionToken: storedToken - }) - }) - }) - - describe('handleAuthenticated', () => { - it('should store session token from authenticated message', () => { - const handlers = plugin.getMessageHandlers() - const authenticatedHandler = handlers['authenticated'] - - // Simulate authenticated message - const message = { - type: 'authenticated', - sessionToken: 'new-token-67890', - reconnected: false, - timestamp: Date.now() - } - - authenticatedHandler(message, mockContext) - - // Verify token was stored - const storedToken = localStorage.getItem('golf_session_token') - expect(storedToken).toBe('new-token-67890') - }) - - it('should handle reconnection flag', () => { - const handlers = plugin.getMessageHandlers() - const authenticatedHandler = handlers['authenticated'] - - // Simulate reconnected session - const message = { - type: 'authenticated', - sessionToken: 'existing-token-abc', - reconnected: true, - timestamp: Date.now() - } - - authenticatedHandler(message, mockContext) - - // Verify token was stored - const storedToken = localStorage.getItem('golf_session_token') - expect(storedToken).toBe('existing-token-abc') - }) - }) - - describe('error handling', () => { - it('should clear session token on authentication error', () => { - // Store a token first - localStorage.setItem('golf_session_token', 'invalid-token') - - const handlers = plugin.getMessageHandlers() - const errorHandler = handlers['error'] - - // Simulate authentication error - const errorMessage = { - type: 'error', - message: 'Invalid session token', - timestamp: Date.now() - } - - errorHandler(errorMessage, mockContext) - - // Verify token was cleared - const storedToken = localStorage.getItem('golf_session_token') - expect(storedToken).toBeNull() - }) - - it('should clear session token on unauthenticated error', () => { - // Store a token first - localStorage.setItem('golf_session_token', 'expired-token') - - const handlers = plugin.getMessageHandlers() - const errorHandler = handlers['error'] - - // Simulate unauthenticated error - const errorMessage = { - type: 'error', - message: 'Unauthenticated: please authenticate first', - timestamp: Date.now() - } - - errorHandler(errorMessage, mockContext) - - // Verify token was cleared - const storedToken = localStorage.getItem('golf_session_token') - expect(storedToken).toBeNull() - }) - - it('should not clear session token on non-auth errors', () => { - // Store a token first - localStorage.setItem('golf_session_token', 'valid-token') - - const handlers = plugin.getMessageHandlers() - const errorHandler = handlers['error'] - - // Simulate non-authentication error - const errorMessage = { - type: 'error', - message: 'Room not found', - timestamp: Date.now() - } - - errorHandler(errorMessage, mockContext) - - // Verify token was NOT cleared - const storedToken = localStorage.getItem('golf_session_token') - expect(storedToken).toBe('valid-token') - }) - }) - - describe('full authentication flow', () => { - it('should complete new session flow', () => { - // 1. Connect (no stored token) - plugin.onConnect(mockContext) - - // Verify authenticate was sent with empty token - expect(sentMessages).toHaveLength(1) - expect(sentMessages[0]).toMatchObject({ - type: 'authenticate', - sessionToken: '' - }) - - // 2. Receive authenticated response - const handlers = plugin.getMessageHandlers() - const authenticatedHandler = handlers['authenticated'] - - const authResponse = { - type: 'authenticated', - sessionToken: 'new-session-abc123', - reconnected: false, - timestamp: Date.now() - } - - authenticatedHandler(authResponse, mockContext) - - // Verify token was stored - expect(localStorage.getItem('golf_session_token')).toBe('new-session-abc123') - }) - - it('should complete reconnection flow', () => { - // 1. Store token from previous session - localStorage.setItem('golf_session_token', 'existing-session-xyz789') - - // 2. Reconnect - plugin.onConnect(mockContext) - - // Verify authenticate was sent with stored token - expect(sentMessages).toHaveLength(1) - expect(sentMessages[0]).toMatchObject({ - type: 'authenticate', - sessionToken: 'existing-session-xyz789' - }) - - // 3. Receive reconnected response - const handlers = plugin.getMessageHandlers() - const authenticatedHandler = handlers['authenticated'] - - const authResponse = { - type: 'authenticated', - sessionToken: 'existing-session-xyz789', - reconnected: true, - timestamp: Date.now() - } - - authenticatedHandler(authResponse, mockContext) - - // Verify token is still stored - expect(localStorage.getItem('golf_session_token')).toBe('existing-session-xyz789') - }) - }) - - describe('message validation', () => { - it('should accept authenticated message type', () => { - const message = { - type: 'authenticated', - sessionToken: 'test', - timestamp: Date.now() - } - - expect(plugin.validateMessage(message)).toBe(true) - }) - }) -}) diff --git a/src/plugins/__tests__/golfNetworkPlugin.gameEnded.test.ts b/src/plugins/__tests__/golfNetworkPlugin.gameEnded.test.ts deleted file mode 100644 index 9f710b8..0000000 --- a/src/plugins/__tests__/golfNetworkPlugin.gameEnded.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { Mock } from 'vitest' -import { GolfNetworkPlugin } from '../golfNetworkPlugin' -import type { NetworkContext } from '@/types/network' - -// gameEnded handling: winner is the display string ("alice & bob" on shared -// wins) and winners is the typed list (MoonBase#1187 phase 0). Legacy servers -// omit winners entirely. -describe('GolfNetworkPlugin - gameEnded', () => { - let plugin: GolfNetworkPlugin - let mockContext: NetworkContext - let onGameEnded: Mock< - (winner: string, finalScores: { playerName: string; score: number }[], winners?: string[]) => void - > - let onNotification: Mock<(message: string) => void> - - const finalScores = [ - { playerName: 'alice', score: 5 }, - { playerName: 'bob', score: 5 }, - { playerName: 'carol', score: 12 } - ] - - beforeEach(() => { - onGameEnded = vi.fn() - onNotification = vi.fn() - - mockContext = { - send: vi.fn(), - broadcast: vi.fn(), - getConnectionId: vi.fn(() => 'test-connection'), - getGameState: vi.fn(() => ({ - playerId: null, - gameState: null, - roomState: null, - gameContext: null, - isInLobby: true - })) as NetworkContext['getGameState'], - updateGameState: vi.fn(), - isConnected: vi.fn(() => true) - } - - plugin = new GolfNetworkPlugin({ onGameEnded, onNotification }) - }) - - const deliverGameEnded = (message: Record) => { - const handler = plugin.getMessageHandlers()['gameEnded'] - handler({ type: 'gameEnded', timestamp: Date.now(), ...message }, mockContext) - } - - it('passes the winners list through on a shared win', () => { - deliverGameEnded({ - winner: 'alice & bob', - winners: ['alice', 'bob'], - finalScores - }) - - expect(onGameEnded).toHaveBeenCalledWith('alice & bob', finalScores, ['alice', 'bob']) - expect(onNotification).toHaveBeenCalledWith('Game over! Winner: alice & bob') - }) - - it('passes a single-element winners list through on a solo win', () => { - deliverGameEnded({ - winner: 'alice', - winners: ['alice'], - finalScores - }) - - expect(onGameEnded).toHaveBeenCalledWith('alice', finalScores, ['alice']) - }) - - it('passes undefined winners for a legacy server that omits the field', () => { - deliverGameEnded({ - winner: 'alice', - finalScores - }) - - expect(onGameEnded).toHaveBeenCalledWith('alice', finalScores, undefined) - }) - - it('does not invoke the callback without finalScores', () => { - deliverGameEnded({ - winner: 'alice', - winners: ['alice'] - }) - - expect(onGameEnded).not.toHaveBeenCalled() - }) - - it('falls back to Unknown when the winner is missing', () => { - deliverGameEnded({ finalScores }) - - expect(onGameEnded).toHaveBeenCalledWith('Unknown', finalScores, undefined) - expect(onNotification).toHaveBeenCalledWith('Game over! Winner: Unknown') - }) -}) diff --git a/src/plugins/golfNetworkPlugin.ts b/src/plugins/golfNetworkPlugin.ts deleted file mode 100644 index 4c14550..0000000 --- a/src/plugins/golfNetworkPlugin.ts +++ /dev/null @@ -1,475 +0,0 @@ -/* eslint-disable no-console */ -import type { - BaseNetworkMessage, - GameNetworkPlugin, - MessageHandlerMap, - NetworkContext -} from '@/types/network' -import type { Player, GameState as GolfGameState, Room, FinalScore } from '@/types/golf' -import type { GolfAdapterCallbacks } from '@/types/golfAdapter' -import { - JOINED_ROOM, - JOINED_GAME, - NEW_GAME, - GAME_STARTED, - turnMessage, - knockedMessage, - gameOverMessage -} from '@/utils/golfNotifications' - -// Golf-specific message types -interface GolfMessage extends BaseNetworkMessage { - type: 'authenticate' | 'authenticated' | 'createRoom' | 'joinRoom' | 'createGame' | 'joinGame' | 'roomJoined' | 'roomStateUpdate' | 'gameState' | 'error' | - 'gameStarted' | 'turnChanged' | 'playerKnocked' | 'gameEnded' | 'newGameStarted' | - 'startGame' | 'peekCard' | 'drawCard' | 'takeFromDiscard' | - 'swapCard' | 'discardDrawn' | 'knock' | 'hideCards' | 'startNewGame' | 'leaveGame' | 'leaveRoom' - // Request fields - roomId?: string - gameId?: string - cardIndex?: number - sessionToken?: string - // Response fields - playerId?: string - gameState?: GolfGameState - roomState?: Room - message?: string - winner?: string - winners?: string[] - finalScores?: FinalScore[] - previousGameId?: string - reconnected?: boolean -} - -// Golf plugin state -interface GolfPluginState { - playerId: string | null - gameState: GolfGameState | null - roomState: Room | null - gameContext: { roomId: string; gameId: string } | null - isInLobby: boolean -} - -const SESSION_TOKEN_KEY = 'golf_session_token' - -export class GolfNetworkPlugin implements GameNetworkPlugin { - gameType = 'golf' - private onRoomJoined?: (playerId: string, roomState: Room) => void - private onGameJoined?: (playerId: string, gameState: GolfGameState) => void - private onGameStateUpdate?: (gameState: GolfGameState) => void - private onRoomStateUpdate?: (roomState: Room) => void - private onNotification?: (message: string) => void - private onGameEnded?: (winner: string, finalScores: FinalScore[], winners?: string[]) => void - private onNewGameStarted?: (gameId: string, previousGameId?: string) => void - private onReconnecting?: () => void - private onGameError?: (message: string) => void - - // onConnectionChange is unused here — the NetworkManager owns it. - constructor(callbacks?: GolfAdapterCallbacks) { - if (callbacks) { - this.onRoomJoined = callbacks.onRoomJoined - this.onGameJoined = callbacks.onGameJoined - this.onGameStateUpdate = callbacks.onGameStateUpdate - this.onRoomStateUpdate = callbacks.onRoomStateUpdate - this.onNotification = callbacks.onNotification - this.onGameEnded = callbacks.onGameEnded - this.onNewGameStarted = callbacks.onNewGameStarted - this.onReconnecting = callbacks.onReconnecting - this.onGameError = callbacks.onGameError - } - } - - getMessageHandlers(): MessageHandlerMap { - return { - 'authenticated': (msg, ctx) => this.handleAuthenticated(msg as GolfMessage, ctx), - 'roomJoined': (msg, ctx) => this.handleRoomJoined(msg as GolfMessage, ctx), - 'roomStateUpdate': (msg, ctx) => this.handleRoomStateUpdate(msg as GolfMessage, ctx), - 'gameJoined': (msg, ctx) => this.handleGameJoined(msg as GolfMessage, ctx), - 'gameState': (msg, ctx) => this.handleGameState(msg as GolfMessage, ctx), - 'error': (msg, ctx) => this.handleError(msg as GolfMessage, ctx), - 'gameStarted': (msg, ctx) => this.handleGameStarted(msg as GolfMessage, ctx), - 'turnChanged': (msg, ctx) => this.handleTurnChanged(msg as GolfMessage, ctx), - 'playerKnocked': (msg, ctx) => this.handlePlayerKnocked(msg as GolfMessage, ctx), - 'gameEnded': (msg, ctx) => this.handleGameEnded(msg as GolfMessage, ctx), - 'newGameStarted': (msg, ctx) => this.handleNewGameStarted(msg as GolfMessage, ctx) - } - } - - validateMessage(message: BaseNetworkMessage): boolean { - const validTypes = [ - 'authenticated', 'roomJoined', 'roomStateUpdate', 'gameJoined', 'gameState', 'error', - 'gameStarted', 'turnChanged', 'playerKnocked', 'gameEnded', 'newGameStarted' - ] - return validTypes.includes(message.type) - } - - getInitialState(): GolfPluginState { - return { - playerId: null, - gameState: null, - roomState: null, - gameContext: null, - isInLobby: true - } - } - - onConnect(context: NetworkContext): void { - console.log('🎮 Golf game connected') - - // Send authenticate message as first message - const storedToken = this.getStoredSessionToken() - context.send({ - type: 'authenticate', - sessionToken: storedToken || '', - timestamp: Date.now() - }) - console.log(`📤 Sent authenticate request ${storedToken ? 'with stored token' : 'for new session'}`) - } - - onDisconnect(_context: NetworkContext): void { - console.log('🎮 Golf game disconnected — keeping state for reconnection') - // Don't reset state on disconnect. The server will restore room/game - // state when we reconnect and re-authenticate with our session token. - } - - // Message handlers - private handleRoomJoined(message: GolfMessage, context: NetworkContext): void { - if (!message.playerId || !message.roomState) { - console.error('Room joined message missing required fields') - return - } - - context.updateGameState(s => ({ - ...s, - playerId: message.playerId!, - roomState: message.roomState!, - isInLobby: false - })) - - console.log(`🎉 Joined room ${message.roomState.id} as player ${message.playerId}`) - - if (this.onRoomJoined) { - this.onRoomJoined(message.playerId, message.roomState) - } - - this.notify(JOINED_ROOM, context) - } - - private handleRoomStateUpdate(message: GolfMessage, context: NetworkContext): void { - if (!message.roomState) { - console.error('Room state update message missing roomState field') - return - } - - context.updateGameState(s => ({ - ...s, - roomState: message.roomState! - })) - - if (this.onRoomStateUpdate) { - this.onRoomStateUpdate(message.roomState) - } - } - - private handleGameJoined(message: GolfMessage, context: NetworkContext): void { - if (!message.playerId || !message.gameState) { - console.error('Game joined message missing required fields') - return - } - - context.updateGameState(s => ({ - ...s, - playerId: message.playerId!, - gameState: message.gameState!, - isInLobby: false - })) - - console.log(`🎉 Joined game ${message.gameState.id} as player ${message.playerId}`) - - if (this.onGameJoined) { - this.onGameJoined(message.playerId, message.gameState) - } - - this.notify(JOINED_GAME, context) - } - - private handleGameState(message: GolfMessage, context: NetworkContext): void { - if (!message.gameState) { - console.error('Game state message missing gameState field') - return - } - - context.updateGameState(s => ({ - ...s, - gameState: message.gameState! - })) - - if (this.onGameStateUpdate) { - this.onGameStateUpdate(message.gameState) - } - } - - private handleError(message: GolfMessage, context: NetworkContext): void { - const errorMessage = message.message || 'Unknown error occurred' - console.error('🚫 Game error:', errorMessage) - - // Clear stored session token if error is related to authentication - if (errorMessage.toLowerCase().includes('session') || - errorMessage.toLowerCase().includes('token') || - errorMessage.toLowerCase().includes('authentication') || - errorMessage.toLowerCase().includes('unauthenticated')) { - console.log('🔐 Clearing invalid session token') - this.clearSessionToken() - } - - this.onGameError?.(errorMessage) - this.notify(errorMessage, context) - } - - private handleGameStarted(_message: GolfMessage, context: NetworkContext): void { - console.log('🎮 Game started!') - this.notify(GAME_STARTED, context) - } - - private handleTurnChanged(message: GolfMessage, context: NetworkContext): void { - const playerName = (message as GolfMessage & { playerName?: string }).playerName || 'Unknown' - console.log(`🔄 Turn changed to ${playerName}`) - this.notify(turnMessage(playerName), context) - } - - private handlePlayerKnocked(message: GolfMessage, context: NetworkContext): void { - const playerName = (message as GolfMessage & { playerName?: string }).playerName || 'Unknown' - console.log(`🔔 ${playerName} has knocked!`) - this.notify(knockedMessage(playerName), context) - } - - private handleGameEnded(message: GolfMessage, context: NetworkContext): void { - const winner = message.winner || 'Unknown' - console.log(`🏆 Game ended! Winner: ${winner}`) - this.notify(gameOverMessage(winner), context) - - // Log final scores if provided - if (message.finalScores) { - console.log('Final scores:', message.finalScores) - } - - // Call the onGameEnded callback if provided. winners is the typed - // list on shared wins; winner stays the display string ("a & b"). - if (this.onGameEnded && message.finalScores) { - this.onGameEnded(winner, message.finalScores, message.winners) - } - } - - private handleNewGameStarted(message: GolfMessage, context: NetworkContext): void { - const gameId = message.gameId - const previousGameId = message.previousGameId - - console.log(`🆕 New game started in room! Game ID: ${gameId}${previousGameId ? `, Previous: ${previousGameId}` : ''}`) - this.notify(NEW_GAME, context) - - if (this.onNewGameStarted && gameId) { - this.onNewGameStarted(gameId, previousGameId) - } - } - - private handleAuthenticated(message: GolfMessage, _context: NetworkContext): void { - if (!message.sessionToken) { - console.error('Authenticated message missing session token') - return - } - - // Store the session token - this.storeSessionToken(message.sessionToken) - - const isReconnect = message.reconnected || false - console.log(`🔐 Authenticated successfully ${isReconnect ? '(reconnected to existing session)' : '(new session)'}`) - - if (isReconnect) { - console.log('♻️ Session restored - you should be back in your previous room/game') - this.onReconnecting?.() - } - } - - // Helper method to send notifications - private notify(message: string, _context: NetworkContext): void { - if (this.onNotification) { - this.onNotification(message) - } - } - - // Public methods for the game to use - createRoom(context: NetworkContext): void { - context.send({ - type: 'createRoom', - timestamp: Date.now() - }) - console.log('📤 Sent create room request') - } - - createGame(roomId: string, context: NetworkContext): void { - context.send({ - type: 'createGame', - roomId: roomId, - timestamp: Date.now() - }) - console.log(`📤 Sent create game request for room ${roomId}`) - } - - joinGame(roomId: string, gameId: string, context: NetworkContext): void { - context.send({ - type: 'joinGame', - roomId: roomId, - gameId: gameId, - timestamp: Date.now() - }) - console.log(`📤 Sent join game request for room ${roomId}, game ${gameId}`) - } - - startGame(context: NetworkContext): void { - context.send({ - type: 'startGame', - timestamp: Date.now() - }) - console.log('📤 Sent start game request') - } - - peekCard(cardIndex: number, context: NetworkContext): void { - context.send({ - type: 'peekCard', - cardIndex: cardIndex, - timestamp: Date.now() - }) - console.log(`📤 Sent peek card request for index ${cardIndex}`) - } - - drawCard(context: NetworkContext): void { - context.send({ - type: 'drawCard', - timestamp: Date.now() - }) - console.log('📤 Sent draw card request') - } - - takeFromDiscard(context: NetworkContext): void { - context.send({ - type: 'takeFromDiscard', - timestamp: Date.now() - }) - console.log('📤 Sent take from discard request') - } - - swapCard(cardIndex: number, context: NetworkContext): void { - context.send({ - type: 'swapCard', - cardIndex: cardIndex, - timestamp: Date.now() - }) - console.log(`📤 Sent swap card request for index ${cardIndex}`) - } - - discardDrawn(context: NetworkContext): void { - context.send({ - type: 'discardDrawn', - timestamp: Date.now() - }) - console.log('📤 Sent discard drawn card request') - } - - knock(context: NetworkContext): void { - context.send({ - type: 'knock', - timestamp: Date.now() - }) - console.log('📤 Sent knock request') - } - - hideCards(context: NetworkContext): void { - context.send({ - type: 'hideCards', - timestamp: Date.now() - }) - console.log('📤 Sent hideCards request') - } - - joinRoom(roomId: string, context: NetworkContext): void { - context.send({ - type: 'joinRoom', - roomId: roomId, - timestamp: Date.now() - }) - console.log(`📤 Sent join room request for room ${roomId}`) - } - - startNewGame(context: NetworkContext): void { - context.send({ - type: 'startNewGame', - timestamp: Date.now() - }) - console.log('📤 Sent start new game request') - } - - leaveGame(context: NetworkContext): void { - context.send({ - type: 'leaveGame', - timestamp: Date.now() - }) - console.log('📤 Sent leave game request') - } - - leaveRoom(roomId: string, context: NetworkContext): void { - context.send({ - type: 'leaveRoom', - roomId: roomId, - timestamp: Date.now() - }) - console.log(`📤 Sent leave room request for room ${roomId}`) - } - - // Session token management - private storeSessionToken(token: string): void { - try { - localStorage.setItem(SESSION_TOKEN_KEY, token) - console.log('💾 Session token stored in localStorage') - } catch (error) { - console.error('Failed to store session token:', error) - } - } - - private getStoredSessionToken(): string | null { - try { - const token = localStorage.getItem(SESSION_TOKEN_KEY) - if (token) { - console.log('🔑 Retrieved stored session token') - } - return token - } catch (error) { - console.error('Failed to retrieve session token:', error) - return null - } - } - - private clearSessionToken(): void { - try { - localStorage.removeItem(SESSION_TOKEN_KEY) - console.log('🗑️ Session token cleared from localStorage') - } catch (error) { - console.error('Failed to clear session token:', error) - } - } - - // Helper to check if it's the player's turn - isMyTurn(context: NetworkContext): boolean { - const state = context.getGameState() - if (!state.gameState || !state.playerId) return false - - const currentPlayer = state.gameState.players[state.gameState.currentPlayerIndex] - return currentPlayer?.id === state.playerId - } - - // Get current player info - getCurrentPlayer(context: NetworkContext): Player | null { - const state = context.getGameState() - if (!state.gameState || !state.playerId) return null - - return state.gameState.players.find(p => p.id === state.playerId) || null - } -} \ No newline at end of file diff --git a/src/types/golfAdapter.ts b/src/types/golfAdapter.ts index 36ea8bd..01245f2 100644 --- a/src/types/golfAdapter.ts +++ b/src/types/golfAdapter.ts @@ -1,23 +1,18 @@ import type { GameState, Room, FinalScore, Player } from './golf' import type { ChatMessage } from './golfChat' -// The one adapter contract useGolfGame depends on. Both the v1 -// GolfNetworkAdapter and the v2 GolfV2NetworkAdapter implement this, so -// the "?golf=v2 swaps the wire, not the UI" premise is a compile-time -// fact rather than two surfaces trusted to stay identical. +// The one adapter contract useGolfGame depends on: the hook and the +// components see this surface, never the wire. // -// Room chat (MoonBase#1226) is a v2-only capability: the v1 wire never -// carried it, so sendChat and the chat callbacks are optional. The UI -// reveals chat only after the wire actually delivers it (the join -// replay or a live message) — an adapter declaring sendChat is not -// proof the connected server has chat. +// Room chat (MoonBase#1226): the UI reveals chat only after the wire +// actually delivers it (the join replay or a live message) — an adapter +// declaring sendChat is not proof the connected server has chat. export interface GolfAdapterCallbacks { onRoomJoined?: (playerId: string, roomState: Room) => void - // The server confirmed a leaveRoom. Optional and v2-only today: the - // v1 wire has no leave acknowledgement. The permalink flow chains on - // this to join a share link's room after leaving a resumed one - // (muchq.github.io#260) — an adapter without it simply cannot detour. + // The server confirmed a leaveRoom. The permalink flow chains on this + // to join a share link's room after leaving a resumed one + // (muchq.github.io#260). onRoomLeft?: (roomId: string) => void onGameJoined?: (playerId: string, gameState: GameState) => void onGameStateUpdate?: (gameState: GameState) => void @@ -37,7 +32,7 @@ export interface GolfAdapterCallbacks { } export interface GolfGameAdapter { - connect(url: string): void + connect(): void disconnect(): void readonly isConnected: boolean readonly playerId: string | null @@ -62,7 +57,6 @@ export interface GolfGameAdapter { isMyTurn(): boolean getCurrentPlayer(): Player | null - // Absent on adapters whose wire has no chat (v1). The server trims, - // validates, and authorizes; this only ships the text. - sendChat?(text: string): void + // The server trims, validates, and authorizes; this only ships the text. + sendChat(text: string): void } diff --git a/src/utils/__tests__/golfPermalinks.test.ts b/src/utils/__tests__/golfPermalinks.test.ts index ee32f9f..6df7891 100644 --- a/src/utils/__tests__/golfPermalinks.test.ts +++ b/src/utils/__tests__/golfPermalinks.test.ts @@ -108,17 +108,6 @@ describe('golfPermalinks', () => { it('should throw error for invalid room ID', () => { expect(() => generateRoomPermalink('room 123')).toThrow('Invalid room ID provided for permalink generation') }) - - it('carries the beta flag while v2 is enabled', () => { - localStorage.setItem('golf_v2_beta', '1') - expect(generateRoomPermalink('room123')).toBe('/golf/room/room123?golf=v2') - localStorage.clear() - }) - - it('omits the beta flag while v2 is disabled', () => { - localStorage.clear() - expect(generateRoomPermalink('room123')).toBe('/golf/room/room123') - }) }) describe('generateGamePermalink', () => { @@ -134,17 +123,6 @@ describe('golfPermalinks', () => { it('should throw error for invalid game ID', () => { expect(() => generateGamePermalink('room123', 'game 456')).toThrow('Invalid game ID provided for permalink generation') }) - - it('carries the beta flag while v2 is enabled', () => { - localStorage.setItem('golf_v2_beta', '1') - expect(generateGamePermalink('room123', 'game456')).toBe('/golf/room/room123/game/game456?golf=v2') - localStorage.clear() - }) - - it('omits the beta flag while v2 is disabled', () => { - localStorage.clear() - expect(generateGamePermalink('room123', 'game456')).toBe('/golf/room/room123/game/game456') - }) }) describe('extractIdsFromUrl', () => { diff --git a/src/utils/__tests__/golfV2.test.ts b/src/utils/__tests__/golfV2.test.ts deleted file mode 100644 index 95e7c54..0000000 --- a/src/utils/__tests__/golfV2.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { isGolfV2Enabled, setGolfV2Enabled, golfV2SessionUrl } from '../golfV2' - -describe('golf v2 beta switch', () => { - const setSearch = (search: string) => { - vi.stubGlobal('location', { ...window.location, search }) - } - - beforeEach(() => { - localStorage.clear() - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('defaults off', () => { - setSearch('') - expect(isGolfV2Enabled()).toBe(false) - }) - - it('?golf=v2 opts in and sticks', () => { - setSearch('?golf=v2') - expect(isGolfV2Enabled()).toBe(true) - - setSearch('') - expect(isGolfV2Enabled()).toBe(true) // persisted - }) - - it('?golf=v1 opts back out and sticks', () => { - localStorage.setItem('golf_v2_beta', '1') - setSearch('?golf=v1') - expect(isGolfV2Enabled()).toBe(false) - - setSearch('') - expect(isGolfV2Enabled()).toBe(false) - }) - - it('the toggle setter persists an explicit choice both ways', () => { - setSearch('') - setGolfV2Enabled(true) - expect(isGolfV2Enabled()).toBe(true) - - setGolfV2Enabled(false) - expect(isGolfV2Enabled()).toBe(false) - }) - - it('derives the session url from the play url', () => { - expect(golfV2SessionUrl()).toBe('https://api.muchq.com/games/v2/session') - }) -}) diff --git a/src/utils/__tests__/golfV2Adapter.test.ts b/src/utils/__tests__/networkAdapter.test.ts similarity index 94% rename from src/utils/__tests__/golfV2Adapter.test.ts rename to src/utils/__tests__/networkAdapter.test.ts index 78b2c8c..9587280 100644 --- a/src/utils/__tests__/golfV2Adapter.test.ts +++ b/src/utils/__tests__/networkAdapter.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { Mock } from 'vitest' -import { GolfV2NetworkAdapter } from '../golfV2Adapter' -import type { GolfAdapterCallbacks } from '../golfV2Adapter' +import { GolfNetworkAdapter, golfSessionUrl } from '../networkAdapter' +import type { GolfAdapterCallbacks } from '../networkAdapter' import type { GameState } from '@/types/golf' type MockedCallbacks = { [K in keyof Required]: Mock[K]> } -// The v2 adapter against a scripted wire: session mint, the smithy -// JSON-text envelopes, v2->v1 shape translation, and the local -// take-from-discard emulation (MoonBase#1187 phase 3). +// The adapter against a scripted wire: session mint, the smithy +// JSON-text envelopes, wire-to-UI shape translation, and the local +// take-from-discard emulation. class FakeWebSocket { static instances: FakeWebSocket[] = [] @@ -56,7 +56,11 @@ class FakeWebSocket { const flushAsync = () => new Promise(resolve => setTimeout(resolve, 0)) -describe('GolfV2NetworkAdapter', () => { +describe('GolfNetworkAdapter', () => { + it('derives the session url from the play url', () => { + expect(golfSessionUrl()).toBe('https://api.muchq.com/games/v2/session') + }) + let fetchMock: ReturnType let callbacks: MockedCallbacks @@ -110,8 +114,8 @@ describe('GolfV2NetworkAdapter', () => { vi.unstubAllGlobals() }) - const connect = async (): Promise<[GolfV2NetworkAdapter, FakeWebSocket]> => { - const adapter = new GolfV2NetworkAdapter(callbacks) + const connect = async (): Promise<[GolfNetworkAdapter, FakeWebSocket]> => { + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await flushAsync() const ws = FakeWebSocket.instances[0] @@ -201,7 +205,7 @@ describe('GolfV2NetworkAdapter', () => { expect(ws.lastSent()).toEqual({ event: 'chat', payload: { text: 'hello room' } }) }) - it('translates game views into the v1 shape', async () => { + it('translates game views into the UI shape', async () => { const [adapter, ws] = await connect() ws.receive('golf', { update: { gameState: { view: sampleView } } }) @@ -298,7 +302,7 @@ describe('GolfV2NetworkAdapter', () => { }) it('signals a resumed session', async () => { - const adapter = new GolfV2NetworkAdapter(callbacks) + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await flushAsync() const ws = FakeWebSocket.instances[0] @@ -309,7 +313,7 @@ describe('GolfV2NetworkAdapter', () => { }) it('drops the resume token when refused before admission', async () => { - const adapter = new GolfV2NetworkAdapter(callbacks) + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await flushAsync() expect(localStorage.getItem('golf_v2_resume_token')).toBe('rt-456') @@ -418,7 +422,7 @@ describe('GolfV2NetworkAdapter', () => { it('re-dials with a fresh mint after an abrupt close', async () => { vi.useFakeTimers() try { - const adapter = new GolfV2NetworkAdapter(callbacks) + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await vi.advanceTimersByTimeAsync(0) const first = FakeWebSocket.instances[0] @@ -443,7 +447,7 @@ describe('GolfV2NetworkAdapter', () => { vi.useFakeTimers() try { fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }) - const adapter = new GolfV2NetworkAdapter(callbacks) + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await vi.advanceTimersByTimeAsync(0) expect(FakeWebSocket.instances).toHaveLength(0) @@ -460,7 +464,7 @@ describe('GolfV2NetworkAdapter', () => { it('disconnect stops the reconnect loop', async () => { vi.useFakeTimers() try { - const adapter = new GolfV2NetworkAdapter(callbacks) + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await vi.advanceTimersByTimeAsync(0) FakeWebSocket.instances[0].open() @@ -478,7 +482,7 @@ describe('GolfV2NetworkAdapter', () => { vi.useFakeTimers() try { fetchMock.mockRejectedValue(new Error('down')) - const adapter = new GolfV2NetworkAdapter(callbacks) + const adapter = new GolfNetworkAdapter(callbacks) adapter.connect() await vi.advanceTimersByTimeAsync(0) for (let i = 0; i < 10; i++) { diff --git a/src/utils/golfNotifications.ts b/src/utils/golfNotifications.ts index af95b50..aa1e2ee 100644 --- a/src/utils/golfNotifications.ts +++ b/src/utils/golfNotifications.ts @@ -1,5 +1,5 @@ -// User-facing golf notification strings, shared by the v1 plugin and the -// v2 adapter so the same game event reads identically on either wire. +// User-facing golf notification strings, kept apart from the adapter so +// the wording is testable without a wire. export const JOINED_ROOM = 'Joined room successfully!' export const JOINED_GAME = 'Joined game successfully!' diff --git a/src/utils/golfPermalinks.ts b/src/utils/golfPermalinks.ts index ab225c6..8e20e52 100644 --- a/src/utils/golfPermalinks.ts +++ b/src/utils/golfPermalinks.ts @@ -2,8 +2,6 @@ * Golf permalink utilities for URL parameter parsing and validation */ -import { isGolfV2Enabled } from './golfV2' - export interface GolfRouteParams extends Record { roomId?: string gameId?: string @@ -27,17 +25,6 @@ export function isValidId(id: string | undefined): boolean { return /^[a-zA-Z0-9-]+$/.test(id) } -/** - * While the v2 beta is active, minted permalinks carry the opt-in flag so - * a shared link lands its recipient on the same backend as its sender — - * a v2 room does not exist on the v1 hub. Deliberately applied on every - * generated path, not just explicit share links: navigation pushes these - * into the address bar, and a hand-copied URL must carry the flag too. - */ -function betaSuffix(): string { - return isGolfV2Enabled() ? '?golf=v2' : '' -} - /** * Parses URL parameters for golf permalinks * Returns parsed and validated room and game IDs @@ -98,7 +85,7 @@ export function generateRoomPermalink(roomId: string): string { if (!isValidId(roomId)) { throw new Error('Invalid room ID provided for permalink generation') } - return `/golf/room/${roomId}${betaSuffix()}` + return `/golf/room/${roomId}` } /** @@ -111,7 +98,7 @@ export function generateGamePermalink(roomId: string, gameId: string): string { if (!isValidId(gameId)) { throw new Error('Invalid game ID provided for permalink generation') } - return `/golf/room/${roomId}/game/${gameId}${betaSuffix()}` + return `/golf/room/${roomId}/game/${gameId}` } /** diff --git a/src/utils/golfV2.ts b/src/utils/golfV2.ts deleted file mode 100644 index c292b14..0000000 --- a/src/utils/golfV2.ts +++ /dev/null @@ -1,45 +0,0 @@ -// The golf v2 beta switch and endpoints (MoonBase#1187 phase 3). -// -// v2 is the smithy event-stream hub. Opt in per browser with ?golf=v2 -// (sticky via localStorage), opt back out with ?golf=v1. A build can -// default everyone in with VITE_GOLF_V2_DEFAULT=true. - -import { safeLocalStorage } from './safeLocalStorage' - -const V2_FLAG_KEY = 'golf_v2_beta' - -// Deliberately impure: seeing ?golf=v2 (or v1) persists the choice, so -// the query param is a one-visit opt-in that sticks. -export function isGolfV2Enabled(): boolean { - const param = new URLSearchParams(window.location.search).get('golf') - if (param === 'v2') { - safeLocalStorage.set(V2_FLAG_KEY, '1') - return true - } - if (param === 'v1') { - safeLocalStorage.set(V2_FLAG_KEY, '0') - return false - } - const stored = safeLocalStorage.get(V2_FLAG_KEY) - if (stored === '1') return true - if (stored === '0') return false - return import.meta.env.VITE_GOLF_V2_DEFAULT === 'true' -} - -// The lobby toggle's setter: an explicit choice, same persistence the -// query param uses. Callers reload — the adapter is chosen at mount. -export function setGolfV2Enabled(enabled: boolean): void { - safeLocalStorage.set(V2_FLAG_KEY, enabled ? '1' : '0') -} - -export function golfV2PlayUrl(): string { - return import.meta.env.VITE_GOLF_V2_WEBSOCKET_URL || 'wss://api.muchq.com/games/v2/golf/play' -} - -// The session mint lives beside the play socket: same origin, http(s) -// for ws(s), /games/v2/session. -export function golfV2SessionUrl(): string { - const play = new URL(golfV2PlayUrl()) - const protocol = play.protocol === 'wss:' ? 'https:' : 'http:' - return `${protocol}//${play.host}/games/v2/session` -} diff --git a/src/utils/golfV2Adapter.ts b/src/utils/golfV2Adapter.ts deleted file mode 100644 index 215d4a8..0000000 --- a/src/utils/golfV2Adapter.ts +++ /dev/null @@ -1,606 +0,0 @@ -/* eslint-disable no-console */ -// The golf v2 client (MoonBase#1187 phase 3): the smithy event-stream -// wire, presented through the same GolfGameAdapter surface as the v1 -// GolfNetworkAdapter so useGolfGame and the components don't change. -// -// Wire shape (smithy-cpp ADR-0018 JSON-text mode): -// - POST /games/v2/session {resumeToken?} -> {playerId, ticket, resumeToken} -// - new WebSocket(playUrl + "?ticket=...", "smithy.eventstream.v1+json") -// - frames both ways: {"event": "", "payload": {...}} -// - game moves ride the golf envelope: {"event":"golf","payload":{"move":{"drawCard":{}}}} -// - game updates arrive as {"event":"golf","payload":{"update":{"gameState":{...}}}} -// -// Two deliberate translations keep the v1 UI untouched: -// - v2 GameView -> v1 GameState (ids to indexes, slots to nullable -// cards). Bridge-lifetime shims, gone with v1 in phase 5: the -// discard renders as a one-card pile (v2 sends top + count; only the -// top is drawn), room game summaries carry fake seat players to -// satisfy the v1 Player[] shape, and gameHistory is empty — the v2 -// wire doesn't carry it, so the "Recent Games" panel is blank on v2. -// - v1's take-then-place discard flow is emulated locally: the discard -// top is public, so "taking" it reveals nothing; the real v2 -// takeFromDiscard{cardIndex} is sent when the player places it. - -import type { Card, GameState as GolfGameState, Player, Room, FinalScore } from '@/types/golf' -import type { GolfAdapterCallbacks, GolfGameAdapter } from '@/types/golfAdapter' -import type { ChatMessage } from '@/types/golfChat' -import { - JOINED_ROOM, - JOINED_GAME, - NEW_GAME, - GAME_STARTED, - turnMessage, - knockedMessage, - gameOverMessage -} from './golfNotifications' -import { safeLocalStorage } from './safeLocalStorage' -import { golfV2PlayUrl, golfV2SessionUrl } from './golfV2' - -export type { GolfAdapterCallbacks } from '@/types/golfAdapter' - -const RESUME_TOKEN_KEY = 'golf_v2_resume_token' -const SUBPROTOCOL = 'smithy.eventstream.v1+json' -const MINT_TIMEOUT_MS = 10_000 -// 2s x 10 covers the hub's 5-minute reconnect grace, matching the v1 -// adapter's tuning (networkAdapter.ts connect config). -const RECONNECT_DELAY_MS = 2000 -const MAX_RECONNECT_ATTEMPTS = 10 - -// --- v2 wire shapes (mirrors model/games.smithy + model/golf_hub.smithy) --- - -type GamePhase = GolfGameState['gamePhase'] - -interface V2CardSlot { - card?: Card -} - -interface V2GamePlayer { - playerId: string - cards: V2CardSlot[] - revealedIndexes: number[] - hasPeeked: boolean - score?: number -} - -interface V2GameView { - gameId: string - phase: GamePhase - players: V2GamePlayer[] - currentPlayerId?: string - drawPileCount: number - discardCount: number - discardTop?: Card - drawnCard?: Card - knockedPlayerId?: string - allPlayersPeeked: boolean -} - -interface V2PlayerInfo { - playerId: string - connected: boolean - gamesPlayed: number - gamesWon: number - totalScore: number -} - -interface V2GameSummary { - gameId: string - status: GamePhase - playerCount: number -} - -interface V2RoomState { - roomId: string - players: V2PlayerInfo[] - games: V2GameSummary[] -} - -interface V2SessionReady { - playerId: string - resumed: boolean - roomId?: string -} - -// The golf update union's JSON encoding: exactly one member present. -interface V2GolfUpdate { - gameJoined?: { view: V2GameView } - gameState?: { view: V2GameView } - gameCreated?: { gameId: string; createdBy?: string } - gameStarted?: Record - turnChanged?: { playerId: string } - playerKnocked?: { playerId: string } - gameEnded?: { - winner: string - winners: string[] - finalScores: { playerId: string; score: number }[] - } - gameLeft?: { gameId: string } -} - -// Inbound frames as a discriminated union: the switch narrows each case, -// and a new event is a compile-time hole instead of a silent cast. -type V2Frame = - | { event: 'sessionReady'; payload: V2SessionReady } - | { event: 'roomState'; payload: V2RoomState } - | { event: 'roomLeft'; payload: { roomId: string } } - | { event: 'roomChat'; payload: ChatMessage } - | { event: 'roomChatHistory'; payload: { messages: ChatMessage[] } } - | { event: 'commandRejected'; payload: { reason: string } } - | { event: 'golf'; payload: { update: V2GolfUpdate } } - -type V2CommandEvent = 'createRoom' | 'joinRoom' | 'leaveRoom' | 'getRoomState' | 'chat' | 'golf' -type V2MoveName = - | 'createGame' - | 'joinGame' - | 'startGame' - | 'leaveGame' - | 'peekCard' - | 'drawCard' - | 'takeFromDiscard' - | 'swapCard' - | 'discardDrawn' - | 'knock' - | 'hideCards' - -// --- v2 -> v1 shape translation --- - -// v2 has no separate display names: the whimsical playerId is the label, -// so it fills both id and name. -function stubPlayer(id: string): Player { - return { - id, - name: id, - cards: [], - score: 0, - revealedCards: [], - isReady: false, - hasPeeked: false, - clientId: '', - totalScore: 0, - gamesPlayed: 0, - gamesWon: 0, - isConnected: true, - joinedAt: '' - } -} - -// Fake seats carrying only a count — the v1 Room shape wants Player[] -// where v2 sends playerCount, and the lobby reads only the length. -function stubSeats(count: number): Player[] { - return Array.from({ length: count }, (_, i) => stubPlayer(`seat-${i}`)) -} - -function mapGamePlayer(player: V2GamePlayer): Player { - return { - ...stubPlayer(player.playerId), - cards: player.cards.map(slot => slot.card ?? null), - score: player.score ?? 0, - revealedCards: player.revealedIndexes, - hasPeeked: player.hasPeeked - } -} - -function mapGameView(view: V2GameView): GolfGameState { - // The UI renders only the discard top, and holding the top during the - // take emulation must not "reveal" a card beneath that this client was - // never sent — so the pile maps to at most one card. - const discardPile: Card[] = view.discardTop == null ? [] : [view.discardTop] - const currentSeat = view.currentPlayerId - ? view.players.findIndex(p => p.playerId === view.currentPlayerId) - : -1 - return { - id: view.gameId, - players: view.players.map(mapGamePlayer), - // An absent or unknown current player (e.g. an ended game) shows as - // seat 0; nothing turn-gated renders in those phases. - currentPlayerIndex: currentSeat >= 0 ? currentSeat : 0, - drawPile: view.drawPileCount, - discardPile, - gamePhase: view.phase, - knockedPlayerId: view.knockedPlayerId ?? null, - drawnCard: view.drawnCard ?? null, - allPlayersPeeked: view.allPlayersPeeked - } -} - -function mapRoomState(room: V2RoomState): Room { - const games: Record = {} - for (const summary of room.games) { - games[summary.gameId] = { - id: summary.gameId, - players: stubSeats(summary.playerCount), - currentPlayerIndex: 0, - drawPile: 0, - discardPile: [], - gamePhase: summary.status, - knockedPlayerId: null, - drawnCard: null, - allPlayersPeeked: false - } - } - return { - id: room.roomId, - players: room.players.map(info => ({ - ...stubPlayer(info.playerId), - isConnected: info.connected, - totalScore: info.totalScore, - gamesPlayed: info.gamesPlayed, - gamesWon: info.gamesWon - })), - games, - gameHistory: [], - createdAt: '', - lastActivity: '' - } -} - -// --- the adapter --- - -export class GolfV2NetworkAdapter implements GolfGameAdapter { - private callbacks: GolfAdapterCallbacks - private ws: WebSocket | null = null - private _playerId: string | null = null - private _gameState: GolfGameState | null = null - private _roomState: Room | null = null - private closed = false - private reconnectAttempts = 0 - private reconnectTimeout: number | null = null - private sawSessionReady = false - - // The room the UI has been told it joined; the next different - // roomState fires onRoomJoined, same-room ones fire onRoomStateUpdate. - // Sound because the hub only sends roomState to members: a new roomId - // always means this player joined (or resumed into) that room. - private announcedRoomId: string | null = null - // v1's take-then-place discard flow, emulated locally. - private pendingDiscardTake = false - // The last authoritative server view, kept so the discard-take - // emulation can be reverted without inventing state. - private lastServerView: V2GameView | null = null - - constructor(callbacks?: GolfAdapterCallbacks) { - this.callbacks = callbacks ?? {} - } - - // The url parameter is part of the shared adapter surface; v2 resolves - // its own endpoints from golfV2.ts, so it is accepted and ignored. - connect(_url?: string): void { - this.closed = false - void this.dial() - } - - disconnect(): void { - this.closed = true - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = null - } - this.ws?.close() - this.ws = null - } - - get isConnected(): boolean { - return this.ws?.readyState === WebSocket.OPEN - } - - get playerId(): string | null { - return this._playerId - } - - get gameState(): GolfGameState | null { - return this._gameState - } - - get roomState(): Room | null { - return this._roomState - } - - // --- session + socket lifecycle --- - - private async dial(): Promise { - try { - const stored = safeLocalStorage.get(RESUME_TOKEN_KEY) - const response = await fetch(golfV2SessionUrl(), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(stored ? { resumeToken: stored } : {}), - // A hung server must count as a failed attempt, not stall the - // bounded reconnect loop forever. - signal: AbortSignal.timeout(MINT_TIMEOUT_MS) - }) - if (!response.ok) { - throw new Error(`session mint failed: ${response.status}`) - } - const session = (await response.json()) as { - playerId: string - ticket: string - resumeToken: string - } - this._playerId = session.playerId - safeLocalStorage.set(RESUME_TOKEN_KEY, session.resumeToken) - - const url = `${golfV2PlayUrl()}?ticket=${encodeURIComponent(session.ticket)}` - const ws = new WebSocket(url, SUBPROTOCOL) - this.ws = ws - this.sawSessionReady = false - - ws.onopen = () => { - console.log('🎮 golf v2 connected') - this.reconnectAttempts = 0 - this.callbacks.onConnectionChange?.(true) - } - ws.onmessage = event => { - try { - // The one boundary cast: frames are validated by shape of use, - // not a runtime schema — unknown events fall through the switch. - this.handleFrame(JSON.parse(event.data as string) as V2Frame) - } catch (error) { - console.error('golf v2: bad frame', error) - } - } - ws.onclose = () => { - this.callbacks.onConnectionChange?.(false) - if (!this.sawSessionReady) { - // Refused before admission (spent ticket, seat conflict, bad - // resume token): drop the token so the next dial mints fresh. - safeLocalStorage.remove(RESUME_TOKEN_KEY) - } - this.scheduleReconnect() - } - ws.onerror = () => { - // onclose follows; nothing useful in the browser error event. - } - } catch (error) { - console.error('golf v2: dial failed', error) - this.scheduleReconnect() - } - } - - private scheduleReconnect(): void { - if (this.closed) return - if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - this.callbacks.onGameError?.('Lost connection to the golf server') - return - } - this.reconnectAttempts++ - console.log(`🔄 golf v2 reconnecting (${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`) - this.reconnectTimeout = window.setTimeout(() => void this.dial(), RECONNECT_DELAY_MS) - } - - private sendEvent(event: V2CommandEvent, payload: unknown): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - console.warn('golf v2: cannot send, not connected') - return - } - this.ws.send(JSON.stringify({ event, payload })) - } - - private sendMove(move: V2MoveName, payload: unknown = {}): void { - this.sendEvent('golf', { move: { [move]: payload } }) - } - - // --- inbound events --- - - private handleFrame(frame: V2Frame): void { - switch (frame.event) { - case 'sessionReady': - this.handleSessionReady(frame.payload) - return - case 'roomState': - this.handleRoomState(frame.payload) - return - case 'roomLeft': - this.announcedRoomId = null - this._roomState = null - this.callbacks.onRoomLeft?.(frame.payload.roomId) - return - case 'roomChat': - // Typed chat state, not a toast (MoonBase#1226): the UI owns - // presentation, and a transient notification would drop the - // message the server just committed durably. - this.callbacks.onChatMessage?.(frame.payload) - return - case 'roomChatHistory': - this.callbacks.onChatHistory?.(frame.payload.messages) - return - case 'commandRejected': - this.callbacks.onGameError?.(frame.payload.reason) - this.callbacks.onNotification?.(frame.payload.reason) - return - case 'golf': - this.handleUpdate(frame.payload.update) - return - default: - console.warn('golf v2: unknown event', frame) - } - } - - private handleSessionReady(ready: V2SessionReady): void { - this._playerId = ready.playerId - this.sawSessionReady = true - if (ready.resumed && ready.roomId) { - console.log('♻️ golf v2 session resumed') - this.callbacks.onReconnecting?.() - } - } - - private handleRoomState(room: V2RoomState): void { - const mapped = mapRoomState(room) - this._roomState = mapped - if (this.announcedRoomId !== room.roomId) { - this.announcedRoomId = room.roomId - this.callbacks.onRoomJoined?.(this._playerId ?? '', mapped) - this.callbacks.onNotification?.(JOINED_ROOM) - } else { - this.callbacks.onRoomStateUpdate?.(mapped) - } - } - - private handleUpdate(update: V2GolfUpdate): void { - if (update.gameJoined) { - const state = this.acceptView(update.gameJoined.view) - this.callbacks.onGameJoined?.(this._playerId ?? '', state) - this.callbacks.onNotification?.(JOINED_GAME) - return - } - if (update.gameState) { - const state = this.acceptView(update.gameState.view) - this.callbacks.onGameStateUpdate?.(state) - return - } - if (update.gameCreated) { - if (update.gameCreated.createdBy === this._playerId) { - // Our own create: the gameJoined we also receive carries the - // state, and announcing it would make the hook double-join. - return - } - this.callbacks.onNotification?.(NEW_GAME) - this.callbacks.onNewGameStarted?.(update.gameCreated.gameId) - return - } - if (update.gameStarted) { - this.callbacks.onNotification?.(GAME_STARTED) - return - } - if (update.turnChanged) { - this.callbacks.onNotification?.(turnMessage(update.turnChanged.playerId)) - return - } - if (update.playerKnocked) { - this.callbacks.onNotification?.(knockedMessage(update.playerKnocked.playerId)) - return - } - if (update.gameEnded) { - const ended = update.gameEnded - const finalScores: FinalScore[] = ended.finalScores.map(score => ({ - playerName: score.playerId, - score: score.score - })) - this.callbacks.onNotification?.(gameOverMessage(ended.winner)) - this.callbacks.onGameEnded?.(ended.winner, finalScores, ended.winners) - return - } - if (update.gameLeft) { - this._gameState = null - this.lastServerView = null - this.pendingDiscardTake = false - return - } - console.warn('golf v2: unknown update', update) - } - - private acceptView(view: V2GameView): GolfGameState { - // Server state is authoritative: any update ends the local - // take-from-discard emulation. - this.lastServerView = view - this.pendingDiscardTake = false - this._gameState = mapGameView(view) - return this._gameState - } - - // --- actions (the shared GolfGameAdapter surface) --- - - createRoom(): void { - this.sendEvent('createRoom', {}) - } - - joinRoom(roomId: string): void { - this.sendEvent('joinRoom', { roomId }) - } - - leaveRoom(_roomId: string): void { - this.announcedRoomId = null - this.sendEvent('leaveRoom', {}) - } - - createGame(_roomId: string): void { - this.requestCreateGame() - } - - startNewGame(): void { - // v2 folded startNewGame into createGame; the creator is auto-seated. - this.requestCreateGame() - } - - private requestCreateGame(): void { - this.sendMove('createGame') - } - - joinGame(_roomId: string, gameId: string): void { - this.sendMove('joinGame', { gameId }) - } - - startGame(): void { - this.sendMove('startGame') - } - - leaveGame(): void { - this.sendMove('leaveGame') - } - - peekCard(cardIndex: number): void { - this.sendMove('peekCard', { cardIndex }) - } - - drawCard(): void { - this.sendMove('drawCard') - } - - takeFromDiscard(): void { - // The discard top is public: "picking it up" reveals nothing, so v1's - // hold step is purely local. The real move goes out on placement. - const view = this.lastServerView - if (!view || view.discardTop == null || this._gameState == null) return - this.pendingDiscardTake = true - this._gameState = { - ...this._gameState, - drawnCard: view.discardTop, - discardPile: [] - } - this.callbacks.onGameStateUpdate?.(this._gameState) - } - - swapCard(cardIndex: number): void { - if (this.pendingDiscardTake) { - this.pendingDiscardTake = false - this.sendMove('takeFromDiscard', { cardIndex }) - return - } - this.sendMove('swapCard', { cardIndex }) - } - - discardDrawn(): void { - if (this.pendingDiscardTake) { - // Putting the discard top back: nothing ever left the server. - this.pendingDiscardTake = false - if (this.lastServerView) { - this._gameState = mapGameView(this.lastServerView) - this.callbacks.onGameStateUpdate?.(this._gameState) - } - return - } - this.sendMove('discardDrawn') - } - - knock(): void { - this.sendMove('knock') - } - - hideCards(): void { - this.sendMove('hideCards') - } - - sendChat(text: string): void { - this.sendEvent('chat', { text }) - } - - isMyTurn(): boolean { - if (!this._gameState || !this._playerId) return false - return this._gameState.players[this._gameState.currentPlayerIndex]?.id === this._playerId - } - - getCurrentPlayer(): Player | null { - if (!this._gameState || !this._playerId) return null - return this._gameState.players.find(p => p.id === this._playerId) ?? null - } -} diff --git a/src/utils/networkAdapter.ts b/src/utils/networkAdapter.ts index 3368e88..0694e01 100644 --- a/src/utils/networkAdapter.ts +++ b/src/utils/networkAdapter.ts @@ -1,169 +1,292 @@ /* eslint-disable no-console */ -import { NetworkManager } from './networkManager' -import { ThoughtsNetworkPlugin } from '@/plugins/thoughtsNetworkPlugin' -import { GolfNetworkPlugin } from '@/plugins/golfNetworkPlugin' -import type { GameState } from '@/types/game' -import type { GameState as GolfGameState, Room } from '@/types/golf' +// The golf client: the golf_hub smithy event-stream wire, presented +// through the GolfGameAdapter surface useGolfGame consumes. +// +// Wire shape (smithy-cpp ADR-0018 JSON-text mode): +// - POST /games/v2/session {resumeToken?} -> {playerId, ticket, resumeToken} +// - new WebSocket(playUrl + "?ticket=...", "smithy.eventstream.v1+json") +// - frames both ways: {"event": "", "payload": {...}} +// - game moves ride the golf envelope: {"event":"golf","payload":{"move":{"drawCard":{}}}} +// - game updates arrive as {"event":"golf","payload":{"update":{"gameState":{...}}}} +// +// Two translations sit between the wire and the UI's model: +// - GameView -> GameState (ids to indexes, slots to nullable cards). +// The discard renders as a one-card pile (the hub sends top + count; +// only the top is drawn), room game summaries carry placeholder seat +// players where the hub sends a playerCount, and gameHistory is +// empty — the wire doesn't carry it, so "Recent Games" is blank. +// - The UI's take-then-place discard flow is emulated locally: the +// discard top is public, so "taking" it reveals nothing; the hub's +// takeFromDiscard{cardIndex} is sent when the player places it. + +import type { Card, GameState as GolfGameState, Player, Room, FinalScore } from '@/types/golf' import type { GolfAdapterCallbacks, GolfGameAdapter } from '@/types/golfAdapter' -import { ConnectionState, BaseNetworkMessage } from '@/types/network' - -// Factory function to create a network manager with pre-registered plugins -export function createGameNetworkManager() { - const manager = new NetworkManager({ - onConnectionStateChange: (state: ConnectionState) => { - console.log(`Connection state changed: ${state}`) - }, - onError: (error: Error) => { - console.error('Network error:', error) - } - }) +import type { ChatMessage } from '@/types/golfChat' +import { + JOINED_ROOM, + JOINED_GAME, + NEW_GAME, + GAME_STARTED, + turnMessage, + knockedMessage, + gameOverMessage +} from './golfNotifications' +import { safeLocalStorage } from './safeLocalStorage' + +export type { GolfAdapterCallbacks } from '@/types/golfAdapter' + +export function golfPlayUrl(): string { + return import.meta.env.VITE_GOLF_WEBSOCKET_URL || 'wss://api.muchq.com/games/v2/golf/play' +} + +// The session mint lives beside the play socket: same origin, http(s) +// for ws(s), /games/v2/session. +export function golfSessionUrl(): string { + const play = new URL(golfPlayUrl()) + const protocol = play.protocol === 'wss:' ? 'https:' : 'http:' + return `${protocol}//${play.host}/games/v2/session` +} + +const RESUME_TOKEN_KEY = 'golf_v2_resume_token' +const SUBPROTOCOL = 'smithy.eventstream.v1+json' +const MINT_TIMEOUT_MS = 10_000 +// 2s x 10 sits well inside the hub's 5-minute reconnect grace. +const RECONNECT_DELAY_MS = 2000 +const MAX_RECONNECT_ATTEMPTS = 10 + +// --- wire shapes (mirrors model/games.smithy + model/golf_hub.smithy) --- + +type GamePhase = GolfGameState['gamePhase'] + +interface V2CardSlot { + card?: Card +} + +interface V2GamePlayer { + playerId: string + cards: V2CardSlot[] + revealedIndexes: number[] + hasPeeked: boolean + score?: number +} + +interface V2GameView { + gameId: string + phase: GamePhase + players: V2GamePlayer[] + currentPlayerId?: string + drawPileCount: number + discardCount: number + discardTop?: Card + drawnCard?: Card + knockedPlayerId?: string + allPlayersPeeked: boolean +} - return manager +interface V2PlayerInfo { + playerId: string + connected: boolean + gamesPlayed: number + gamesWon: number + totalScore: number } -// Adapter for Thoughts game to maintain backward compatibility -export class ThoughtsNetworkAdapter { - private manager: NetworkManager - private plugin: ThoughtsNetworkPlugin - - constructor( - gameState: GameState, - onPlayerIdReceived?: (playerId: string) => void - ) { - this.manager = new NetworkManager() - this.plugin = new ThoughtsNetworkPlugin(gameState, onPlayerIdReceived) - - // Register the plugin - this.manager.registerPlugin({ - plugin: this.plugin - }) - } - - connect(url: string): void { - this.manager.connect({ - url, - gameType: 'thoughts', - reconnect: true, - reconnectDelay: 5000, - maxReconnectAttempts: 5 - }) +interface V2GameSummary { + gameId: string + status: GamePhase + playerCount: number +} + +interface V2RoomState { + roomId: string + players: V2PlayerInfo[] + games: V2GameSummary[] +} + +interface V2SessionReady { + playerId: string + resumed: boolean + roomId?: string +} + +// The golf update union's JSON encoding: exactly one member present. +interface V2GolfUpdate { + gameJoined?: { view: V2GameView } + gameState?: { view: V2GameView } + gameCreated?: { gameId: string; createdBy?: string } + gameStarted?: Record + turnChanged?: { playerId: string } + playerKnocked?: { playerId: string } + gameEnded?: { + winner: string + winners: string[] + finalScores: { playerId: string; score: number }[] + } + gameLeft?: { gameId: string } +} + +// Inbound frames as a discriminated union: the switch narrows each case, +// and a new event is a compile-time hole instead of a silent cast. +type V2Frame = + | { event: 'sessionReady'; payload: V2SessionReady } + | { event: 'roomState'; payload: V2RoomState } + | { event: 'roomLeft'; payload: { roomId: string } } + | { event: 'roomChat'; payload: ChatMessage } + | { event: 'roomChatHistory'; payload: { messages: ChatMessage[] } } + | { event: 'commandRejected'; payload: { reason: string } } + | { event: 'golf'; payload: { update: V2GolfUpdate } } + +type V2CommandEvent = 'createRoom' | 'joinRoom' | 'leaveRoom' | 'getRoomState' | 'chat' | 'golf' +type V2MoveName = + | 'createGame' + | 'joinGame' + | 'startGame' + | 'leaveGame' + | 'peekCard' + | 'drawCard' + | 'takeFromDiscard' + | 'swapCard' + | 'discardDrawn' + | 'knock' + | 'hideCards' + +// --- wire -> UI model translation --- + +// The hub has no separate display names: the whimsical playerId is the +// label, so it fills both id and name. +function stubPlayer(id: string): Player { + return { + id, + name: id, + cards: [], + score: 0, + revealedCards: [], + isReady: false, + hasPeeked: false, + clientId: '', + totalScore: 0, + gamesPlayed: 0, + gamesWon: 0, + isConnected: true, + joinedAt: '' } +} - disconnect(): void { - this.manager.disconnect() +// Placeholder seats carrying only a count — the Room shape wants Player[] +// where the hub sends playerCount, and the lobby reads only the length. +function stubSeats(count: number): Player[] { + return Array.from({ length: count }, (_, i) => stubPlayer(`seat-${i}`)) +} + +function mapGamePlayer(player: V2GamePlayer): Player { + return { + ...stubPlayer(player.playerId), + cards: player.cards.map(slot => slot.card ?? null), + score: player.score ?? 0, + revealedCards: player.revealedIndexes, + hasPeeked: player.hasPeeked } +} - get isConnected(): boolean { - return this.manager.isConnected() - } - - sendPositionUpdate(position: [number, number, number]): void { - // Create a temporary context to call plugin method - const context = { - send: (msg: BaseNetworkMessage) => this.manager.send(msg), - broadcast: (msg: BaseNetworkMessage) => this.manager.broadcast(msg), - getConnectionId: () => '', - getGameState: () => this.manager['gameState'] as T, - updateGameState: (updater: (state: T) => T) => { - this.manager['gameState'] = updater(this.manager['gameState'] as T) - }, - isConnected: () => this.manager.isConnected() - } - - this.plugin.sendPositionUpdate(position, context) - } - - sendShapeUpdate(shape: number): void { - const context = { - send: (msg: BaseNetworkMessage) => this.manager.send(msg), - broadcast: (msg: BaseNetworkMessage) => this.manager.broadcast(msg), - getConnectionId: () => '', - getGameState: () => this.manager['gameState'] as T, - updateGameState: (updater: (state: T) => T) => { - this.manager['gameState'] = updater(this.manager['gameState'] as T) - }, - isConnected: () => this.manager.isConnected() - } - - this.plugin.sendShapeUpdate(shape, context) +function mapGameView(view: V2GameView): GolfGameState { + // The UI renders only the discard top, and holding the top during the + // take emulation must not "reveal" a card beneath that this client was + // never sent — so the pile maps to at most one card. + const discardPile: Card[] = view.discardTop == null ? [] : [view.discardTop] + const currentSeat = view.currentPlayerId + ? view.players.findIndex(p => p.playerId === view.currentPlayerId) + : -1 + return { + id: view.gameId, + players: view.players.map(mapGamePlayer), + // An absent or unknown current player (e.g. an ended game) shows as + // seat 0; nothing turn-gated renders in those phases. + currentPlayerIndex: currentSeat >= 0 ? currentSeat : 0, + drawPile: view.drawPileCount, + discardPile, + gamePhase: view.phase, + knockedPlayerId: view.knockedPlayerId ?? null, + drawnCard: view.drawnCard ?? null, + allPlayersPeeked: view.allPlayersPeeked } +} - // For fake server simulation - isSimulated = false - setFakeServer(_fakeServer: unknown): void { - // Not implemented in new system yet - console.warn('Fake server not implemented in plugin system yet') +function mapRoomState(room: V2RoomState): Room { + const games: Record = {} + for (const summary of room.games) { + games[summary.gameId] = { + id: summary.gameId, + players: stubSeats(summary.playerCount), + currentPlayerIndex: 0, + drawPile: 0, + discardPile: [], + gamePhase: summary.status, + knockedPlayerId: null, + drawnCard: null, + allPlayersPeeked: false + } + } + return { + id: room.roomId, + players: room.players.map(info => ({ + ...stubPlayer(info.playerId), + isConnected: info.connected, + totalScore: info.totalScore, + gamesPlayed: info.gamesPlayed, + gamesWon: info.gamesWon + })), + games, + gameHistory: [], + createdAt: '', + lastActivity: '' } } -// Adapter for Golf game +// --- the adapter --- + export class GolfNetworkAdapter implements GolfGameAdapter { - private manager: NetworkManager - private plugin: GolfNetworkPlugin + private callbacks: GolfAdapterCallbacks + private ws: WebSocket | null = null private _playerId: string | null = null private _gameState: GolfGameState | null = null - private _roomState: Room | null = null + private closed = false + private reconnectAttempts = 0 + private reconnectTimeout: number | null = null + private sawSessionReady = false + + // The room the UI has been told it joined; the next different + // roomState fires onRoomJoined, same-room ones fire onRoomStateUpdate. + // Sound because the hub only sends roomState to members: a new roomId + // always means this player joined (or resumed into) that room. + private announcedRoomId: string | null = null + // The UI's take-then-place discard flow, emulated locally. + private pendingDiscardTake = false + // The last authoritative server view, kept so the discard-take + // emulation can be reverted without inventing state. + private lastServerView: V2GameView | null = null constructor(callbacks?: GolfAdapterCallbacks) { - // Create manager with connection state callback - this.manager = new NetworkManager({ - onConnectionStateChange: (state: ConnectionState) => { - if (callbacks?.onConnectionChange) { - callbacks.onConnectionChange(state === ConnectionState.CONNECTED) - } - } - }) - - // Create plugin with game callbacks - this.plugin = new GolfNetworkPlugin({ - onRoomJoined: (playerId, roomState) => { - this._playerId = playerId - this._roomState = roomState - callbacks?.onRoomJoined?.(playerId, roomState) - }, - onGameJoined: (playerId, gameState) => { - this._playerId = playerId - this._gameState = gameState - callbacks?.onGameJoined?.(playerId, gameState) - }, - onGameStateUpdate: (gameState) => { - this._gameState = gameState - callbacks?.onGameStateUpdate?.(gameState) - }, - onRoomStateUpdate: (roomState) => { - this._roomState = roomState - callbacks?.onRoomStateUpdate?.(roomState) - }, - onNotification: callbacks?.onNotification, - onGameEnded: callbacks?.onGameEnded, - onNewGameStarted: callbacks?.onNewGameStarted, - onReconnecting: callbacks?.onReconnecting, - onGameError: callbacks?.onGameError - }) - - // Register the plugin - this.manager.registerPlugin({ - plugin: this.plugin - }) - } - - connect(url: string): void { - this.manager.connect({ - url, - gameType: 'golf', - reconnect: true, // Enable reconnection with JWT session restore - reconnectDelay: 2000, // Try to reconnect after 2 seconds - maxReconnectAttempts: 10 // Try up to 10 times (covers the 5-minute grace period) - }) + this.callbacks = callbacks ?? {} + } + + connect(): void { + this.closed = false + void this.dial() } disconnect(): void { - this.manager.disconnect() + this.closed = true + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + this.ws?.close() + this.ws = null } get isConnected(): boolean { - return this.manager.isConnected() + return this.ws?.readyState === WebSocket.OPEN } get playerId(): string | null { @@ -178,86 +301,312 @@ export class GolfNetworkAdapter implements GolfGameAdapter { return this._roomState } - // Create context helper - private getContext() { - return { - send: (msg: BaseNetworkMessage) => this.manager.send(msg), - broadcast: (msg: BaseNetworkMessage) => this.manager.broadcast(msg), - getConnectionId: () => '', - getGameState: () => this.manager['gameState'] as T, - updateGameState: (updater: (state: T) => T) => { - this.manager['gameState'] = updater(this.manager['gameState'] as T) - }, - isConnected: () => this.manager.isConnected() + // --- session + socket lifecycle --- + + private async dial(): Promise { + try { + const stored = safeLocalStorage.get(RESUME_TOKEN_KEY) + const response = await fetch(golfSessionUrl(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(stored ? { resumeToken: stored } : {}), + // A hung server must count as a failed attempt, not stall the + // bounded reconnect loop forever. + signal: AbortSignal.timeout(MINT_TIMEOUT_MS) + }) + if (!response.ok) { + throw new Error(`session mint failed: ${response.status}`) + } + const session = (await response.json()) as { + playerId: string + ticket: string + resumeToken: string + } + this._playerId = session.playerId + safeLocalStorage.set(RESUME_TOKEN_KEY, session.resumeToken) + + const url = `${golfPlayUrl()}?ticket=${encodeURIComponent(session.ticket)}` + const ws = new WebSocket(url, SUBPROTOCOL) + this.ws = ws + this.sawSessionReady = false + + ws.onopen = () => { + console.log('🎮 golf v2 connected') + this.reconnectAttempts = 0 + this.callbacks.onConnectionChange?.(true) + } + ws.onmessage = event => { + try { + // The one boundary cast: frames are validated by shape of use, + // not a runtime schema — unknown events fall through the switch. + this.handleFrame(JSON.parse(event.data as string) as V2Frame) + } catch (error) { + console.error('golf v2: bad frame', error) + } + } + ws.onclose = () => { + this.callbacks.onConnectionChange?.(false) + if (!this.sawSessionReady) { + // Refused before admission (spent ticket, seat conflict, bad + // resume token): drop the token so the next dial mints fresh. + safeLocalStorage.remove(RESUME_TOKEN_KEY) + } + this.scheduleReconnect() + } + ws.onerror = () => { + // onclose follows; nothing useful in the browser error event. + } + } catch (error) { + console.error('golf v2: dial failed', error) + this.scheduleReconnect() } } - // Delegate game actions to plugin - createRoom(): void { - this.plugin.createRoom(this.getContext()) + private scheduleReconnect(): void { + if (this.closed) return + if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + this.callbacks.onGameError?.('Lost connection to the golf server') + return + } + this.reconnectAttempts++ + console.log(`🔄 golf v2 reconnecting (${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`) + this.reconnectTimeout = window.setTimeout(() => void this.dial(), RECONNECT_DELAY_MS) } - createGame(roomId: string): void { - this.plugin.createGame(roomId, this.getContext()) + private sendEvent(event: V2CommandEvent, payload: unknown): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.warn('golf v2: cannot send, not connected') + return + } + this.ws.send(JSON.stringify({ event, payload })) + } + + private sendMove(move: V2MoveName, payload: unknown = {}): void { + this.sendEvent('golf', { move: { [move]: payload } }) + } + + // --- inbound events --- + + private handleFrame(frame: V2Frame): void { + switch (frame.event) { + case 'sessionReady': + this.handleSessionReady(frame.payload) + return + case 'roomState': + this.handleRoomState(frame.payload) + return + case 'roomLeft': + this.announcedRoomId = null + this._roomState = null + this.callbacks.onRoomLeft?.(frame.payload.roomId) + return + case 'roomChat': + // Typed chat state, not a toast (MoonBase#1226): the UI owns + // presentation, and a transient notification would drop the + // message the server just committed durably. + this.callbacks.onChatMessage?.(frame.payload) + return + case 'roomChatHistory': + this.callbacks.onChatHistory?.(frame.payload.messages) + return + case 'commandRejected': + this.callbacks.onGameError?.(frame.payload.reason) + this.callbacks.onNotification?.(frame.payload.reason) + return + case 'golf': + this.handleUpdate(frame.payload.update) + return + default: + console.warn('golf v2: unknown event', frame) + } + } + + private handleSessionReady(ready: V2SessionReady): void { + this._playerId = ready.playerId + this.sawSessionReady = true + if (ready.resumed && ready.roomId) { + console.log('♻️ golf v2 session resumed') + this.callbacks.onReconnecting?.() + } + } + + private handleRoomState(room: V2RoomState): void { + const mapped = mapRoomState(room) + this._roomState = mapped + if (this.announcedRoomId !== room.roomId) { + this.announcedRoomId = room.roomId + this.callbacks.onRoomJoined?.(this._playerId ?? '', mapped) + this.callbacks.onNotification?.(JOINED_ROOM) + } else { + this.callbacks.onRoomStateUpdate?.(mapped) + } + } + + private handleUpdate(update: V2GolfUpdate): void { + if (update.gameJoined) { + const state = this.acceptView(update.gameJoined.view) + this.callbacks.onGameJoined?.(this._playerId ?? '', state) + this.callbacks.onNotification?.(JOINED_GAME) + return + } + if (update.gameState) { + const state = this.acceptView(update.gameState.view) + this.callbacks.onGameStateUpdate?.(state) + return + } + if (update.gameCreated) { + if (update.gameCreated.createdBy === this._playerId) { + // Our own create: the gameJoined we also receive carries the + // state, and announcing it would make the hook double-join. + return + } + this.callbacks.onNotification?.(NEW_GAME) + this.callbacks.onNewGameStarted?.(update.gameCreated.gameId) + return + } + if (update.gameStarted) { + this.callbacks.onNotification?.(GAME_STARTED) + return + } + if (update.turnChanged) { + this.callbacks.onNotification?.(turnMessage(update.turnChanged.playerId)) + return + } + if (update.playerKnocked) { + this.callbacks.onNotification?.(knockedMessage(update.playerKnocked.playerId)) + return + } + if (update.gameEnded) { + const ended = update.gameEnded + const finalScores: FinalScore[] = ended.finalScores.map(score => ({ + playerName: score.playerId, + score: score.score + })) + this.callbacks.onNotification?.(gameOverMessage(ended.winner)) + this.callbacks.onGameEnded?.(ended.winner, finalScores, ended.winners) + return + } + if (update.gameLeft) { + this._gameState = null + this.lastServerView = null + this.pendingDiscardTake = false + return + } + console.warn('golf v2: unknown update', update) + } + + private acceptView(view: V2GameView): GolfGameState { + // Server state is authoritative: any update ends the local + // take-from-discard emulation. + this.lastServerView = view + this.pendingDiscardTake = false + this._gameState = mapGameView(view) + return this._gameState + } + + // --- actions (the shared GolfGameAdapter surface) --- + + createRoom(): void { + this.sendEvent('createRoom', {}) } joinRoom(roomId: string): void { - this.plugin.joinRoom(roomId, this.getContext()) + this.sendEvent('joinRoom', { roomId }) + } + + leaveRoom(_roomId: string): void { + this.announcedRoomId = null + this.sendEvent('leaveRoom', {}) + } + + createGame(_roomId: string): void { + this.requestCreateGame() + } + + startNewGame(): void { + // v2 folded startNewGame into createGame; the creator is auto-seated. + this.requestCreateGame() + } + + private requestCreateGame(): void { + this.sendMove('createGame') } - joinGame(roomId: string, gameId: string): void { - this.plugin.joinGame(roomId, gameId, this.getContext()) + joinGame(_roomId: string, gameId: string): void { + this.sendMove('joinGame', { gameId }) } startGame(): void { - this.plugin.startGame(this.getContext()) + this.sendMove('startGame') + } + + leaveGame(): void { + this.sendMove('leaveGame') } peekCard(cardIndex: number): void { - this.plugin.peekCard(cardIndex, this.getContext()) + this.sendMove('peekCard', { cardIndex }) } drawCard(): void { - this.plugin.drawCard(this.getContext()) + this.sendMove('drawCard') } takeFromDiscard(): void { - this.plugin.takeFromDiscard(this.getContext()) + // The discard top is public: "picking it up" reveals nothing, so the + // hold step is purely local. The real move goes out on placement. + const view = this.lastServerView + if (!view || view.discardTop == null || this._gameState == null) return + this.pendingDiscardTake = true + this._gameState = { + ...this._gameState, + drawnCard: view.discardTop, + discardPile: [] + } + this.callbacks.onGameStateUpdate?.(this._gameState) } swapCard(cardIndex: number): void { - this.plugin.swapCard(cardIndex, this.getContext()) + if (this.pendingDiscardTake) { + this.pendingDiscardTake = false + this.sendMove('takeFromDiscard', { cardIndex }) + return + } + this.sendMove('swapCard', { cardIndex }) } discardDrawn(): void { - this.plugin.discardDrawn(this.getContext()) + if (this.pendingDiscardTake) { + // Putting the discard top back: nothing ever left the server. + this.pendingDiscardTake = false + if (this.lastServerView) { + this._gameState = mapGameView(this.lastServerView) + this.callbacks.onGameStateUpdate?.(this._gameState) + } + return + } + this.sendMove('discardDrawn') } knock(): void { - this.plugin.knock(this.getContext()) + this.sendMove('knock') } hideCards(): void { - this.plugin.hideCards(this.getContext()) - } - - startNewGame(): void { - this.plugin.startNewGame(this.getContext()) - } - - leaveGame(): void { - this.plugin.leaveGame(this.getContext()) + this.sendMove('hideCards') } - leaveRoom(roomId: string): void { - this.plugin.leaveRoom(roomId, this.getContext()) + sendChat(text: string): void { + this.sendEvent('chat', { text }) } isMyTurn(): boolean { - return this.plugin.isMyTurn(this.getContext()) + if (!this._gameState || !this._playerId) return false + return this._gameState.players[this._gameState.currentPlayerIndex]?.id === this._playerId } - getCurrentPlayer() { - return this.plugin.getCurrentPlayer(this.getContext()) + getCurrentPlayer(): Player | null { + if (!this._gameState || !this._playerId) return null + return this._gameState.players.find(p => p.id === this._playerId) ?? null } -} \ No newline at end of file +} From 814159cd609f37d10fac2095aa72c0dab6b2559d Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Wed, 2 Sep 2026 21:08:26 -0400 Subject: [PATCH 2/3] golf: docs and comments name one wire --- WORKING_AGREEMENT.md | 9 +++++---- src/hooks/__tests__/useGolfGame.chat.test.tsx | 2 +- src/hooks/__tests__/useGolfGame.permalinkDetour.test.tsx | 2 +- src/hooks/useGolfGame.ts | 2 +- src/types/golfChat.ts | 2 +- src/utils/networkAdapter.ts | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/WORKING_AGREEMENT.md b/WORKING_AGREEMENT.md index 07cf407..7aa8c51 100644 --- a/WORKING_AGREEMENT.md +++ b/WORKING_AGREEMENT.md @@ -217,12 +217,13 @@ preview. Chromium and Playwright are available in the sandbox. ## Docs - Update docs in the same PR as the code: [README.md](README.md) and the - wire-contract docs ([GOLF.md](GOLF.md), [THOUGHTS.md](THOUGHTS.md)). + wire-contract docs ([THOUGHTS.md](THOUGHTS.md) here, the golf_hub smithy + model in MoonBase). - **When behavior changes, fix the doc that describes it in the same commit.** A doc left contradicting the code is a defect in its own right. -- GOLF.md and THOUGHTS.md describe contracts the MoonBase server speaks; when - either side of the wire changes, the doc moves with it — or says explicitly - which side is ahead. +- THOUGHTS.md and the golf_hub model describe contracts the MoonBase server + speaks; when either side of the wire changes, the doc moves with it — or + says explicitly which side is ahead. - Keep the claims accurate. Don't write that something is covered "everywhere" when a subtree is deliberately excluded; name the exclusion. - There are no ADRs and no CHANGELOG. The nearest equivalents are the docs diff --git a/src/hooks/__tests__/useGolfGame.chat.test.tsx b/src/hooks/__tests__/useGolfGame.chat.test.tsx index b050aa3..eaa150d 100644 --- a/src/hooks/__tests__/useGolfGame.chat.test.tsx +++ b/src/hooks/__tests__/useGolfGame.chat.test.tsx @@ -30,7 +30,7 @@ const mockNetworkAdapter = { hideCards: vi.fn(), isMyTurn: vi.fn(), getCurrentPlayer: vi.fn(), - // Declared the way the v2 adapter does; the send path forwards here. + // Declared the way the adapter does; the send path forwards here. // Availability is proven by wire events, not by this declaration. sendChat: vi.fn(), roomState: null, diff --git a/src/hooks/__tests__/useGolfGame.permalinkDetour.test.tsx b/src/hooks/__tests__/useGolfGame.permalinkDetour.test.tsx index 72142ea..14e917f 100644 --- a/src/hooks/__tests__/useGolfGame.permalinkDetour.test.tsx +++ b/src/hooks/__tests__/useGolfGame.permalinkDetour.test.tsx @@ -119,7 +119,7 @@ describe('useGolfGame - permalink detour and rejection (#260)', () => { expect(result.current.permalinkJoinAttempt.isAttempting).toBe(true) // The hub's refusal is the race's signature — the cue to leave (the - // v2 wire needs no room id) and chain, not a terminal verdict. The + // wire needs no room id) and chain, not a terminal verdict. The // wire fans the same string to onNotification; mid-recovery it must // not reach the screen as a toast. act(() => { diff --git a/src/hooks/useGolfGame.ts b/src/hooks/useGolfGame.ts index fc82c7b..d5769c4 100644 --- a/src/hooks/useGolfGame.ts +++ b/src/hooks/useGolfGame.ts @@ -670,7 +670,7 @@ export const useGolfGame = ({ // open, before the resume's roomState lands, so the join effect // usually sends a bare joinRoom that the hub refuses — the seat // is still in its old room server-side. That refusal is the cue, - // not the verdict: leave (the v2 wire needs no room id; the hub + // not the verdict: leave (the wire needs no room id; the hub // knows the seat's room) and let onRoomLeft chain the join. Once // per attempt, so a genuinely missing target cannot loop. if (errorMessage.includes('already in a room') && attempt.roomId && diff --git a/src/types/golfChat.ts b/src/types/golfChat.ts index 4a7216b..03dbbeb 100644 --- a/src/types/golfChat.ts +++ b/src/types/golfChat.ts @@ -1,4 +1,4 @@ -// Room chat over the golf v2 wire (MoonBase#1226): the shared message +// Room chat over the golf wire (MoonBase#1226): the shared message // shape and the merge rule every consumer applies. The server is // authoritative for ids, sender, and timestamp; delivery is // at-least-once and history/live overlap is legal, so everything that diff --git a/src/utils/networkAdapter.ts b/src/utils/networkAdapter.ts index 0694e01..a692545 100644 --- a/src/utils/networkAdapter.ts +++ b/src/utils/networkAdapter.ts @@ -524,7 +524,7 @@ export class GolfNetworkAdapter implements GolfGameAdapter { } startNewGame(): void { - // v2 folded startNewGame into createGame; the creator is auto-seated. + // The hub has no separate start: creating a game seats the creator. this.requestCreateGame() } From 000b196ed35c6bc1c42b490c954ad8cd2b0f4785 Mon Sep 17 00:00:00 2001 From: Andy Aylward Date: Wed, 2 Sep 2026 21:11:23 -0400 Subject: [PATCH 3/3] golf: cover the play url override; drop the unreferenced network manager and thoughts plugin --- .../useGolfGame.gameCreation.test.tsx | 7 - src/plugins/thoughtsNetworkPlugin.ts | 287 ----------------- src/utils/__tests__/networkAdapter.test.ts | 9 + src/utils/networkManager.ts | 297 ------------------ 4 files changed, 9 insertions(+), 591 deletions(-) delete mode 100644 src/plugins/thoughtsNetworkPlugin.ts delete mode 100644 src/utils/networkManager.ts diff --git a/src/hooks/__tests__/useGolfGame.gameCreation.test.tsx b/src/hooks/__tests__/useGolfGame.gameCreation.test.tsx index 168f348..f798264 100644 --- a/src/hooks/__tests__/useGolfGame.gameCreation.test.tsx +++ b/src/hooks/__tests__/useGolfGame.gameCreation.test.tsx @@ -109,13 +109,6 @@ vi.mock('../../utils/networkAdapter', () => ({ }) })) -// Mock environment variable -vi.mock('import.meta', () => ({ - env: { - VITE_GOLF_WEBSOCKET_URL: 'ws://test-server' - } -})) - describe('useGolfGame enhanced game creation flow', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/plugins/thoughtsNetworkPlugin.ts b/src/plugins/thoughtsNetworkPlugin.ts deleted file mode 100644 index adbcf70..0000000 --- a/src/plugins/thoughtsNetworkPlugin.ts +++ /dev/null @@ -1,287 +0,0 @@ -/* eslint-disable no-console */ -import type { - BaseNetworkMessage, - GameNetworkPlugin, - MessageHandlerMap, - NetworkContext -} from '@/types/network' -import type { GameState, Player } from '@/types/game' -import { ShapeType } from '@/types/game' -import { generateRandomColor, generateRandomSpawnPosition } from '@/utils/gameUtils' -import { GAME_CONFIG } from '@/utils/gameClasses' - -// Thoughts-specific message types -interface ThoughtsMessage extends BaseNetworkMessage { - type: 'welcome' | 'player_join' | 'player_leave' | 'position_update' | 'shape_update' | 'game_state' - playerId?: string - position?: [number, number, number] - color?: [number, number, number] - shape?: ShapeType - players?: Array<{ - playerId: string - position: [number, number, number] - color: [number, number, number] - shape: ShapeType - }> -} - -// Thoughts game state -interface ThoughtsGameState { - localPlayerId: string | null - players: Map - pendingPlayerData?: { - position: [number, number, number] - color: [number, number, number] - shape: ShapeType - } - lastSentPosition: [number, number, number] | null - lastPositionSent: number -} - -export class ThoughtsNetworkPlugin implements GameNetworkPlugin { - gameType = 'thoughts' - private positionUpdateThrottle = 50 // ms - private onPlayerIdReceived?: (playerId: string) => void - private gameStateRef: GameState | null = null - - constructor(gameStateRef?: GameState, onPlayerIdReceived?: (playerId: string) => void) { - if (gameStateRef) { - this.gameStateRef = gameStateRef - } - if (onPlayerIdReceived) { - this.onPlayerIdReceived = onPlayerIdReceived - } - } - - getMessageHandlers(): MessageHandlerMap { - return { - 'welcome': (msg, ctx) => this.handleWelcome(msg as ThoughtsMessage, ctx), - 'player_join': (msg, ctx) => this.handlePlayerJoin(msg as ThoughtsMessage, ctx), - 'player_leave': (msg, ctx) => this.handlePlayerLeave(msg as ThoughtsMessage, ctx), - 'position_update': (msg, ctx) => this.handlePositionUpdate(msg as ThoughtsMessage, ctx), - 'shape_update': (msg, ctx) => this.handleShapeUpdate(msg as ThoughtsMessage, ctx), - 'game_state': (msg, ctx) => this.handleGameState(msg as ThoughtsMessage, ctx) - } - } - - validateMessage(message: BaseNetworkMessage): boolean { - const validTypes = ['welcome', 'player_join', 'player_leave', 'position_update', 'shape_update', 'game_state'] - return validTypes.includes(message.type) - } - - getInitialState(): ThoughtsGameState { - const randomSpawnPosition = generateRandomSpawnPosition(GAME_CONFIG.worldBoundary) - const randomColor = generateRandomColor() - - return { - localPlayerId: null, - players: new Map(), - pendingPlayerData: { - position: randomSpawnPosition, - color: randomColor, - shape: ShapeType.SPHERE - }, - lastSentPosition: null, - lastPositionSent: 0 - } - } - - onConnect(_context: NetworkContext): void { - console.log('🎮 Thoughts game connected') - // Connection established, waiting for welcome message from server - } - - onDisconnect(context: NetworkContext): void { - console.log('🎮 Thoughts game disconnected') - const state = context.getGameState() - - // Send leave message if we have a player ID - if (state.localPlayerId) { - context.send({ - type: 'player_leave', - timestamp: Date.now() - }) - } - } - - // Message handlers - private handleWelcome(message: ThoughtsMessage, context: NetworkContext): void { - if (!message.playerId) { - console.error('Welcome message missing playerId') - return - } - - const state = context.getGameState() - - // Update state with server-assigned ID - context.updateGameState(s => ({ - ...s, - localPlayerId: message.playerId! - })) - - console.log(`🎉 Received player ID from server: ${message.playerId}`) - - // Call callback if provided - if (this.onPlayerIdReceived) { - this.onPlayerIdReceived(message.playerId) - } - - // Add local player with pending data - if (state.pendingPlayerData && this.gameStateRef) { - this.gameStateRef.localPlayerId = message.playerId - this.gameStateRef.addPlayer( - message.playerId, - state.pendingPlayerData.position, - state.pendingPlayerData.color, - state.pendingPlayerData.shape - ) - - console.log(`Spawning player ${message.playerId} at position [${state.pendingPlayerData.position.map(x => x.toFixed(2)).join(', ')}]`) - - // Send player_join message - context.send({ - type: 'player_join', - position: state.pendingPlayerData.position, - color: state.pendingPlayerData.color, - shape: state.pendingPlayerData.shape, - timestamp: Date.now() - }) - - // Clear pending data - context.updateGameState(s => ({ - ...s, - pendingPlayerData: undefined - })) - } - } - - private handlePlayerJoin(message: ThoughtsMessage, context: NetworkContext): void { - if (!message.playerId || !message.position || !message.color) { - console.error('Player join message missing required fields') - return - } - - const state = context.getGameState() - - if (message.playerId !== state.localPlayerId && this.gameStateRef) { - this.gameStateRef.addPlayer( - message.playerId, - message.position, - message.color, - message.shape || ShapeType.SPHERE - ) - console.log(`👋 Player ${message.playerId} joined at [${message.position.join(', ')}]`) - } - } - - private handlePlayerLeave(message: ThoughtsMessage, context: NetworkContext): void { - if (!message.playerId) { - console.error('Player leave message missing playerId') - return - } - - const state = context.getGameState() - - if (message.playerId !== state.localPlayerId && this.gameStateRef) { - const player = this.gameStateRef.players.get(message.playerId) - if (player) { - this.gameStateRef.removePlayer(message.playerId) - } - } - } - - private handlePositionUpdate(message: ThoughtsMessage, context: NetworkContext): void { - if (!message.playerId || !message.position) { - console.error('Position update message missing required fields') - return - } - - const state = context.getGameState() - - if (message.playerId !== state.localPlayerId && this.gameStateRef) { - this.gameStateRef.updatePlayer(message.playerId, message.position) - } - } - - private handleShapeUpdate(message: ThoughtsMessage, context: NetworkContext): void { - if (!message.playerId || message.shape === undefined) { - console.error('Shape update message missing required fields') - return - } - - const state = context.getGameState() - - if (message.playerId !== state.localPlayerId && this.gameStateRef) { - const player = this.gameStateRef.players.get(message.playerId) - if (player) { - player.shape = message.shape - } - } - } - - private handleGameState(message: ThoughtsMessage, context: NetworkContext): void { - - const state = context.getGameState() - - // Process the players array from the game_state message - if (message.players && Array.isArray(message.players) && this.gameStateRef) { - message.players.forEach(player => { - // Skip adding the local player - if (player.playerId !== state.localPlayerId) { - this.gameStateRef!.addPlayer( - player.playerId, - player.position, - player.color, - player.shape || ShapeType.SPHERE - ) - console.log(`🎮 Added player ${player.playerId} from game state at [${player.position.join(', ')}]`) - } - }) - } - } - - // Helper methods for the game to use - sendPositionUpdate(position: [number, number, number], context: NetworkContext): void { - const state = context.getGameState() - const now = Date.now() - - // Throttle position updates - if (now - state.lastPositionSent < this.positionUpdateThrottle) { - return - } - - // Check if position actually changed significantly - if (state.lastSentPosition) { - const dx = position[0] - state.lastSentPosition[0] - const dz = position[2] - state.lastSentPosition[2] - const distance = Math.sqrt(dx * dx + dz * dz) - - // Only send if moved more than 0.1 units - if (distance < 0.1) { - return - } - } - - context.send({ - type: 'position_update', - position: position, - timestamp: now - }) - - // Update state - context.updateGameState(s => ({ - ...s, - lastSentPosition: [...position], - lastPositionSent: now - })) - - } - - sendShapeUpdate(shape: ShapeType, context: NetworkContext): void { - context.send({ - type: 'shape_update', - shape: shape, - timestamp: Date.now() - }) - } -} \ No newline at end of file diff --git a/src/utils/__tests__/networkAdapter.test.ts b/src/utils/__tests__/networkAdapter.test.ts index 9587280..ebf00e8 100644 --- a/src/utils/__tests__/networkAdapter.test.ts +++ b/src/utils/__tests__/networkAdapter.test.ts @@ -61,6 +61,15 @@ describe('GolfNetworkAdapter', () => { expect(golfSessionUrl()).toBe('https://api.muchq.com/games/v2/session') }) + it('follows the play url override, plain http for a plain ws', () => { + vi.stubEnv('VITE_GOLF_WEBSOCKET_URL', 'ws://localhost:2015/games/v2/golf/play') + try { + expect(golfSessionUrl()).toBe('http://localhost:2015/games/v2/session') + } finally { + vi.unstubAllEnvs() + } + }) + let fetchMock: ReturnType let callbacks: MockedCallbacks diff --git a/src/utils/networkManager.ts b/src/utils/networkManager.ts deleted file mode 100644 index a549da1..0000000 --- a/src/utils/networkManager.ts +++ /dev/null @@ -1,297 +0,0 @@ -/* eslint-disable no-console */ -import type { - BaseNetworkMessage, - GameNetworkPlugin, - NetworkConfig, - NetworkContext, - NetworkEvents, - PluginRegistration -} from '@/types/network' -import { ConnectionState } from '@/types/network' - -export class NetworkManager { - private plugins: Map = new Map() - private activePlugin: GameNetworkPlugin | null = null - private ws: WebSocket | null = null - private config: NetworkConfig | null = null - private connectionState: ConnectionState = ConnectionState.DISCONNECTED - private gameState: unknown = null - private events: NetworkEvents = {} - private reconnectAttempts = 0 - private reconnectTimeout: number | null = null - - constructor(events?: NetworkEvents) { - if (events) { - this.events = events - } - } - - // Register a game plugin - registerPlugin({ plugin, override = false }: PluginRegistration): void { - if (this.plugins.has(plugin.gameType) && !override) { - throw new Error(`Plugin for game type "${plugin.gameType}" already registered`) - } - this.plugins.set(plugin.gameType, plugin) - console.log(`🔌 Registered plugin for game: ${plugin.gameType}`) - } - - // Connect to server with specific game type - connect(config: NetworkConfig): void { - this.config = config - - // Find and activate the plugin for this game type - const plugin = this.plugins.get(config.gameType) - if (!plugin) { - throw new Error(`No plugin registered for game type: ${config.gameType}`) - } - - this.activePlugin = plugin - - // Initialize game state if plugin provides initial state - if (plugin.getInitialState) { - this.gameState = plugin.getInitialState() - } else { - this.gameState = {} - } - - this.establishConnection() - } - - private establishConnection(): void { - if (!this.config) return - - this.setConnectionState(ConnectionState.CONNECTING) - - try { - this.ws = new WebSocket(this.config.url) - - this.ws.onopen = () => { - console.log(`🔌 Connected to ${this.config!.url} for game: ${this.config!.gameType}`) - this.reconnectAttempts = 0 - this.setConnectionState(ConnectionState.CONNECTED) - - // Call plugin's onConnect hook - if (this.activePlugin?.onConnect) { - this.activePlugin.onConnect(this.createContext()) - } - } - - this.ws.onmessage = (event) => { - try { - // First try to parse as a single message - try { - const message = JSON.parse(event.data) as BaseNetworkMessage - this.handleMessage(message) - return - } catch (firstError) { - // If that fails, try handling as newline-delimited JSON - const lines = event.data.split('\n') - const messages: BaseNetworkMessage[] = [] - let currentJson = '' - - for (const line of lines) { - currentJson += line - try { - const message = JSON.parse(currentJson) as BaseNetworkMessage - messages.push(message) - currentJson = '' - } catch { - // Not a complete JSON yet, add newline back and continue - currentJson += '\n' - } - } - - // If we have accumulated JSON that couldn't be parsed, it's an error - if (currentJson.trim()) { - throw new Error('Incomplete JSON message', { cause: firstError }) - } - - // Process all successfully parsed messages - if (messages.length === 0) { - throw firstError // Re-throw the original error - } - - for (const message of messages) { - this.handleMessage(message) - } - } - } catch (error) { - console.error('Failed to parse message:', error) - this.handleError(new Error('Invalid message format')) - } - } - - this.ws.onclose = () => { - console.log('🔌 WebSocket disconnected') - this.handleDisconnection() - } - - this.ws.onerror = (error) => { - console.error('🔌 WebSocket error:', error) - this.handleError(new Error('WebSocket connection error')) - } - } catch (error) { - console.error('🔌 Failed to connect:', error) - this.handleError(error as Error) - } - } - - private handleMessage(message: BaseNetworkMessage): void { - if (!this.activePlugin) { - console.warn('No active plugin to handle message') - return - } - - // Log message if debug is enabled - if (this.config?.debug) { - console.log('📥 Received:', message) - } - - // Call global message handler if provided - if (this.events.onMessage) { - this.events.onMessage(message) - } - - // Validate message with plugin - if (!this.activePlugin.validateMessage(message)) { - console.warn('Invalid message for current game:', message) - return - } - - // Get message handlers from plugin - const handlers = this.activePlugin.getMessageHandlers() - const handler = handlers[message.type] - - if (handler) { - handler(message, this.createContext()) - } else { - console.warn(`No handler for message type: ${message.type}`) - } - } - - private handleDisconnection(): void { - this.setConnectionState(ConnectionState.DISCONNECTED) - - // Call plugin's onDisconnect hook - if (this.activePlugin?.onDisconnect) { - this.activePlugin.onDisconnect(this.createContext()) - } - - // Handle reconnection if enabled - if (this.config?.reconnect && this.reconnectAttempts < (this.config.maxReconnectAttempts || 5)) { - this.attemptReconnection() - } - } - - private attemptReconnection(): void { - this.reconnectAttempts++ - const delay = this.config?.reconnectDelay || 5000 - - console.log(`🔄 Attempting reconnection ${this.reconnectAttempts}/${this.config?.maxReconnectAttempts || 5} in ${delay}ms`) - this.setConnectionState(ConnectionState.RECONNECTING) - - this.reconnectTimeout = window.setTimeout(() => { - this.establishConnection() - }, delay) - } - - private handleError(error: Error): void { - this.setConnectionState(ConnectionState.ERROR) - - // Call global error handler - if (this.events.onError) { - this.events.onError(error) - } - - // Call plugin's onError hook - if (this.activePlugin?.onError) { - this.activePlugin.onError(error, this.createContext()) - } - } - - private setConnectionState(state: ConnectionState): void { - if (this.connectionState !== state) { - this.connectionState = state - if (this.events.onConnectionStateChange) { - this.events.onConnectionStateChange(state) - } - } - } - - private createContext(): NetworkContext { - return { - send: (message: BaseNetworkMessage) => this.send(message), - broadcast: (message: BaseNetworkMessage) => this.broadcast(message), - getConnectionId: () => this.ws?.url || '', - getGameState: () => this.gameState as T, - updateGameState: (updater: (state: T) => T) => { - this.gameState = updater(this.gameState as T) - }, - isConnected: () => this.connectionState === ConnectionState.CONNECTED - } - } - - // Send a message to the server - send(message: BaseNetworkMessage): void { - if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { - console.warn('Cannot send message: not connected') - return - } - - // Ensure timestamp is set - if (!message.timestamp) { - message.timestamp = Date.now() - } - - // Log message if debug is enabled - if (this.config?.debug) { - console.log('📤 Sending:', message) - } - - this.ws.send(JSON.stringify(message)) - } - - // Broadcast is same as send for client (server would handle actual broadcast) - broadcast(message: BaseNetworkMessage): void { - this.send(message) - } - - // Disconnect from server - disconnect(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = null - } - - if (this.ws) { - this.ws.close() - this.ws = null - } - - this.activePlugin = null - this.gameState = null - this.config = null - this.reconnectAttempts = 0 - this.setConnectionState(ConnectionState.DISCONNECTED) - } - - // Get current connection state - getConnectionState(): ConnectionState { - return this.connectionState - } - - // Get active game type - getActiveGameType(): string | null { - return this.activePlugin?.gameType || null - } - - // Check if connected - isConnected(): boolean { - return this.connectionState === ConnectionState.CONNECTED - } - - // Get registered game types - getRegisteredGameTypes(): string[] { - return Array.from(this.plugins.keys()) - } -} \ No newline at end of file