Preference-based refactoring
This commit is contained in:
parent
cd21e4e8bd
commit
1f19022d45
|
@ -0,0 +1,8 @@
|
||||||
|
Frontend: HTMX and plain JS/CSS
|
||||||
|
Backend: Elysia (TypeScript)
|
||||||
|
|
||||||
|
Important files:
|
||||||
|
* src/index.ts - backend entrypoint
|
||||||
|
* src/game/GameInstance.ts - gomoku game logic
|
||||||
|
* src/game/WebSocketHandler.ts - network with players via websockets
|
||||||
|
* src/view/board-renderer.ts - render frontend components
|
|
@ -28,7 +28,7 @@
|
||||||
<button onclick="copyGameLink()">Copy</button>
|
<button onclick="copyGameLink()">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="ws-status" style="margin-top: 10px; color: grey">
|
<div id="ws-status" style="margin-top: 10px; color: grey">
|
||||||
Connecting...
|
Disconnected
|
||||||
</div>
|
</div>
|
||||||
<script src="scripts/display-ws-connection.js"></script>
|
<script src="scripts/display-ws-connection.js"></script>
|
||||||
<script src="scripts/send-ws-messages.js"></script>
|
<script src="scripts/send-ws-messages.js"></script>
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
console.log('Gomoku client entry point -- HTMX mode.');
|
|
||||||
|
|
||||||
const WS_URL = process.env.WS_URL || 'ws://localhost:3000/ws';
|
|
||||||
|
|
||||||
// This will be handled by HTMX's ws-connect
|
|
||||||
// However, we might still need a way to send messages from client-side JS if required by HTMX
|
|
||||||
|
|
||||||
// Example of how to send a message via the established HTMX WebSocket.
|
|
||||||
// This is a placeholder and might evolve as we refactor.
|
|
||||||
(window as any).sendWebSocketMessage = (message: any) => {
|
|
||||||
const gameContainer = document.getElementById('game-container');
|
|
||||||
if (gameContainer) {
|
|
||||||
const ws = (gameContainer as any)._htmx_ws;
|
|
||||||
if (ws) {
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
} else {
|
|
||||||
console.error('HTMX WebSocket not found on game-container.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Game container not found.');
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,91 +0,0 @@
|
||||||
import { expect, test, describe, beforeEach, afterEach, mock } from 'bun:test';
|
|
||||||
import { GameStateManager, GameStateType } 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.from({ length: 15 }, () => Array(15).fill(null)),
|
|
||||||
currentPlayer: 'white' as 'black' | 'white',
|
|
||||||
status: 'playing' as 'waiting' | 'playing' | 'finished',
|
|
||||||
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.from({ length: 15 }, () => Array(15).fill(null));
|
|
||||||
initialBoard[7][7] = 'black'; // Simulate an optimistic move
|
|
||||||
|
|
||||||
const optimisticState = {
|
|
||||||
id: 'game123',
|
|
||||||
board: initialBoard,
|
|
||||||
currentPlayer: 'white' as 'black' | 'white', // Turn changes optimistically
|
|
||||||
status: 'playing' as 'waiting' | 'playing' | 'finished',
|
|
||||||
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.from({ length: 15 }, () => Array(15).fill(null)),
|
|
||||||
currentPlayer: 'black' as 'black' | 'white',
|
|
||||||
status: 'playing' as 'waiting' | 'playing' | 'finished',
|
|
||||||
winner: null,
|
|
||||||
players: { black: 'playerA', white: 'playerB' },
|
|
||||||
};
|
|
||||||
gameStateManager.updateGameState(initialServerState);
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
const optimisticBoard = Array.from({ length: 15 }, () =>
|
|
||||||
Array(15).fill(null),
|
|
||||||
);
|
|
||||||
optimisticBoard[7][7] = 'black';
|
|
||||||
const optimisticState = {
|
|
||||||
id: 'game123',
|
|
||||||
board: optimisticBoard,
|
|
||||||
currentPlayer: 'white' as 'black' | 'white',
|
|
||||||
status: 'playing' as 'waiting' | 'playing' | 'finished',
|
|
||||||
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
|
|
||||||
});
|
|
|
@ -1,52 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,208 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
||||||
import { WebSocketClient } from './WebSocketClient';
|
|
||||||
|
|
||||||
// Define MockWebSocket as a regular class
|
|
||||||
class MockWebSocket {
|
|
||||||
static CONNECTING = 0;
|
|
||||||
static OPEN = 1;
|
|
||||||
static CLOSING = 2;
|
|
||||||
static CLOSED = 3;
|
|
||||||
|
|
||||||
constructor(url?: string) {
|
|
||||||
// In a real scenario, you might do something with the URL
|
|
||||||
// For this mock, we just need to accept it.
|
|
||||||
}
|
|
||||||
|
|
||||||
onopen: ((this: any, ev: Event) => any) | null = null;
|
|
||||||
onmessage: ((this: any, ev: MessageEvent<any>) => any) | null = null;
|
|
||||||
onclose: ((this: any, ev: CloseEvent) => any) | null = null;
|
|
||||||
onerror: ((this: any, 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: typeof WebSocket;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
(instance as any).send = mock(instance.send.bind(instance));
|
|
||||||
(instance as any).close = mock(instance.close.bind(instance));
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}) as unknown as typeof WebSocket;
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,109 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
||||||
import { GameManager } from './GameManager';
|
|
||||||
import { GameInstance } from './GameInstance';
|
|
||||||
|
|
||||||
describe('GameManager', () => {
|
|
||||||
let gameManager: GameManager;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
gameManager = new GameManager();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a new game', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
expect(game).toBeInstanceOf(GameInstance);
|
|
||||||
expect(gameManager.getGame(game.id)).toBe(game);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow players to join games', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
const playerId = 'player1';
|
|
||||||
const result = gameManager.joinGame(game.id, playerId);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
// Add more assertions based on GameInstance implementation
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not allow joining non-existent games', () => {
|
|
||||||
const result = gameManager.joinGame('non-existent-id', 'player1');
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should retrieve existing games', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
const retrievedGame = gameManager.getGame(game.id);
|
|
||||||
expect(retrievedGame).toBe(game);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-existent games', () => {
|
|
||||||
const game = gameManager.getGame('non-existent-id');
|
|
||||||
expect(game).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove games', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
gameManager.removeGame(game.id);
|
|
||||||
const retrievedGame = gameManager.getGame(game.id);
|
|
||||||
expect(retrievedGame).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { GameInstance } from './GameInstance';
|
|
||||||
|
|
||||||
export class GameManager {
|
|
||||||
private games: Map<string, GameInstance>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.games = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overload createGame to optionally accept a gameId
|
|
||||||
createGame(gameId?: string): GameInstance {
|
|
||||||
const game = new GameInstance(gameId); // Pass gameId to GameInstance constructor
|
|
||||||
this.games.set(game.id, game);
|
|
||||||
return game;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGame(gameId: string): GameInstance | null {
|
|
||||||
return this.games.get(gameId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public joinGame(gameId: string, playerId: string): boolean {
|
|
||||||
const game = this.games.get(gameId);
|
|
||||||
if (!game) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return game.addPlayer(playerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeGame(gameId: string): void {
|
|
||||||
this.games.delete(gameId);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,397 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
|
||||||
import { WebSocketHandler } from './WebSocketHandler';
|
|
||||||
import { GameManager } from './GameManager';
|
|
||||||
import { GameInstance } from './GameInstance';
|
|
||||||
|
|
||||||
// Mock ElysiaWS type for testing purposes - fully compatible with standard WebSocket
|
|
||||||
type MockElysiaWS = {
|
|
||||||
send: ReturnType<typeof mock>;
|
|
||||||
close: ReturnType<typeof mock>;
|
|
||||||
on: ReturnType<typeof mock>;
|
|
||||||
_messageCallback: ((message: string) => void) | null;
|
|
||||||
_closeCallback: (() => void) | null;
|
|
||||||
_errorCallback: ((error: Error) => void) | null;
|
|
||||||
data: {
|
|
||||||
gameId?: string;
|
|
||||||
playerId?: string;
|
|
||||||
query: Record<string, string>;
|
|
||||||
};
|
|
||||||
// Standard WebSocket properties
|
|
||||||
binaryType: 'blob' | 'arraybuffer';
|
|
||||||
bufferedAmount: number;
|
|
||||||
extensions: string;
|
|
||||||
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null;
|
|
||||||
onerror: ((this: WebSocket, ev: Event) => any) | null;
|
|
||||||
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null;
|
|
||||||
onopen: ((this: WebSocket, ev: Event) => any) | null;
|
|
||||||
protocol: string;
|
|
||||||
readyState: number;
|
|
||||||
url: string;
|
|
||||||
CLOSED: number;
|
|
||||||
CONNECTING: number;
|
|
||||||
OPEN: number;
|
|
||||||
CLOSING: number;
|
|
||||||
dispatchEvent: ReturnType<typeof mock>;
|
|
||||||
addEventListener: ReturnType<typeof mock>;
|
|
||||||
removeEventListener: ReturnType<typeof mock>;
|
|
||||||
ping: ReturnType<typeof mock>; // Bun.js specific
|
|
||||||
pong: ReturnType<typeof mock>; // Bun.js specific
|
|
||||||
subscribe: ReturnType<typeof mock>; // Bun.js specific
|
|
||||||
unsubscribe: ReturnType<typeof mock>; // Bun.js specific
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('WebSocketHandler', () => {
|
|
||||||
let gameManager: GameManager;
|
|
||||||
let webSocketHandler: WebSocketHandler;
|
|
||||||
let mockWs: MockElysiaWS;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockWs = {
|
|
||||||
// Mock standard WebSocket methods
|
|
||||||
send: mock(() => {}),
|
|
||||||
close: mock(() => {}),
|
|
||||||
|
|
||||||
// Mock custom 'on' method for attaching callbacks
|
|
||||||
on: mock((event: string, callback: (...args: any[]) => void) => {
|
|
||||||
if (event === 'message') (mockWs as any)._messageCallback = callback;
|
|
||||||
if (event === 'close') (mockWs as any)._closeCallback = callback;
|
|
||||||
if (event === 'error') (mockWs as any)._errorCallback = callback;
|
|
||||||
}),
|
|
||||||
|
|
||||||
_messageCallback: null,
|
|
||||||
_closeCallback: null,
|
|
||||||
_errorCallback: null,
|
|
||||||
|
|
||||||
data: { query: {} },
|
|
||||||
|
|
||||||
// Initialize all standard WebSocket properties
|
|
||||||
binaryType: 'blob',
|
|
||||||
bufferedAmount: 0,
|
|
||||||
extensions: '',
|
|
||||||
onclose: null,
|
|
||||||
onerror: null,
|
|
||||||
onmessage: null,
|
|
||||||
onopen: null,
|
|
||||||
protocol: '',
|
|
||||||
readyState: 1,
|
|
||||||
url: '',
|
|
||||||
CLOSED: 3,
|
|
||||||
CONNECTING: 0,
|
|
||||||
OPEN: 1,
|
|
||||||
CLOSING: 2,
|
|
||||||
dispatchEvent: mock(() => {}),
|
|
||||||
addEventListener: mock(() => {}),
|
|
||||||
removeEventListener: mock(() => {}),
|
|
||||||
ping: mock(() => {}),
|
|
||||||
pong: mock(() => {}),
|
|
||||||
subscribe: mock(() => {}),
|
|
||||||
unsubscribe: mock(() => {}),
|
|
||||||
};
|
|
||||||
gameManager = new GameManager();
|
|
||||||
webSocketHandler = new WebSocketHandler(gameManager);
|
|
||||||
});
|
|
||||||
|
|
||||||
const triggerMessage = (ws: MockElysiaWS, message: string) => {
|
|
||||||
if (ws._messageCallback) {
|
|
||||||
ws._messageCallback(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerClose = (ws: MockElysiaWS) => {
|
|
||||||
if (ws._closeCallback) {
|
|
||||||
ws._closeCallback();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerError = (ws: MockElysiaWS, error: Error) => {
|
|
||||||
if (ws._errorCallback) {
|
|
||||||
ws._errorCallback(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createNewMockWs = (): MockElysiaWS => ({
|
|
||||||
send: mock(() => {}),
|
|
||||||
close: mock(() => {}),
|
|
||||||
on: mock((event: string, callback: (...args: any[]) => void) => {
|
|
||||||
if (event === 'message')
|
|
||||||
(createNewMockWs() as any)._messageCallback = callback;
|
|
||||||
if (event === 'close')
|
|
||||||
(createNewMockWs() as any)._closeCallback = callback;
|
|
||||||
if (event === 'error')
|
|
||||||
(createNewMockWs() as any)._errorCallback = callback;
|
|
||||||
}),
|
|
||||||
_messageCallback: null,
|
|
||||||
_closeCallback: null,
|
|
||||||
_errorCallback: null,
|
|
||||||
data: { query: {} },
|
|
||||||
binaryType: 'blob',
|
|
||||||
bufferedAmount: 0,
|
|
||||||
extensions: '',
|
|
||||||
onclose: null,
|
|
||||||
onerror: null,
|
|
||||||
onmessage: null,
|
|
||||||
onopen: null,
|
|
||||||
protocol: '',
|
|
||||||
readyState: 1,
|
|
||||||
url: '',
|
|
||||||
CLOSED: 3,
|
|
||||||
CONNECTING: 0,
|
|
||||||
OPEN: 1,
|
|
||||||
CLOSING: 2,
|
|
||||||
dispatchEvent: mock(() => {}),
|
|
||||||
addEventListener: mock(() => {}),
|
|
||||||
removeEventListener: mock(() => {}),
|
|
||||||
ping: mock(() => {}),
|
|
||||||
pong: mock(() => {}),
|
|
||||||
subscribe: mock(() => {}),
|
|
||||||
unsubscribe: mock(() => {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should register a new connection', () => {
|
|
||||||
mockWs.data.gameId = 'test-game';
|
|
||||||
mockWs.data.playerId = 'player-alpha';
|
|
||||||
mockWs.data.query.gameId = 'test-game';
|
|
||||||
mockWs.data.query.playerId = 'player-alpha';
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
expect((webSocketHandler as any).connections.get('test-game')).toContain(
|
|
||||||
mockWs,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should process a join_game message for an already connected client', () => {
|
|
||||||
const gameId = gameManager.createGame().id;
|
|
||||||
mockWs.data.query.gameId = gameId;
|
|
||||||
mockWs.data.query.playerId = 'player1';
|
|
||||||
mockWs.data.gameId = gameId;
|
|
||||||
mockWs.data.playerId = 'player1';
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
const joinGameMessage = JSON.stringify({
|
|
||||||
type: 'join_game',
|
|
||||||
gameId: gameId,
|
|
||||||
playerId: 'player1',
|
|
||||||
});
|
|
||||||
triggerMessage(mockWs, joinGameMessage);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="game-board"'),
|
|
||||||
);
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="player-info"'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle a make_move message and broadcast HTML updates', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
game.addPlayer('player1');
|
|
||||||
game.addPlayer('player2');
|
|
||||||
game.currentPlayer = 'black';
|
|
||||||
|
|
||||||
mockWs.data.gameId = game.id;
|
|
||||||
mockWs.data.playerId = 'player1';
|
|
||||||
mockWs.data.query.gameId = game.id;
|
|
||||||
mockWs.data.query.playerId = 'player1';
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
|
|
||||||
const makeMoveMessage = JSON.stringify({
|
|
||||||
type: 'make_move',
|
|
||||||
gameId: game.id,
|
|
||||||
playerId: 'player1',
|
|
||||||
row: 7,
|
|
||||||
col: 7,
|
|
||||||
});
|
|
||||||
triggerMessage(mockWs, makeMoveMessage);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="game-board"'),
|
|
||||||
);
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="player-info"'),
|
|
||||||
);
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="messages"'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(game.board[7][7]).toBe('black');
|
|
||||||
expect(game.currentPlayer).toBe('white');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send an error for an invalid move', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
game.addPlayer('player1');
|
|
||||||
game.addPlayer('player2');
|
|
||||||
game.currentPlayer = 'black';
|
|
||||||
|
|
||||||
mockWs.data.gameId = game.id;
|
|
||||||
mockWs.data.playerId = 'player1';
|
|
||||||
mockWs.data.query.gameId = game.id;
|
|
||||||
mockWs.data.query.playerId = 'player1';
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
|
|
||||||
const makeMoveMessage1 = JSON.stringify({
|
|
||||||
type: 'make_move',
|
|
||||||
gameId: game.id,
|
|
||||||
playerId: 'player1',
|
|
||||||
row: 7,
|
|
||||||
col: 7,
|
|
||||||
});
|
|
||||||
triggerMessage(mockWs, makeMoveMessage1);
|
|
||||||
mockWs.send.mockClear();
|
|
||||||
|
|
||||||
triggerMessage(mockWs, makeMoveMessage1);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle ping/pong messages', () => {
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
const pingMessage = JSON.stringify({ type: 'ping' });
|
|
||||||
triggerMessage(mockWs, pingMessage);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle player disconnection and notify others', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
const player1Ws = createNewMockWs();
|
|
||||||
const player2Ws = createNewMockWs();
|
|
||||||
|
|
||||||
player1Ws.data.gameId = game.id;
|
|
||||||
player1Ws.data.playerId = 'player1';
|
|
||||||
player1Ws.data.query.gameId = game.id;
|
|
||||||
player1Ws.data.query.playerId = 'player1';
|
|
||||||
|
|
||||||
player2Ws.data.gameId = game.id;
|
|
||||||
player2Ws.data.playerId = 'player2';
|
|
||||||
player2Ws.data.query.gameId = game.id;
|
|
||||||
player2Ws.data.query.playerId = 'player2';
|
|
||||||
|
|
||||||
webSocketHandler.handleConnection(player1Ws);
|
|
||||||
webSocketHandler.handleConnection(player2Ws);
|
|
||||||
|
|
||||||
player1Ws.send.mockClear();
|
|
||||||
player2Ws.send.mockClear();
|
|
||||||
|
|
||||||
webSocketHandler.handleDisconnect(player2Ws);
|
|
||||||
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="player-info"'),
|
|
||||||
);
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="messages"'),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
player1Ws.send.mock.calls
|
|
||||||
.flat()
|
|
||||||
.some((call) => (call as string).includes('player2 disconnected')),
|
|
||||||
).toBeTrue();
|
|
||||||
expect((webSocketHandler as any).connections.get(game.id)).not.toContain(
|
|
||||||
player2Ws,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send error for unknown message type', () => {
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
|
|
||||||
triggerMessage(mockWs, unknownMessage);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send error for invalid JSON message', () => {
|
|
||||||
webSocketHandler.handleConnection(mockWs);
|
|
||||||
const invalidJsonMessage = 'not a json';
|
|
||||||
triggerMessage(mockWs, invalidJsonMessage);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(
|
|
||||||
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should broadcast game state to a specific client when targetWs is provided', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
game.addPlayer('player1');
|
|
||||||
game.addPlayer('player2');
|
|
||||||
game.currentPlayer = 'black';
|
|
||||||
|
|
||||||
const player1Ws = createNewMockWs();
|
|
||||||
player1Ws.data.gameId = game.id;
|
|
||||||
player1Ws.data.playerId = 'player1';
|
|
||||||
player1Ws.data.query.gameId = game.id;
|
|
||||||
player1Ws.data.query.playerId = 'player1';
|
|
||||||
webSocketHandler.handleConnection(player1Ws);
|
|
||||||
|
|
||||||
const player2Ws = createNewMockWs();
|
|
||||||
player2Ws.data.gameId = game.id;
|
|
||||||
player2Ws.data.playerId = 'player2';
|
|
||||||
player2Ws.data.query.gameId = game.id;
|
|
||||||
player2Ws.data.query.playerId = 'player2';
|
|
||||||
webSocketHandler.handleConnection(player2Ws);
|
|
||||||
|
|
||||||
player1Ws.send.mockClear();
|
|
||||||
player2Ws.send.mockClear();
|
|
||||||
|
|
||||||
webSocketHandler.broadcastGameUpdate(game.id, game, null, null, player1Ws);
|
|
||||||
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="game-board"'),
|
|
||||||
);
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="player-info"'),
|
|
||||||
);
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="messages"'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(player2Ws.send).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should broadcast game state to all clients if targetWs is not provided', () => {
|
|
||||||
const game = gameManager.createGame();
|
|
||||||
game.addPlayer('player1');
|
|
||||||
game.addPlayer('player2');
|
|
||||||
game.currentPlayer = 'black';
|
|
||||||
|
|
||||||
const player1Ws = createNewMockWs();
|
|
||||||
player1Ws.data.gameId = game.id;
|
|
||||||
player1Ws.data.playerId = 'player1';
|
|
||||||
player1Ws.data.query.gameId = game.id;
|
|
||||||
player1Ws.data.query.playerId = 'player1';
|
|
||||||
webSocketHandler.handleConnection(player1Ws);
|
|
||||||
|
|
||||||
const player2Ws = createNewMockWs();
|
|
||||||
player2Ws.data.gameId = game.id;
|
|
||||||
player2Ws.data.playerId = 'player2';
|
|
||||||
player2Ws.data.query.gameId = game.id;
|
|
||||||
player2Ws.data.query.playerId = 'player2';
|
|
||||||
webSocketHandler.handleConnection(player2Ws);
|
|
||||||
|
|
||||||
player1Ws.send.mockClear();
|
|
||||||
player2Ws.send.mockClear();
|
|
||||||
|
|
||||||
webSocketHandler.broadcastGameUpdate(game.id, game);
|
|
||||||
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="game-board"'),
|
|
||||||
);
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="player-info"'),
|
|
||||||
);
|
|
||||||
expect(player1Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="messages"'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(player2Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="game-board"'),
|
|
||||||
);
|
|
||||||
expect(player2Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="player-info"'),
|
|
||||||
);
|
|
||||||
expect(player2Ws.send).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('<div id="messages"'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ElysiaWS } from 'elysia/dist/ws';
|
||||||
import { GameInstance } from './GameInstance';
|
import { GameInstance } from './GameInstance';
|
||||||
import {
|
import {
|
||||||
renderGameBoardHtml,
|
renderGameBoardHtml,
|
||||||
|
@ -11,8 +12,9 @@ interface MakeMoveMessage {
|
||||||
col: number;
|
col: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WS = ElysiaWS<{query: {playerId: string, gameId: string}}>;
|
||||||
export class WebSocketHandler {
|
export class WebSocketHandler {
|
||||||
private connections: Map<string, Array<any>>; // Use 'any' for the specific Elysia WS object for now
|
private connections: Map<string, Array<WS>>;
|
||||||
private games: Map<string, GameInstance>;
|
private games: Map<string, GameInstance>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -20,31 +22,28 @@ export class WebSocketHandler {
|
||||||
this.games = new Map();
|
this.games = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleConnection(ws: any, gameId: string, playerId: string): void {
|
public handleConnection(ws: WS): void {
|
||||||
|
const {gameId, playerId} = ws.data.query;
|
||||||
|
|
||||||
if (!this.connections.has(gameId)) {
|
if (!this.connections.has(gameId)) {
|
||||||
this.connections.set(gameId, []);
|
this.connections.set(gameId, []);
|
||||||
}
|
}
|
||||||
ws.data.playerId = playerId;
|
|
||||||
ws.data.gameId = gameId;
|
|
||||||
this.connections.get(gameId)?.push(ws);
|
this.connections.get(gameId)?.push(ws);
|
||||||
|
|
||||||
|
const game = this.getGame(gameId);
|
||||||
|
if (game) {
|
||||||
|
this.broadcastGameState(game.id);
|
||||||
|
} else {
|
||||||
|
ws.send('Error: game not found');
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`WebSocket connected, registered for Game ${gameId} as Player ${playerId}`,
|
`WebSocket connected, registered for Game ${gameId} as Player ${playerId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleError(ws: any, error: Error): void {
|
public handleMessage(ws: WS, message: any): void {
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
if (ws) {
|
|
||||||
this.sendMessage(
|
|
||||||
ws.data.gameId,
|
|
||||||
'Error: server-side WebSocket error',
|
|
||||||
ws,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleMessage(ws: any, message: any): void {
|
|
||||||
const type: string = message.type;
|
const type: string = message.type;
|
||||||
// Someday we might have other message types
|
// Someday we might have other message types
|
||||||
if (type === 'make_move') {
|
if (type === 'make_move') {
|
||||||
|
@ -52,24 +51,22 @@ export class WebSocketHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMakeMove(ws: any, message: MakeMoveMessage): void {
|
private handleMakeMove(ws: WS, message: MakeMoveMessage): void {
|
||||||
const { row, col } = message;
|
const { row, col } = message;
|
||||||
const gameId = ws.data.gameId;
|
const {gameId, playerId} = ws.data.query;
|
||||||
const playerId = ws.data.playerId;
|
|
||||||
console.log(`Handling make_move message in game ${gameId} from player ${playerId}: ${{message}}`);
|
console.log(`Handling make_move message in game ${gameId} from player ${playerId}: ${{message}}`);
|
||||||
|
|
||||||
if (!gameId || !playerId || row === undefined || col === undefined) {
|
if (!gameId || !playerId || row === undefined || col === undefined) {
|
||||||
this.sendMessage(
|
this.sendMessage(
|
||||||
gameId,
|
|
||||||
'Error: missing gameId, playerId, row, or col',
|
|
||||||
ws,
|
ws,
|
||||||
|
'Error: missing gameId, playerId, row, or col',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const game = this.games.get(gameId);
|
const game = this.games.get(gameId);
|
||||||
if (!game) {
|
if (!game) {
|
||||||
this.sendMessage(gameId, 'Error: game not found', ws);
|
this.sendMessage(ws, 'Error: game not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,15 +75,14 @@ export class WebSocketHandler {
|
||||||
)?.[0] as ('black' | 'white') | undefined;
|
)?.[0] as ('black' | 'white') | undefined;
|
||||||
if (!playerColor) {
|
if (!playerColor) {
|
||||||
this.sendMessage(
|
this.sendMessage(
|
||||||
gameId,
|
|
||||||
'Error: you are not a player in this game',
|
|
||||||
ws,
|
ws,
|
||||||
|
'Error: you are not a player in this game',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (game.currentPlayer !== playerColor) {
|
if (game.currentPlayer !== playerColor) {
|
||||||
this.sendMessage(gameId, 'Error: It\'s not your turn', ws);
|
this.sendMessage(ws, "Error: It's not your turn");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,20 +94,21 @@ export class WebSocketHandler {
|
||||||
`Move made in game ${game.id} by ${playerId}: (${row}, ${col})`,
|
`Move made in game ${game.id} by ${playerId}: (${row}, ${col})`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.sendMessage(gameId, result.error || 'Error: invalid move', ws);
|
this.sendMessage(ws, result.error || 'Error: invalid move');
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.sendMessage(gameId, 'Error: ' + e.message, ws);
|
this.sendMessage(ws, 'Error: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleDisconnect(ws: any): void {
|
public handleDisconnect(ws: WS): void {
|
||||||
const gameId = ws.data.gameId;
|
const {gameId, playerId} = ws.data.query;
|
||||||
const playerId = ws.data.playerId;
|
|
||||||
|
|
||||||
if (gameId && playerId) {
|
|
||||||
const connectionsInGame = this.connections.get(gameId);
|
const connectionsInGame = this.connections.get(gameId);
|
||||||
if (connectionsInGame) {
|
if (!connectionsInGame) {
|
||||||
|
console.error(`Disconnecting WebSocket for player ${playerId} from game ${gameId}, but that game has no connections!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.connections.set(
|
this.connections.set(
|
||||||
gameId,
|
gameId,
|
||||||
connectionsInGame.filter((conn) => conn !== ws),
|
connectionsInGame.filter((conn) => conn !== ws),
|
||||||
|
@ -119,15 +116,13 @@ export class WebSocketHandler {
|
||||||
if (this.connections.get(gameId)?.length === 0) {
|
if (this.connections.get(gameId)?.length === 0) {
|
||||||
this.connections.delete(gameId);
|
this.connections.delete(gameId);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (this.connections.has(gameId)) {
|
if (this.connections.has(gameId)) {
|
||||||
// Notify remaining players about disconnect
|
// Notify remaining players about disconnect
|
||||||
this.sendMessage(gameId, 'message', `${playerId} disconnected.`);
|
this.sendMessageToGame(gameId, `${playerId} disconnected.`);
|
||||||
}
|
}
|
||||||
console.log(`${playerId} disconnected from game ${gameId}`);
|
console.log(`${playerId} disconnected from game ${gameId}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public broadcastGameState(gameId: string): void {
|
public broadcastGameState(gameId: string): void {
|
||||||
const game = this.games.get(gameId);
|
const game = this.games.get(gameId);
|
||||||
|
@ -139,36 +134,33 @@ export class WebSocketHandler {
|
||||||
const connectionsToUpdate = this.connections.get(gameId);
|
const connectionsToUpdate = this.connections.get(gameId);
|
||||||
if (connectionsToUpdate) {
|
if (connectionsToUpdate) {
|
||||||
connectionsToUpdate.forEach((ws) => {
|
connectionsToUpdate.forEach((ws) => {
|
||||||
if (!ws.data.playerId) {
|
const {gameId, playerId} = ws.data.query;
|
||||||
console.warn('WebSocket without playerId in game for update', gameId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedBoardHtml = renderGameBoardHtml(game, ws.data.playerId);
|
const updatedBoardHtml = renderGameBoardHtml(game, playerId);
|
||||||
ws.send(updatedBoardHtml);
|
ws.send(updatedBoardHtml);
|
||||||
const updatedPlayerInfoHtml = renderPlayerInfoHtml(
|
const updatedPlayerInfoHtml = renderPlayerInfoHtml(
|
||||||
game.id,
|
game.id,
|
||||||
ws.data.playerId,
|
playerId,
|
||||||
);
|
);
|
||||||
ws.send(updatedPlayerInfoHtml);
|
ws.send(updatedPlayerInfoHtml);
|
||||||
|
|
||||||
if (game.status === 'finished') {
|
if (game.status === 'finished') {
|
||||||
if (game.winner === 'draw') {
|
if (game.winner === 'draw') {
|
||||||
this.sendMessage(gameId, 'Game ended in draw.');
|
this.sendMessageToGame(gameId, 'Game ended in draw.');
|
||||||
} else if (game.winner) {
|
} else if (game.winner) {
|
||||||
this.sendMessage(gameId, `${game.winner.toUpperCase()} wins!`);
|
this.sendMessageToGame(gameId, `${game.winner.toUpperCase()} wins!`);
|
||||||
}
|
}
|
||||||
} else if (game.status === 'playing') {
|
} else if (game.status === 'playing') {
|
||||||
const clientPlayerColor = Object.entries(game.players).find(
|
const clientPlayerColor = Object.entries(game.players).find(
|
||||||
([_, id]) => id === ws.data.playerId,
|
([_, id]) => id === playerId,
|
||||||
)?.[0] as ('black' | 'white') | undefined;
|
)?.[0] as ('black' | 'white') | undefined;
|
||||||
if (game.currentPlayer && clientPlayerColor === game.currentPlayer) {
|
if (game.currentPlayer && clientPlayerColor === game.currentPlayer) {
|
||||||
this.sendMessage(gameId, "It's your turn!", ws);
|
this.sendMessage(ws, "It's your turn!");
|
||||||
} else if (game.currentPlayer) {
|
} else if (game.currentPlayer) {
|
||||||
this.sendMessage(gameId, `Waiting for ${game.currentPlayer}'s move.`, ws);
|
this.sendMessage(ws, `Waiting for ${game.currentPlayer}'s move.`);
|
||||||
}
|
}
|
||||||
} else if (game.status === 'waiting') {
|
} else if (game.status === 'waiting') {
|
||||||
this.sendMessage(gameId, 'Waiting for another player...', ws);
|
this.sendMessage(ws, 'Waiting for another player...');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
@ -176,12 +168,11 @@ export class WebSocketHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendMessage(
|
public sendMessageToGame(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
message: string,
|
message: string,
|
||||||
targetWs?: any,
|
|
||||||
): void {
|
): void {
|
||||||
const connections = targetWs ? [targetWs] : this.connections.get(gameId);
|
const connections = this.connections.get(gameId);
|
||||||
if (connections) {
|
if (connections) {
|
||||||
connections.forEach((ws) => {
|
connections.forEach((ws) => {
|
||||||
ws.send('<div id="messages">' + message + '</div>')
|
ws.send('<div id="messages">' + message + '</div>')
|
||||||
|
@ -189,6 +180,13 @@ export class WebSocketHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sendMessage(
|
||||||
|
targetWs: WS,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
targetWs.send('<div id="messages">' + message + '</div>')
|
||||||
|
}
|
||||||
|
|
||||||
public getGame(gameId: string): GameInstance | undefined {
|
public getGame(gameId: string): GameInstance | undefined {
|
||||||
return this.games.get(gameId)
|
return this.games.get(gameId)
|
||||||
}
|
}
|
||||||
|
|
45
src/index.ts
45
src/index.ts
|
@ -4,7 +4,6 @@ import { cookie } from '@elysiajs/cookie';
|
||||||
import { WebSocketHandler } from './game/WebSocketHandler';
|
import { WebSocketHandler } from './game/WebSocketHandler';
|
||||||
import { GameInstance } from './game/GameInstance';
|
import { GameInstance } from './game/GameInstance';
|
||||||
|
|
||||||
// Initialize WebSocketHandler
|
|
||||||
const wsHandler = new WebSocketHandler();
|
const wsHandler = new WebSocketHandler();
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
@ -30,48 +29,16 @@ const app = new Elysia()
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wsHandler.handleConnection(ws);
|
||||||
wsHandler.handleConnection(ws, gameId, playerId);
|
|
||||||
|
|
||||||
const game = wsHandler.getGame(gameId);
|
|
||||||
if (game) {
|
|
||||||
wsHandler.broadcastGameState(game.id);
|
|
||||||
let message = '';
|
|
||||||
if (game.getPlayerCount() === 2 && game.status === 'playing') {
|
|
||||||
message = `${game.currentPlayer}'s turn.`;
|
|
||||||
} else if (game.getPlayerCount() === 1 && game.status === 'waiting') {
|
|
||||||
message = `You are ${playerId}. Waiting for another player to join.`;
|
|
||||||
}
|
|
||||||
wsHandler.sendMessage(game.id, message, ws);
|
|
||||||
} else {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: 'error',
|
|
||||||
error: 'Game not found after WebSocket connection.',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
console.log(`WebSocket connected: Player ${playerId} for Game ${gameId}`);
|
|
||||||
},
|
},
|
||||||
message(ws, message) {
|
message(ws, message) {
|
||||||
let msgString: string;
|
wsHandler.handleMessage(ws, message);
|
||||||
if (message instanceof Buffer) {
|
|
||||||
msgString = message.toString();
|
|
||||||
} else {
|
|
||||||
// Assuming it's always a stringified JSON for 'make_move'
|
|
||||||
msgString = message as string;
|
|
||||||
}
|
|
||||||
wsHandler.handleMessage(ws, msgString);
|
|
||||||
},
|
},
|
||||||
close(ws) {
|
close(ws) {
|
||||||
wsHandler.handleDisconnect(ws);
|
wsHandler.handleDisconnect(ws);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.get('/', async ({ query, cookie, request }) => {
|
.get('/', async ({ query, cookie, request: _request }) => {
|
||||||
const htmlTemplate = await Bun.file('/home/sepia/gomoku/index.html').text();
|
|
||||||
const urlGameId = query.gameId as string | undefined;
|
|
||||||
|
|
||||||
let playerId: string;
|
let playerId: string;
|
||||||
const existingPlayerId = cookie.playerId?.value;
|
const existingPlayerId = cookie.playerId?.value;
|
||||||
if (existingPlayerId) {
|
if (existingPlayerId) {
|
||||||
|
@ -88,6 +55,7 @@ const app = new Elysia()
|
||||||
console.log(`Generated new playerId and set cookie: ${playerId}`);
|
console.log(`Generated new playerId and set cookie: ${playerId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlGameId = query.gameId as string | undefined;
|
||||||
let game: GameInstance;
|
let game: GameInstance;
|
||||||
if (urlGameId) {
|
if (urlGameId) {
|
||||||
let existingGame = wsHandler.getGame(urlGameId);
|
let existingGame = wsHandler.getGame(urlGameId);
|
||||||
|
@ -106,6 +74,7 @@ const app = new Elysia()
|
||||||
game.addPlayer(playerId);
|
game.addPlayer(playerId);
|
||||||
wsHandler.broadcastGameState(game.id);
|
wsHandler.broadcastGameState(game.id);
|
||||||
|
|
||||||
|
const htmlTemplate = await Bun.file('./index.html').text();
|
||||||
let finalHtml = htmlTemplate
|
let finalHtml = htmlTemplate
|
||||||
.replace(
|
.replace(
|
||||||
'<meta name="gameId" content="" />',
|
'<meta name="gameId" content="" />',
|
||||||
|
@ -114,10 +83,6 @@ const app = new Elysia()
|
||||||
.replace(
|
.replace(
|
||||||
'<meta name="playerId" content="" />',
|
'<meta name="playerId" content="" />',
|
||||||
`<meta name="playerId" content="${playerId}" />`,
|
`<meta name="playerId" content="${playerId}" />`,
|
||||||
)
|
|
||||||
.replace(
|
|
||||||
`<div id="game-link"></div>`,
|
|
||||||
`<div id="game-link">${request.url.split('?')[0]}?gameId=${game.id}</div>`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(finalHtml, {
|
return new Response(finalHtml, {
|
||||||
|
|
Loading…
Reference in New Issue