commit c15c9c16c80061e282dd4ea4192aab2b1a4c0a85 Author: sepia Date: Tue Jul 15 12:13:17 2025 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e5610 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6a8af5e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..7a64e43 --- /dev/null +++ b/ARCHITECTURE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bebf061 --- /dev/null +++ b/README.md @@ -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. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..70a9c2a Binary files /dev/null and b/bun.lockb differ diff --git a/justfile b/justfile new file mode 100644 index 0000000..d44198d --- /dev/null +++ b/justfile @@ -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" diff --git a/package.json b/package.json new file mode 100644 index 0000000..a5d95b4 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/game/GameInstance.test.ts b/src/game/GameInstance.test.ts new file mode 100644 index 0000000..eaff08d --- /dev/null +++ b/src/game/GameInstance.test.ts @@ -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'); + }); +}); diff --git a/src/game/GameInstance.ts b/src/game/GameInstance.ts new file mode 100644 index 0000000..488fff5 --- /dev/null +++ b/src/game/GameInstance.ts @@ -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; + } +} diff --git a/src/game/GameManager.test.ts b/src/game/GameManager.test.ts new file mode 100644 index 0000000..e69f95d --- /dev/null +++ b/src/game/GameManager.test.ts @@ -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(); + }); +}); diff --git a/src/game/GameManager.ts b/src/game/GameManager.ts new file mode 100644 index 0000000..a7c7a5d --- /dev/null +++ b/src/game/GameManager.ts @@ -0,0 +1,31 @@ +import { GameInstance } from './GameInstance'; + +export class GameManager { + private games: Map; + + 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); + } +} diff --git a/src/game/WebSocketHandler.test.ts b/src/game/WebSocketHandler.test.ts new file mode 100644 index 0000000..d4b1569 --- /dev/null +++ b/src/game/WebSocketHandler.test.ts @@ -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'); + }); diff --git a/src/game/WebSocketHandler.ts b/src/game/WebSocketHandler.ts new file mode 100644 index 0000000..7b62836 --- /dev/null +++ b/src/game/WebSocketHandler.ts @@ -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>; // 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); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9a1d1f6 --- /dev/null +++ b/src/index.ts @@ -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}`, +); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ca47bb --- /dev/null +++ b/tsconfig.json @@ -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 ''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. */ + } +}