init
This commit is contained in:
commit
c15c9c16c8
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true
|
||||
}
|
|
@ -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
|
|
@ -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.
|
|
@ -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"
|
|
@ -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"
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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}`,
|
||||
);
|
|
@ -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. */
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue