This commit is contained in:
sepia 2025-07-15 12:13:17 -05:00
commit c15c9c16c8
15 changed files with 1601 additions and 0 deletions

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
**/*.trace
**/*.zip
**/*.tar.gz
**/*.tgz
**/*.log
package-lock.json
**/*.bun

5
.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"tabWidth": 2,
"semi": true,
"singleQuote": true
}

341
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,341 @@
# Gomoku Game Design Document
## 1. Overview
A simple turn-based multiplayer Gomoku (Five-in-a-Row) web game for casual play between friends. The system prioritizes simplicity and ease of deployment over scalability.
## 2. Tech Stack
- **Backend:** Elysia (Bun runtime)
- **Frontend:** HTML/CSS/TypeScript + HTMX
- **Real-time communication:** WebSockets (Elysia built-in)
- **Database:** SQLite with Bun:sqlite
## 3. System Requirements
### 3.1 Functional Requirements
**FR1: Game Creation**
- The system SHALL allow players to create new game sessions
- The system SHALL generate unique game IDs for each session
- The system SHALL support maximum 2 players per game
**FR2: Player Connection**
- The system SHALL establish WebSocket connections for real-time communication
- The system SHALL handle player disconnection gracefully
- The system SHALL allow players to reconnect to existing games
**FR3: Game Logic**
- The system SHALL implement standard Gomoku rules (15x15 board)
- The system SHALL detect win conditions (5 stones in a row: horizontal, vertical, diagonal)
- The system SHALL validate moves (prevent placing stones on occupied positions)
- The system SHALL enforce turn-based play
**FR4: Real-time Updates**
- The system SHALL broadcast moves to all connected players immediately
- The system SHALL notify players of game state changes (turn, win, draw)
- The system SHALL update UI in real-time without page refresh
### 3.2 Non-Functional Requirements
**NFR1: Performance**
- The system SHALL respond to moves within 100ms
- The system SHALL support concurrent games (minimum 10 simultaneous games)
**NFR2: Usability**
- The system SHALL provide intuitive click-to-place stone interaction
- The system SHALL display current turn indicator
- The system SHALL show game status (waiting, playing, finished)
**NFR3: Reliability**
- The system SHALL maintain game state during temporary disconnections
- The system SHALL prevent cheating through client-side validation bypass
## 4. System Architecture
### 4.1 High-Level Architecture
```
┌─────────────────┐ WebSocket ┌─────────────────┐
│ Client A │◄─────────────► │ │
│ (Browser) │ │ Elysia │
└─────────────────┘ │ Server │
│ │
┌─────────────────┐ WebSocket │ │
│ Client B │◄─────────────► │ │
│ (Browser) │ └─────────────────┘
└─────────────────┘ │
┌─────────────────┐
│ SQLite DB │
│ (Optional) │
└─────────────────┘
```
### 4.2 Component Design
#### 4.2.1 Server Components
**GameManager**
- Responsibilities: Create/destroy games, manage active game instances, handle matchmaking
- Interface:
```typescript
createGame(): GameInstance
joinGame(gameId: string, playerId: string): boolean
getGame(gameId: string): GameInstance | null
removeGame(gameId: string): void
```
**GameInstance**
- Responsibilities: Maintain game state, validate moves, detect win conditions
- State:
```typescript
{
id: string
board: (null | 'black' | 'white')[][]
currentPlayer: 'black' | 'white'
status: 'waiting' | 'playing' | 'finished'
winner: null | 'black' | 'white' | 'draw'
players: { black?: string, white?: string }
}
```
**WebSocketHandler**
- Responsibilities: Manage connections, route messages, handle disconnections
- Message Types:
```typescript
// Client → Server
{ type: 'join_game', gameId: string, playerId: string }
{ type: 'make_move', row: number, col: number }
{ type: 'ping' }
// Server → Client
{ type: 'game_state', state: GameState }
{ type: 'move_result', success: boolean, error?: string }
{ type: 'player_joined', playerId: string }
{ type: 'player_disconnected', playerId: string }
```
#### 4.2.2 Client Components
**GameBoardUI**
- Responsibilities: Render 15x15 grid, handle stone placement clicks
- The component SHALL highlight the last move
- The component SHALL show stone colors (black/white)
- The component SHALL disable interaction when not player's turn
**GameStateManager**
- Responsibilities: Track local game state, sync with server updates
- The component SHALL maintain local copy of game state
- The component SHALL handle optimistic updates with rollback capability
**WebSocketClient**
- Responsibilities: Manage WebSocket connection, send/receive messages
- The component SHALL automatically reconnect on connection loss
- The component SHALL queue messages during disconnection
## 5. API Design
### 5.1 WebSocket Messages
**Join Game Request**
```json
{
"type": "join_game",
"gameId": "optional-game-id",
"playerId": "player-uuid"
}
```
**Make Move Request**
```json
{
"type": "make_move",
"row": 7,
"col": 7
}
```
**Game State Update**
```json
{
"type": "game_state",
"state": {
"id": "game-uuid",
"board": "15x15 array",
"currentPlayer": "black",
"status": "playing",
"winner": null,
"players": {
"black": "player1-uuid",
"white": "player2-uuid"
}
}
}
```
### 5.2 HTTP Endpoints (HTMX)
**GET /**
- Returns main game interface HTML
**GET /game/:gameId**
- Returns game-specific interface (for sharing game links)
## 6. Database Schema
### 6.1 Tables (Optional - MVP can work without persistence)
**games**
```sql
CREATE TABLE games (
id TEXT PRIMARY KEY,
board TEXT NOT NULL, -- JSON serialized board
current_player TEXT NOT NULL,
status TEXT NOT NULL,
winner TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
```
**players**
```sql
CREATE TABLE players (
id TEXT PRIMARY KEY,
name TEXT,
created_at INTEGER NOT NULL
);
```
**game_players**
```sql
CREATE TABLE game_players (
game_id TEXT NOT NULL,
player_id TEXT NOT NULL,
color TEXT NOT NULL, -- 'black' or 'white'
FOREIGN KEY (game_id) REFERENCES games(id),
FOREIGN KEY (player_id) REFERENCES players(id),
PRIMARY KEY (game_id, player_id)
);
```
## 7. User Flow
### 7.1 Happy Path
1. Player A opens game URL
2. System creates new game, assigns Player A as 'black'
3. Player A shares game link with Player B
4. Player B joins game, assigned as 'white'
5. Game starts, Player A (black) makes first move
6. Players alternate turns until win/draw condition
7. System displays game result
### 7.2 Error Scenarios
**Player Disconnection**
- The system SHALL maintain game state for 5 minutes
- The system SHALL notify remaining player of disconnection
- The system SHALL allow reconnection using same player ID
**Invalid Move**
- The system SHALL reject invalid moves
- The system SHALL send error message to client
- The system SHALL maintain current game state
## 8. Security Considerations
### 8.1 Move Validation
- The system SHALL validate all moves server-side
- The system SHALL prevent players from making moves out of turn
- The system SHALL prevent overwriting existing stones
### 8.2 Game Integrity
- The system SHALL generate cryptographically secure game IDs
- The system SHALL prevent players from joining games they're not invited to
- The system SHALL rate-limit move requests to prevent spam
## 9. Deployment
### 9.1 Development Environment
```bash
# Start development server
bun run dev
# Run tests
bun test
# Build for production
bun run build
```
### 9.2 Production Considerations
- Single server deployment (no load balancing needed)
- SQLite database file backup strategy
- Environment variable configuration
- Basic logging for debugging
## 10. Future Enhancements
### 10.1 Phase 2 Features
- Spectator mode
- Game replay functionality
- Player statistics tracking
- Tournament bracket system
### 10.2 Technical Improvements
- Redis for session management (multi-server support)
- Move history with undo functionality
- AI opponent option
- Mobile-responsive design optimization
## 11. Testing Strategy
### 11.1 Unit Tests
- Game logic validation (win detection, move validation)
- WebSocket message handling
- Database operations (if implemented)
### 11.2 Integration Tests
- End-to-end game flow
- Multi-player scenarios
- Reconnection handling
### 11.3 Manual Testing
- Cross-browser compatibility
- Network interruption scenarios
- Simultaneous player actions

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# Elysia with Bun runtime
## Getting Started
To get started with this template, simply paste this command into your terminal:
```bash
bun create elysia ./elysia-example
```
## Development
To start the development server run:
```bash
bun run dev
```
Open http://localhost:3000/ with your browser to see the result.

BIN
bun.lockb Executable file

Binary file not shown.

40
justfile Normal file
View File

@ -0,0 +1,40 @@
# justfile for Elysia project
# Install dependencies
install:
bun install
# Run the development server
dev:
bun run --watch src/index.ts
# Build the project
build:
bun run build
# Run tests
test:
bun test
check:
bunx tsc --noEmit --skipLibCheck
# Lint the project
lint:
# Add your lint command here, for example:
# bun run lint
echo "Linting not configured yet"
# Clean the project
clean:
rm -rf node_modules
rm -rf dist
# Format the code
format:
bun run prettier . --write
# Bump version
bump-version:
# Add your version bump command here
echo "Version bump not configured yet"

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "gomoku",
"version": "1.0.50",
"dependencies": {
"elysia": "latest",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"bun-types": "latest",
"jest": "^30.0.4",
"prettier": "^3.6.2"
},
"module": "src/index.js"
}

View 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
View 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;
}
}

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

View 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');
});

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

28
src/index.ts Normal file
View File

@ -0,0 +1,28 @@
import { Elysia } from 'elysia';
import { GameManager } from './game/GameManager';
import { WebSocketHandler } from './game/WebSocketHandler';
const gameManager = new GameManager();
const webSocketHandler = new WebSocketHandler(gameManager);
const app = new Elysia()
.ws('/ws', {
open(ws: any) {
webSocketHandler.handleConnection(ws, ws.data.request);
},
message(ws: any, message: any) {
// This is handled inside WebSocketHandler.handleMessage
},
close(ws: any) {
// This is handled inside WebSocketHandler.handleDisconnect
},
err(ws: any, error: any, code: number, message: string) {
// This is handled inside WebSocketHandler.handleConnection
},
})
.get('/', () => 'Hello Elysia')
.listen(3000);
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);

105
tsconfig.json Normal file
View File

@ -0,0 +1,105 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": [
"bun-types"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}