init
This commit is contained in:
commit
c15c9c16c8
15 changed files with 1601 additions and 0 deletions
138
src/game/GameInstance.test.ts
Normal file
138
src/game/GameInstance.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { GameInstance } from './GameInstance';
|
||||
import { expect, test, describe } from 'bun:test';
|
||||
|
||||
describe('GameInstance', () => {
|
||||
test('should initialize with correct default state', () => {
|
||||
const game = new GameInstance();
|
||||
|
||||
expect(game.id).toBeDefined();
|
||||
expect(game.board.length).toBe(15);
|
||||
expect(game.board[0].length).toBe(15);
|
||||
expect(game.currentPlayer).toBeNull();
|
||||
expect(game.status).toBe('waiting');
|
||||
expect(game.winner).toBeNull();
|
||||
expect(game.players).toEqual({});
|
||||
});
|
||||
|
||||
test('should add players correctly', () => {
|
||||
const game = new GameInstance();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
// First player joins as black
|
||||
const joined1 = game.addPlayer(player1);
|
||||
expect(joined1).toBe(true);
|
||||
|
||||
// Second player joins as white
|
||||
const joined2 = game.addPlayer(player2);
|
||||
expect(joined2).toBe(true);
|
||||
|
||||
// Game should now be in playing state
|
||||
expect(game.status).toBe('playing');
|
||||
expect(game.currentPlayer).toBe('black');
|
||||
expect(game.players.black).toBe(player1);
|
||||
expect(game.players.white).toBe(player2);
|
||||
});
|
||||
|
||||
test('should prevent more than two players from joining', () => {
|
||||
const game = new GameInstance();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
const player3 = 'player3-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Third player tries to join (should fail)
|
||||
const joined = game.addPlayer(player3);
|
||||
expect(joined).toBe(false);
|
||||
|
||||
// Players object should remain unchanged, only two players should be present
|
||||
expect(game.players.black).toBe(player1);
|
||||
expect(game.players.white).toBe(player2);
|
||||
expect(Object.values(game.players).length).toBe(2);
|
||||
});
|
||||
|
||||
test('should validate moves correctly', () => {
|
||||
const game = new GameInstance();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Player black makes first move
|
||||
const move1 = game.makeMove(player1, 7, 7);
|
||||
expect(move1.success).toBe(true);
|
||||
|
||||
// Same player tries to move again (should fail)
|
||||
const move2 = game.makeMove(player1, 7, 8);
|
||||
expect(move2.success).toBe(false);
|
||||
|
||||
// White player makes a move
|
||||
const move3 = game.makeMove(player2, 7, 8);
|
||||
expect(move3.success).toBe(true);
|
||||
|
||||
// Try to place on occupied cell (should fail)
|
||||
const move4 = game.makeMove(player2, 7, 7);
|
||||
expect(move4.success).toBe(false);
|
||||
|
||||
// Try to place out of bounds (should fail)
|
||||
const move5 = game.makeMove(player2, 15, 15);
|
||||
expect(move5.success).toBe(false);
|
||||
});
|
||||
|
||||
test('should detect win conditions', () => {
|
||||
const game = new GameInstance();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Create a horizontal win for black
|
||||
for (let col = 0; col < 5; col++) {
|
||||
game.makeMove(player1, 7, col);
|
||||
// Switch to other player for next move
|
||||
if (col < 4) game.makeMove(player2, 8, col);
|
||||
}
|
||||
|
||||
expect(game.winner).toBe('black');
|
||||
expect(game.status).toBe('finished');
|
||||
});
|
||||
|
||||
test('should detect draw condition', () => {
|
||||
const game = new GameInstance();
|
||||
|
||||
const player1 = 'player1-uuid';
|
||||
const player2 = 'player2-uuid';
|
||||
|
||||
game.addPlayer(player1);
|
||||
game.addPlayer(player2);
|
||||
|
||||
// Create a pattern that doesn't result in a win but fills the board
|
||||
// We'll use a simple alternating pattern
|
||||
for (let row = 0; row < 15; row++) {
|
||||
for (let col = 0; col < 15; col++) {
|
||||
const currentPlayer = game.currentPlayer!;
|
||||
const playerId = game.players[currentPlayer]!;
|
||||
|
||||
// Make move
|
||||
const result = game.makeMove(playerId, row, col);
|
||||
|
||||
// If we can't make a move, it means someone won already
|
||||
if (!result.success) {
|
||||
expect(game.winner).not.toBeNull();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(game.winner).toBe('draw');
|
||||
expect(game.status).toBe('finished');
|
||||
});
|
||||
});
|
||||
176
src/game/GameInstance.ts
Normal file
176
src/game/GameInstance.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type PlayerColor = 'black' | 'white';
|
||||
type GameStatus = 'waiting' | 'playing' | 'finished';
|
||||
type BoardCell = null | 'black' | 'white';
|
||||
|
||||
export class GameInstance {
|
||||
public readonly id: string;
|
||||
public readonly board: BoardCell[][];
|
||||
public currentPlayer: PlayerColor | null;
|
||||
public status: GameStatus;
|
||||
public winner: null | PlayerColor | 'draw';
|
||||
public players: { black?: string; white?: string };
|
||||
|
||||
private readonly boardSize = 15;
|
||||
private moveCount = 0;
|
||||
|
||||
constructor() {
|
||||
this.id = uuidv4();
|
||||
this.board = Array.from({ length: this.boardSize }, () =>
|
||||
Array(this.boardSize).fill(null),
|
||||
);
|
||||
this.currentPlayer = null;
|
||||
this.status = 'waiting';
|
||||
this.winner = null;
|
||||
this.players = {};
|
||||
}
|
||||
|
||||
public getPlayerCount(): number {
|
||||
return Object.values(this.players).filter(Boolean).length;
|
||||
}
|
||||
|
||||
public addPlayer(playerId: string): boolean {
|
||||
// If game is full, prevent new players from joining.
|
||||
if (this.getPlayerCount() >= 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If player is already in the game, return true.
|
||||
if (Object.values(this.players).includes(playerId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Assign black if available, otherwise white
|
||||
if (!this.players.black) {
|
||||
this.players.black = playerId;
|
||||
} else if (!this.players.white) {
|
||||
this.players.white = playerId;
|
||||
} else {
|
||||
return false; // Should not happen if getPlayerCount() check is correct
|
||||
}
|
||||
|
||||
// If both players have joined, start the game.
|
||||
if (this.players.black && this.players.white) {
|
||||
this.currentPlayer = 'black';
|
||||
this.status = 'playing';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public makeMove(
|
||||
playerId: string,
|
||||
row: number,
|
||||
col: number,
|
||||
): { success: boolean; error?: string } {
|
||||
// Find player's color
|
||||
let playerColor: PlayerColor | null = null;
|
||||
for (const [color, id] of Object.entries(this.players)) {
|
||||
if (id === playerId) {
|
||||
playerColor = color as PlayerColor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!playerColor) {
|
||||
return { success: false, error: 'Player not in this game' };
|
||||
}
|
||||
|
||||
// Validate it's the player's turn
|
||||
if (this.currentPlayer !== playerColor) {
|
||||
return { success: false, error: 'Not your turn' };
|
||||
}
|
||||
|
||||
// Validate move is within bounds
|
||||
if (row < 0 || row >= this.boardSize || col < 0 || col >= this.boardSize) {
|
||||
return { success: false, error: 'Move out of bounds' };
|
||||
}
|
||||
|
||||
// Validate cell is empty
|
||||
if (this.board[row][col] !== null) {
|
||||
return { success: false, error: 'Cell already occupied' };
|
||||
}
|
||||
|
||||
// Make the move
|
||||
this.board[row][col] = playerColor;
|
||||
this.moveCount++;
|
||||
|
||||
// Check for win condition
|
||||
if (this.checkWin(row, col, playerColor)) {
|
||||
this.winner = playerColor;
|
||||
this.status = 'finished';
|
||||
this.currentPlayer = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Check for draw condition
|
||||
if (this.moveCount === this.boardSize * this.boardSize) {
|
||||
this.winner = 'draw';
|
||||
this.status = 'finished';
|
||||
this.currentPlayer = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Switch turns
|
||||
this.currentPlayer = playerColor === 'black' ? 'white' : 'black';
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private checkWin(row: number, col: number, color: PlayerColor): boolean {
|
||||
const directions = [
|
||||
[1, 0], // vertical
|
||||
[0, 1], // horizontal
|
||||
[1, 1], // diagonal down-right
|
||||
[1, -1], // diagonal down-left
|
||||
];
|
||||
|
||||
for (const [dx, dy] of directions) {
|
||||
let count = 1;
|
||||
|
||||
// Check in positive direction
|
||||
for (let i = 1; i < 5; i++) {
|
||||
const newRow = row + dx * i;
|
||||
const newCol = col + dy * i;
|
||||
if (
|
||||
newRow < 0 ||
|
||||
newRow >= this.boardSize ||
|
||||
newCol < 0 ||
|
||||
newCol >= this.boardSize
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (this.board[newRow][newCol] === color) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check in negative direction
|
||||
for (let i = 1; i < 5; i++) {
|
||||
const newRow = row - dx * i;
|
||||
const newCol = col - dy * i;
|
||||
if (
|
||||
newRow < 0 ||
|
||||
newRow >= this.boardSize ||
|
||||
newCol < 0 ||
|
||||
newCol >= this.boardSize
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (this.board[newRow][newCol] === color) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (count >= 5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
48
src/game/GameManager.test.ts
Normal file
48
src/game/GameManager.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
31
src/game/GameManager.ts
Normal file
31
src/game/GameManager.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { GameInstance } from './GameInstance';
|
||||
|
||||
export class GameManager {
|
||||
private games: Map<string, GameInstance>;
|
||||
|
||||
constructor() {
|
||||
this.games = new Map();
|
||||
}
|
||||
|
||||
createGame(): GameInstance {
|
||||
const game = new GameInstance();
|
||||
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);
|
||||
}
|
||||
}
|
||||
381
src/game/WebSocketHandler.test.ts
Normal file
381
src/game/WebSocketHandler.test.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
||||
import { WebSocketHandler } from './WebSocketHandler';
|
||||
import { GameManager } from './GameManager';
|
||||
import { GameInstance } from './GameInstance';
|
||||
|
||||
describe('WebSocketHandler', () => {
|
||||
let gameManager: GameManager;
|
||||
let webSocketHandler: WebSocketHandler;
|
||||
let mockWs: any;
|
||||
let mockWsData: { request: {}; gameId?: string; playerId?: string };
|
||||
|
||||
beforeEach(() => {
|
||||
gameManager = new GameManager();
|
||||
|
||||
mockWsData = { request: {} };
|
||||
|
||||
mockWs = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs._messageCallback = callback;
|
||||
if (event === 'close') mockWs._closeCallback = callback;
|
||||
if (event === 'error') mockWs._errorCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
_closeCallback: null,
|
||||
_errorCallback: null,
|
||||
data: mockWsData,
|
||||
};
|
||||
|
||||
webSocketHandler = new WebSocketHandler(gameManager);
|
||||
});
|
||||
|
||||
const triggerMessage = (message: string) => {
|
||||
if (mockWs._messageCallback) {
|
||||
mockWs._messageCallback(message);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerClose = () => {
|
||||
if (mockWs._closeCallback) {
|
||||
mockWs._closeCallback();
|
||||
}
|
||||
};
|
||||
|
||||
it('should handle a new connection', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
expect(mockWs.on).toHaveBeenCalledWith('message', expect.any(Function));
|
||||
expect(mockWs.on).toHaveBeenCalledWith('close', expect.any(Function));
|
||||
expect(mockWs.on).toHaveBeenCalledWith('error', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should handle a join_game message for a new game', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
const joinGameMessage = JSON.stringify({
|
||||
type: 'join_game',
|
||||
playerId: 'player1',
|
||||
});
|
||||
triggerMessage(joinGameMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('game_state'),
|
||||
);
|
||||
expect(mockWsData.gameId).toBeDefined();
|
||||
expect(mockWsData.playerId).toBe('player1');
|
||||
});
|
||||
|
||||
it('should handle a join_game message for an existing game', () => {
|
||||
const game = gameManager.createGame();
|
||||
gameManager.joinGame(game.id, 'player1');
|
||||
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
const joinGameMessage = JSON.stringify({
|
||||
type: 'join_game',
|
||||
gameId: game.id,
|
||||
playerId: 'player2',
|
||||
});
|
||||
triggerMessage(joinGameMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
expect.stringContaining('game_state'),
|
||||
);
|
||||
expect(mockWsData.gameId).toBe(game.id);
|
||||
expect(mockWsData.playerId).toBe('player2');
|
||||
});
|
||||
|
||||
it('should handle a make_move message', () => {
|
||||
const game = gameManager.createGame();
|
||||
gameManager.joinGame(game.id, 'player1');
|
||||
gameManager.joinGame(game.id, 'player2');
|
||||
|
||||
game.status = 'playing';
|
||||
|
||||
mockWsData.gameId = game.id;
|
||||
mockWsData.playerId = 'player1';
|
||||
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
const makeMoveMessage = JSON.stringify({
|
||||
type: 'make_move',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessage(makeMoveMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'move_result', success: true }),
|
||||
);
|
||||
expect(game.board[7][7]).toBe('black');
|
||||
});
|
||||
|
||||
it('should send an error for an invalid move', () => {
|
||||
const game = gameManager.createGame();
|
||||
gameManager.joinGame(game.id, 'player1');
|
||||
gameManager.joinGame(game.id, 'player2');
|
||||
|
||||
game.status = 'playing';
|
||||
|
||||
mockWsData.gameId = game.id;
|
||||
mockWsData.playerId = 'player1';
|
||||
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
|
||||
const makeMoveMessage1 = JSON.stringify({
|
||||
type: 'make_move',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessage(makeMoveMessage1);
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'move_result', success: true }),
|
||||
);
|
||||
|
||||
game.currentPlayer = 'black';
|
||||
const makeMoveMessage2 = JSON.stringify({
|
||||
type: 'make_move',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessage(makeMoveMessage2);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'error', error: 'Cell already occupied' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ping/pong messages', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
const pingMessage = JSON.stringify({ type: 'ping' });
|
||||
triggerMessage(pingMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify({ type: 'pong' }));
|
||||
});
|
||||
|
||||
it('should handle player disconnection', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
|
||||
mockWsData.gameId = 'test-game-id';
|
||||
mockWsData.playerId = 'test-player-id';
|
||||
|
||||
triggerClose();
|
||||
});
|
||||
|
||||
it('should send error for unknown message type', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
const unknownMessage = JSON.stringify({ type: 'unknown_type' });
|
||||
triggerMessage(unknownMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send error for invalid JSON message', () => {
|
||||
webSocketHandler.handleConnection(mockWs, mockWs.data.request);
|
||||
const invalidJsonMessage = 'not a json';
|
||||
triggerMessage(invalidJsonMessage);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
it('should notify other players and remove a disconnected player', () => {
|
||||
const gameManager = new GameManager();
|
||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
||||
|
||||
// Player 1
|
||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = { request: {} };
|
||||
const mockWs1: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs1._messageCallback = callback;
|
||||
if (event === 'close') mockWs1._closeCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
_closeCallback: null,
|
||||
data: mockWsData1,
|
||||
};
|
||||
|
||||
// Player 2
|
||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = { request: {} };
|
||||
const mockWs2: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs2._messageCallback = callback;
|
||||
if (event === 'close') mockWs2._closeCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
_closeCallback: null,
|
||||
data: mockWsData2,
|
||||
};
|
||||
|
||||
const triggerMessageForWs = (ws: any, message: string) => {
|
||||
if (ws._messageCallback) {
|
||||
ws._messageCallback(message);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerCloseForWs = (ws: any) => {
|
||||
if (ws._closeCallback) {
|
||||
ws._closeCallback();
|
||||
}
|
||||
};
|
||||
|
||||
// Player 1 joins, creates game
|
||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
||||
triggerMessageForWs(mockWs1, JSON.stringify({ type: 'join_game', playerId: 'player1' }));
|
||||
mockWs1.data.gameId = mockWsData1.gameId;
|
||||
mockWs1.data.playerId = 'player1';
|
||||
|
||||
// Player 2 joins same game
|
||||
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
|
||||
triggerMessageForWs(mockWs2, JSON.stringify({ type: 'join_game', gameId: mockWsData1.gameId, playerId: 'player2' }));
|
||||
mockWs2.data.gameId = mockWsData1.gameId;
|
||||
mockWs2.data.playerId = 'player2';
|
||||
|
||||
// Player 2 disconnects
|
||||
mockWs1.send.mockClear(); // Clear P1's send history before P2 disconnects
|
||||
triggerCloseForWs(mockWs2);
|
||||
|
||||
// Expect Player 1 to receive player_disconnected message
|
||||
expect(mockWs1.send).toHaveBeenCalledTimes(1);
|
||||
const receivedMessage = JSON.parse(mockWs1.send.mock.calls[0][0]);
|
||||
expect(receivedMessage.type).toBe('player_disconnected');
|
||||
expect(receivedMessage.playerId).toBe('player2');
|
||||
expect(receivedMessage.gameId).toBe(mockWsData1.gameId);
|
||||
|
||||
// Verify connections map is updated (Player 2 removed)
|
||||
// @ts-ignore
|
||||
expect(webSocketHandler.connections.get(mockWsData1.gameId)).toContain(mockWs1);
|
||||
// @ts-ignore
|
||||
expect(webSocketHandler.connections.get(mockWsData1.gameId)).not.toContain(mockWs2);
|
||||
});
|
||||
it('should broadcast game state to other players when a new player joins', () => {
|
||||
const gameManager = new GameManager();
|
||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
||||
|
||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = { request: {} };
|
||||
const mockWs1: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs1._messageCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
data: mockWsData1,
|
||||
};
|
||||
|
||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = { request: {} };
|
||||
const mockWs2: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs2._messageCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
data: mockWsData2,
|
||||
};
|
||||
|
||||
const triggerMessageForWs = (ws: any, message: string) => {
|
||||
if (ws._messageCallback) {
|
||||
ws._messageCallback(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Player 1 joins and creates a new game
|
||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
||||
const joinGameMessage1 = JSON.stringify({
|
||||
type: 'join_game',
|
||||
playerId: 'player1',
|
||||
});
|
||||
triggerMessageForWs(mockWs1, joinGameMessage1);
|
||||
const player1GameId = mockWsData1.gameId;
|
||||
|
||||
// Player 2 joins the same game
|
||||
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
|
||||
const joinGameMessage2 = JSON.stringify({
|
||||
type: 'join_game',
|
||||
gameId: player1GameId,
|
||||
playerId: 'player2',
|
||||
});
|
||||
triggerMessageForWs(mockWs2, joinGameMessage2);
|
||||
|
||||
// Check that Player 1 received the game_state update after Player 2 joined
|
||||
// Player 1 should have received two messages: initial join and then game_state after P2 joins
|
||||
expect(mockWs1.send).toHaveBeenCalledTimes(2);
|
||||
const secondCallArgs = mockWs1.send.mock.calls[1][0];
|
||||
const receivedMessage = JSON.parse(secondCallArgs);
|
||||
|
||||
expect(receivedMessage.type).toBe('game_state');
|
||||
expect(receivedMessage.state.players.black).toBe('player1');
|
||||
expect(receivedMessage.state.players.white).toBe('player2');
|
||||
});
|
||||
|
||||
it('should broadcast game state after a successful move', () => {
|
||||
const gameManager = new GameManager();
|
||||
const webSocketHandler = new WebSocketHandler(gameManager);
|
||||
|
||||
let mockWsData1: { request: {}; gameId?: string; playerId?: string } = { request: {} };
|
||||
const mockWs1: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs1._messageCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
data: mockWsData1,
|
||||
};
|
||||
|
||||
let mockWsData2: { request: {}; gameId?: string; playerId?: string } = { request: {} };
|
||||
const mockWs2: any = {
|
||||
send: mock(() => {}),
|
||||
on: mock((event: string, callback: Function) => {
|
||||
if (event === 'message') mockWs2._messageCallback = callback;
|
||||
}),
|
||||
_messageCallback: null,
|
||||
data: mockWsData2,
|
||||
};
|
||||
|
||||
const triggerMessageForWs = (ws: any, message: string) => {
|
||||
if (ws._messageCallback) {
|
||||
ws._messageCallback(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Player 1 joins and creates a new game
|
||||
webSocketHandler.handleConnection(mockWs1, mockWs1.data.request);
|
||||
const joinGameMessage1 = JSON.stringify({
|
||||
type: 'join_game',
|
||||
playerId: 'player1',
|
||||
});
|
||||
triggerMessageForWs(mockWs1, joinGameMessage1);
|
||||
const player1GameId = mockWsData1.gameId;
|
||||
mockWs1.data.gameId = player1GameId; // Manually set gameId for mockWs1
|
||||
mockWs1.data.playerId = 'player1'; // Manually set playerId for mockWs1
|
||||
|
||||
// Player 2 joins the same game
|
||||
webSocketHandler.handleConnection(mockWs2, mockWs2.data.request);
|
||||
const joinGameMessage2 = JSON.stringify({
|
||||
type: 'join_game',
|
||||
gameId: player1GameId,
|
||||
playerId: 'player2',
|
||||
});
|
||||
triggerMessageForWs(mockWs2, joinGameMessage2);
|
||||
mockWs2.data.gameId = player1GameId; // Manually set gameId for mockWs2
|
||||
mockWs2.data.playerId = 'player2'; // Manually set playerId for mockWs2
|
||||
|
||||
// Clear previous calls for clean assertion
|
||||
mockWs1.send.mockClear();
|
||||
mockWs2.send.mockClear();
|
||||
|
||||
// Player 1 makes a move
|
||||
const makeMoveMessage = JSON.stringify({
|
||||
type: 'make_move',
|
||||
row: 7,
|
||||
col: 7,
|
||||
});
|
||||
triggerMessageForWs(mockWs1, makeMoveMessage);
|
||||
|
||||
// Expect Player 2 to receive the game state update
|
||||
expect(mockWs2.send).toHaveBeenCalledTimes(1);
|
||||
const receivedMessage = JSON.parse(mockWs2.send.mock.calls[0][0]);
|
||||
expect(receivedMessage.type).toBe('game_state');
|
||||
expect(receivedMessage.state.board[7][7]).toBe('black');
|
||||
});
|
||||
232
src/game/WebSocketHandler.ts
Normal file
232
src/game/WebSocketHandler.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { GameManager } from './GameManager';
|
||||
import { GameInstance } from './GameInstance';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
gameId?: string;
|
||||
playerId?: string;
|
||||
row?: number;
|
||||
col?: number;
|
||||
state?: any; // GameState
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class WebSocketHandler {
|
||||
private gameManager: GameManager;
|
||||
|
||||
private connections: Map<string, Array<any>>; // Map of gameId to an array of connected websockets
|
||||
constructor(gameManager: GameManager) {
|
||||
this.gameManager = gameManager;
|
||||
this.connections = new Map();
|
||||
}
|
||||
|
||||
public handleConnection(ws: any, req: any): void {
|
||||
console.log('WebSocket connected');
|
||||
|
||||
ws.on('message', (message: string) => {
|
||||
this.handleMessage(ws, message);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.handleDisconnect(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error: Error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(ws: any, message: string): void {
|
||||
try {
|
||||
const parsedMessage: WebSocketMessage = JSON.parse(message);
|
||||
console.log('Received message:', parsedMessage);
|
||||
|
||||
switch (parsedMessage.type) {
|
||||
case 'join_game':
|
||||
this.handleJoinGame(ws, parsedMessage);
|
||||
break;
|
||||
case 'make_move':
|
||||
this.handleMakeMove(ws, parsedMessage);
|
||||
break;
|
||||
case 'ping':
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
break;
|
||||
default:
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'error', error: 'Unknown message type' }),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', message, error);
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'error', error: 'Invalid message format' }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleJoinGame(ws: any, message: WebSocketMessage): void {
|
||||
const { gameId, playerId } = message;
|
||||
if (!playerId) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'playerId is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let game: GameInstance | null = null;
|
||||
let isNewGame = false;
|
||||
|
||||
if (gameId) {
|
||||
game = this.gameManager.getGame(gameId);
|
||||
if (!game) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Game not found' }));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Create a new game if no gameId is provided
|
||||
game = this.gameManager.createGame();
|
||||
isNewGame = true;
|
||||
}
|
||||
|
||||
if (game && this.gameManager.joinGame(game.id, playerId)) {
|
||||
ws.data.gameId = game.id; // Store gameId on the WebSocket object
|
||||
ws.data.playerId = playerId; // Store playerId on the WebSocket object
|
||||
|
||||
if (!this.connections.has(game.id)) {
|
||||
this.connections.set(game.id, []);
|
||||
}
|
||||
this.connections.get(game.id)?.push(ws);
|
||||
|
||||
const gameStateMessage = JSON.stringify({
|
||||
type: 'game_state',
|
||||
state: {
|
||||
id: game.id,
|
||||
board: game.board,
|
||||
currentPlayer: game.currentPlayer,
|
||||
status: game.status,
|
||||
winner: game.winner,
|
||||
players: game.players,
|
||||
},
|
||||
});
|
||||
ws.send(gameStateMessage);
|
||||
// Notify other players if any
|
||||
this.connections.get(game.id)?.forEach((playerWs: any) => {
|
||||
if (playerWs !== ws) { // Don't send back to the player who just joined
|
||||
playerWs.send(gameStateMessage);
|
||||
}
|
||||
});
|
||||
console.log(`${playerId} joined game ${game.id}`);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Failed to join game' }));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMakeMove(ws: any, message: WebSocketMessage): void {
|
||||
const { row, col } = message;
|
||||
const gameId = ws.data.gameId;
|
||||
const playerId = ws.data.playerId;
|
||||
|
||||
if (!gameId || !playerId) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Not in a game' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const game = this.gameManager.getGame(gameId);
|
||||
if (!game) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Game not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (row === undefined || col === undefined) {
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'error', error: 'Invalid move coordinates' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerColor =
|
||||
game.players.black === playerId
|
||||
? 'black'
|
||||
: game.players.white === playerId
|
||||
? 'white'
|
||||
: null;
|
||||
if (!playerColor) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: 'You are not a player in this game',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.currentPlayer !== playerColor) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Not your turn' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = game.makeMove(playerId, row, col);
|
||||
ws.send(JSON.stringify({ type: 'move_result', success: result.success }));
|
||||
if (result.success) {
|
||||
// Broadcast updated game state to all players in the game
|
||||
this.broadcastGameState(gameId, game);
|
||||
console.log(
|
||||
`Move made in game ${gameId} by ${playerId}: (${row}, ${col})`,
|
||||
);
|
||||
} else {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
error: result.error || 'Invalid move',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: e.message }));
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect(ws: any): void {
|
||||
const gameId = ws.data.gameId;
|
||||
const playerId = ws.data.playerId;
|
||||
|
||||
if (gameId && playerId) {
|
||||
// Remove disconnected player's websocket from connections
|
||||
const connectionsInGame = this.connections.get(gameId);
|
||||
if (connectionsInGame) {
|
||||
this.connections.set(gameId, connectionsInGame.filter((conn: any) => conn !== ws));
|
||||
if (this.connections.get(gameId)?.length === 0) {
|
||||
this.connections.delete(gameId); // Clean up if no players left
|
||||
}
|
||||
}
|
||||
|
||||
// Notify other players
|
||||
if (this.connections.has(gameId)) {
|
||||
const disconnectMessage = JSON.stringify({
|
||||
type: 'player_disconnected',
|
||||
playerId: playerId,
|
||||
gameId: gameId,
|
||||
});
|
||||
this.connections.get(gameId)?.forEach((playerWs: any) => {
|
||||
playerWs.send(disconnectMessage);
|
||||
});
|
||||
}
|
||||
console.log(`${playerId} disconnected from game ${gameId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to send updated game state to all participants in a game
|
||||
// This would typically be called by GameManager when game state changes
|
||||
public broadcastGameState(gameId: string, state: any): void {
|
||||
const message = JSON.stringify({ type: 'game_state', state });
|
||||
const connectionsInGame = this.connections.get(gameId);
|
||||
|
||||
if (connectionsInGame) {
|
||||
connectionsInGame.forEach((ws: any) => {
|
||||
ws.send(message);
|
||||
});
|
||||
}
|
||||
console.log(`Broadcasting game state for ${gameId}:`, state);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue