From c15c9c16c80061e282dd4ea4192aab2b1a4c0a85 Mon Sep 17 00:00:00 2001 From: sepia Date: Tue, 15 Jul 2025 12:13:17 -0500 Subject: [PATCH] init --- .gitignore | 42 ++++ .prettierrc.json | 5 + ARCHITECTURE.md | 341 ++++++++++++++++++++++++++ README.md | 19 ++ bun.lockb | Bin 0 -> 132472 bytes justfile | 40 ++++ package.json | 15 ++ src/game/GameInstance.test.ts | 138 +++++++++++ src/game/GameInstance.ts | 176 ++++++++++++++ src/game/GameManager.test.ts | 48 ++++ src/game/GameManager.ts | 31 +++ src/game/WebSocketHandler.test.ts | 381 ++++++++++++++++++++++++++++++ src/game/WebSocketHandler.ts | 232 ++++++++++++++++++ src/index.ts | 28 +++ tsconfig.json | 105 ++++++++ 15 files changed, 1601 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc.json create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100755 bun.lockb create mode 100644 justfile create mode 100644 package.json create mode 100644 src/game/GameInstance.test.ts create mode 100644 src/game/GameInstance.ts create mode 100644 src/game/GameManager.test.ts create mode 100644 src/game/GameManager.ts create mode 100644 src/game/WebSocketHandler.test.ts create mode 100644 src/game/WebSocketHandler.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json 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 0000000000000000000000000000000000000000..70a9c2ad101084c20bf10adc241b8800a3e20012 GIT binary patch literal 132472 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 literal 0 HcmV?d00001 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. */ + } +}