Add GameStateManager and WebSocketClient

This commit is contained in:
sepia 2025-07-15 16:17:59 -05:00
parent c15c9c16c8
commit 3be0c40b64
4 changed files with 443 additions and 0 deletions

View File

@ -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
});

View File

@ -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
}
}
}

View File

@ -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>) => 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<typeof mock>;
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
});
});

View File

@ -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);
}
}
}
}