From 3be0c40b6455f8473988e3d95d97dfff0192eca5 Mon Sep 17 00:00:00 2001 From: sepia Date: Tue, 15 Jul 2025 16:17:59 -0500 Subject: [PATCH] Add GameStateManager and WebSocketClient --- src/game-client/GameStateManager.test.ts | 90 +++++++++++ src/game-client/GameStateManager.ts | 50 ++++++ src/game-client/WebSocketClient.test.ts | 197 +++++++++++++++++++++++ src/game-client/WebSocketClient.ts | 106 ++++++++++++ 4 files changed, 443 insertions(+) create mode 100644 src/game-client/GameStateManager.test.ts create mode 100644 src/game-client/GameStateManager.ts create mode 100644 src/game-client/WebSocketClient.test.ts create mode 100644 src/game-client/WebSocketClient.ts diff --git a/src/game-client/GameStateManager.test.ts b/src/game-client/GameStateManager.test.ts new file mode 100644 index 0000000..3a9fa64 --- /dev/null +++ b/src/game-client/GameStateManager.test.ts @@ -0,0 +1,90 @@ + +import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test'; +import { GameStateManager } from './GameStateManager'; + +describe('GameStateManager', () => { + let gameStateManager: GameStateManager; + + beforeEach(() => { + // Initialize a fresh GameStateManager before each test + gameStateManager = new GameStateManager(); + }); + + test('should initialize with a default empty game state', () => { + const initialState = gameStateManager.getGameState(); + expect(initialState).toEqual({ + id: '', + board: Array(15).fill(Array(15).fill(null)), + currentPlayer: 'black', + status: 'waiting', + winner: null, + players: {}, + }); + }); + + test('should update game state from server updates', () => { + const serverState = { + id: 'game123', + board: Array(15).fill(Array(15).fill(null)), + currentPlayer: 'white', + status: 'playing', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + gameStateManager.updateGameState(serverState); + expect(gameStateManager.getGameState()).toEqual(serverState); + }); + + test('should handle optimistic updates for making a move', () => { + const initialBoard = Array(15).fill(Array(15).fill(null)); + initialBoard[7][7] = 'black'; // Simulate an optimistic move + + const optimisticState = { + id: 'game123', + board: initialBoard, + currentPlayer: 'white', // Turn changes optimistically + status: 'playing', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + + gameStateManager.updateGameState(optimisticState); + expect(gameStateManager.getGameState().board[7][7]).toEqual('black'); + expect(gameStateManager.getGameState().currentPlayer).toEqual('white'); + }); + + test('should rollback optimistic updates if server rejects move', () => { + const initialServerState = { + id: 'game123', + board: Array(15).fill(Array(15).fill(null)), + currentPlayer: 'black', + status: 'playing', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + gameStateManager.updateGameState(initialServerState); + + // Optimistic update + const optimisticBoard = Array(15).fill(Array(15).fill(null)); + optimisticBoard[7][7] = 'black'; + const optimisticState = { + id: 'game123', + board: optimisticBoard, + currentPlayer: 'white', + status: 'playing', + winner: null, + players: { black: 'playerA', white: 'playerB' }, + }; + gameStateManager.updateGameState(optimisticState); + + // Server rejection - rollback to initial state + gameStateManager.rollbackGameState(); // This method will be implemented + expect(gameStateManager.getGameState()).toEqual(initialServerState); + }); + + // Add more tests for: + // - Win conditions + // - Draw conditions + // - Invalid moves (already occupied, out of bounds - though this might be server-side validation primarily) + // - Player disconnection/reconnection behavior +}); diff --git a/src/game-client/GameStateManager.ts b/src/game-client/GameStateManager.ts new file mode 100644 index 0000000..74ff918 --- /dev/null +++ b/src/game-client/GameStateManager.ts @@ -0,0 +1,50 @@ +export interface GameStateType { + id: string; + board: (null | 'black' | 'white')[][]; + currentPlayer: 'black' | 'white'; + status: 'waiting' | 'playing' | 'finished'; + winner: null | 'black' | 'white' | 'draw'; + players: { black?: string, white?: string }; +} + +export class GameStateManager { + private gameState: GameStateType; + private stateHistory: GameStateType[]; + + constructor() { + this.gameState = this.getDefaultGameState(); + this.stateHistory = []; + } + + private getDefaultGameState(): GameStateType { + const emptyBoard: (null | 'black' | 'white')[][] = Array(15).fill(null).map(() => Array(15).fill(null)); + return { + id: '', + board: emptyBoard, + currentPlayer: 'black', + status: 'waiting', + winner: null, + players: {}, + }; + } + + getGameState(): GameStateType { + return this.gameState; + } + + updateGameState(newState: GameStateType): void { + // Store a deep copy of the current state before updating + // This is crucial for rollback to work correctly, as objects are passed by reference. + this.stateHistory.push(JSON.parse(JSON.stringify(this.gameState))); + this.gameState = newState; + } + + rollbackGameState(): void { + if (this.stateHistory.length > 0) { + this.gameState = this.stateHistory.pop()!; + } else { + console.warn("No previous state to rollback to."); + // Optionally, throw an error or reset to default state here + } + } +} diff --git a/src/game-client/WebSocketClient.test.ts b/src/game-client/WebSocketClient.test.ts new file mode 100644 index 0000000..8964f8d --- /dev/null +++ b/src/game-client/WebSocketClient.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { WebSocketClient } from './WebSocketClient'; + +// Define MockWebSocket as a regular class +class MockWebSocket { + onopen: ((this: WebSocket, ev: Event) => any) | null = null; + onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null; + onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null; + onerror: ((this: WebSocket, ev: Event) => any) | null = null; + readyState: number = 0; // 0: CONNECTING, 1: OPEN, 2: CLOSING, 3: CLOSED + + // Using a plain function for send/close to simplify. + send(data: string) { + if (this.readyState === WebSocket.OPEN) { + // Simulate server echoing back message for testing received messages + this.onmessage?.(new MessageEvent('message', { data })); + } + } + close() { + this.readyState = WebSocket.CLOSING; + this.onclose?.(new CloseEvent('close')); + this.readyState = WebSocket.CLOSED; + } + + // Helper to simulate events + _simulateOpen() { + this.readyState = WebSocket.OPEN; + this.onopen?.(new Event('open')); + } + + _simulateMessage(data: string) { + this.onmessage?.(new MessageEvent('message', { data })); + } + + _simulateClose(code = 1000, reason = '', wasClean = true) { + this.readyState = WebSocket.CLOSING; + this.onclose?.(new CloseEvent('close', { code, reason, wasClean })); + this.readyState = WebSocket.CLOSED; + } + + _simulateError(error: Event) { + this.onerror?.(error); + } +} + +// Global array to track MockWebSocket instances created +let createdMockWebSockets: MockWebSocket[] = []; + +// Store original WebSocket for restoration +const originalWebSocket = global.WebSocket; + +describe('WebSocketClient', () => { + let client: WebSocketClient; + const url = 'ws://localhost:8080'; + + // Using a mock function to wrap the actual global WebSocket constructor + let globalWebSocketConstructorMock: ReturnType; + + beforeEach(() => { + // Clear instances and reset mocks before each test + createdMockWebSockets = []; + + // Create a mock function that, when called as the WebSocket constructor, + // creates a new MockWebSocket instance, pushes it to our global tracker, + // and then spies on its send and close methods.< + globalWebSocketConstructorMock = mock((url: string) => { + const instance = new MockWebSocket(url); + createdMockWebSockets.push(instance); + + // Wrap send and close methods in mock functions for call tracking + // We do this directly on the instance for each new WebSocket. + // Using mock() directly on the method reference, and binding 'this' + // to ensure it operates in the context of the instance. + (instance as any).send = mock(instance.send.bind(instance)); + (instance as any).close = mock(instance.close.bind(instance)); + + return instance; + }) as any; // Cast as any to satisfy TypeScript for global assignment + + global.WebSocket = globalWebSocketConstructorMock; + + }); + + afterEach(() => { + global.WebSocket = originalWebSocket; // Restore original WebSocket + }); + + it('should connect to the specified URL', () => { + client = new WebSocketClient(url); + client.connect(); + + expect(globalWebSocketConstructorMock).toHaveBeenCalledWith(url); + expect(createdMockWebSockets.length).toBe(1); + }); + + it('should call onOpen callback when connection is established', () => { + const onOpenMock = mock(() => {}); + client = new WebSocketClient(url); + client.onOpen(onOpenMock); + client.connect(); + + createdMockWebSockets[0]._simulateOpen(); + expect(onOpenMock).toHaveBeenCalledTimes(1); + }); + + it('should call onMessage callback when a message is received', () => { + const onMessageMock = mock(() => {}); + client = new WebSocketClient(url); + client.onMessage(onMessageMock); + client.connect(); + + createdMockWebSockets[0]._simulateOpen(); + createdMockWebSockets[0]._simulateMessage('test message'); + + expect(onMessageMock).toHaveBeenCalledWith('test message'); + }); + + it('should call onClose callback when connection is closed', () => { + const onCloseMock = mock(() => {}); + client = new WebSocketClient(url); + client.onClose(onCloseMock); + client.connect(); + + createdMockWebSockets[0]._simulateOpen(); + createdMockWebSockets[0]._simulateClose(); + + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('should call onError callback when an error occurs', () => { + const onErrorMock = mock(() => {}); + client = new WebSocketClient(url); + client.onError(onErrorMock); + client.connect(); + + createdMockWebSockets[0]._simulateError(new Event('error')); + + expect(onErrorMock).toHaveBeenCalledTimes(1); + }); + + it('should send a message when connected', () => { + client = new WebSocketClient(url); + client.connect(); + createdMockWebSockets[0]._simulateOpen(); + + client.send('hello'); + // Expect the mocked `send` method on the MockWebSocket instance to have been called + expect(createdMockWebSockets[0].send).toHaveBeenCalledWith('hello'); + }); + + it('should queue messages when disconnected and send them upon reconnection', async () => { + client = new WebSocketClient(url, { reconnectInterval: 10 }); // Shorter interval for faster test + const onOpenMock = mock(() => {}); + client.onOpen(onOpenMock); + + client.connect(); // Connect for the first time + expect(createdMockWebSockets.length).toBe(1); // First instance + const firstWs = createdMockWebSockets[0]; + + // Simulate immediate disconnection before open + firstWs._simulateClose(); + + // Send messages while disconnected, they should be queued + client.send('queued message 1'); + client.send('queued message 2'); + + // Simulate reconnection after a short delay + await new Promise(resolve => setTimeout(resolve, 20)); // Allow for reconnectInterval + expect(createdMockWebSockets.length).toBe(2); // New instance created for reconnection + createdMockWebSockets[1]._simulateOpen(); // Simulate new connection opening + + // Wait for messages to be flushed + await new Promise(resolve => setTimeout(resolve, 5)); + + expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 1'); + expect(createdMockWebSockets[1].send).toHaveBeenCalledWith('queued message 2'); + + expect(onOpenMock).toHaveBeenCalledTimes(1); // onOpen should be called on successful reconnection + }); + + it('should not attempt to reconnect if explicitly closed', async () => { + client = new WebSocketClient(url, { reconnectAttempts: 3, reconnectInterval: 10 }); + const onCloseMock = mock(() => {}); + client.onClose(onCloseMock); + + client.connect(); + createdMockWebSockets[0]._simulateOpen(); + client.close(); // Explicitly close + + // Allow some time for potential reconnect attempts. If no new WebSocket is created after the attempts would have happened, then we know it's not reconnecting. + await new Promise(resolve => setTimeout(resolve, 50)); // (reconnectAttempts * reconnectInterval) + buffer + + expect(createdMockWebSockets.length).toBe(1); // Only the initial one + expect(onCloseMock).toHaveBeenCalledTimes(1); // onClose should be called + }); + +}); diff --git a/src/game-client/WebSocketClient.ts b/src/game-client/WebSocketClient.ts new file mode 100644 index 0000000..acdb03c --- /dev/null +++ b/src/game-client/WebSocketClient.ts @@ -0,0 +1,106 @@ +type MessageHandler = (message: string) => void; +type OpenHandler = () => void; +type CloseHandler = (code: number, reason: string) => void; +type ErrorHandler = (event: Event) => void; + +interface WebSocketClientOptions { + reconnectAttempts?: number; + reconnectInterval?: number; +} + +export class WebSocketClient { + private ws: WebSocket | null = null; + private url: string; + private messageQueue: string[] = []; + private isConnected: boolean = false; + private reconnectCount: number = 0; + private options: WebSocketClientOptions; + private manualClose: boolean = false; + + private onMessageHandler: MessageHandler = () => {}; + private onOpenHandler: OpenHandler = () => {}; + private onCloseHandler: CloseHandler = () => {}; + private onErrorHandler: ErrorHandler = () => {}; + + constructor(url: string, options?: WebSocketClientOptions) { + this.url = url; + this.options = { + reconnectAttempts: 5, // Default reconnect attempts + reconnectInterval: 3000, // Default reconnect interval in ms + ...options, + }; + } + + public connect(): void { + this.manualClose = false; + this.ws = new WebSocket(this.url); + this.ws.onopen = this.handleOpen.bind(this); + this.ws.onmessage = this.handleMessage.bind(this); + this.ws.onclose = this.handleClose.bind(this); + this.ws.onerror = this.handleError.bind(this); + } + + public send(message: string): void { + if (this.isConnected && this.ws) { + this.ws.send(message); + } else { + this.messageQueue.push(message); + } + } + + public close(): void { + this.manualClose = true; + if (this.ws) { + this.ws.close(); + } + } + + public onMessage(handler: MessageHandler): void { + this.onMessageHandler = handler; + } + + public onOpen(handler: OpenHandler): void { + this.onOpenHandler = handler; + } + + public onClose(handler: CloseHandler): void { + this.onCloseHandler = handler; + } + + public onError(handler: ErrorHandler): void { + this.onErrorHandler = handler; + } + + private handleOpen(): void { + this.isConnected = true; + this.reconnectCount = 0; + this.onOpenHandler(); + this.flushMessageQueue(); + } + + private handleMessage(event: MessageEvent): void { + this.onMessageHandler(event.data); + } + + private handleClose(event: CloseEvent): void { + this.isConnected = false; + this.onCloseHandler(event.code, event.reason); + if (!this.manualClose && this.reconnectCount < this.options.reconnectAttempts!) { + this.reconnectCount++; + setTimeout(() => this.connect(), this.options.reconnectInterval); + } + } + + private handleError(event: Event): void { + this.onErrorHandler(event); + } + + private flushMessageQueue(): void { + while (this.messageQueue.length > 0 && this.isConnected && this.ws) { + const message = this.messageQueue.shift(); + if (message) { + this.ws.send(message); + } + } + } +}