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..7aa8c51 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
@@ -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/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/__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.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/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 e36cc83..d5769c4 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
@@ -675,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 &&
@@ -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/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/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/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/__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 92%
rename from src/utils/__tests__/golfV2Adapter.test.ts
rename to src/utils/__tests__/networkAdapter.test.ts
index 78b2c8c..ebf00e8 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,20 @@ 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')
+ })
+
+ 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
@@ -110,8 +123,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 +214,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 +311,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 +322,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 +431,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 +456,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 +473,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 +491,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..a692545 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 {
+ // The hub has no separate start: creating a game seats the creator.
+ 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
+}
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