├── .gitignore ├── LICENSE ├── README.md ├── client ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── GameClient │ │ ├── DataModel.ts │ │ ├── GameClient.ts │ │ └── TypedEventEmitter.ts │ ├── GameSettingsView │ │ └── GameSettings.tsx │ ├── GameView │ │ ├── GameView.tsx │ │ └── pages │ │ │ ├── ConnectingPage │ │ │ ├── ConnectingPage.tsx │ │ │ └── index.ts │ │ │ ├── ErrorPage │ │ │ ├── ErrorPage.tsx │ │ │ └── index.ts │ │ │ ├── FakeUserSelector │ │ │ ├── FakeUserSelector.tsx │ │ │ └── index.ts │ │ │ ├── GamePage │ │ │ ├── GamePage.tsx │ │ │ ├── UI │ │ │ │ ├── Board │ │ │ │ │ ├── Board.tsx │ │ │ │ │ ├── BoardWrapper.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── GameControlPanel │ │ │ │ │ ├── GameControlPanel.tsx │ │ │ │ │ ├── GiveUpPopup.tsx │ │ │ │ │ ├── PlayerActionsBlock.tsx │ │ │ │ │ ├── SpectatorsBlock.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── GameResultPopup │ │ │ │ │ ├── GameResultPopup.tsx │ │ │ │ │ └── index.ts │ │ │ │ └── PlayerBar │ │ │ │ │ ├── PlayerBar.tsx │ │ │ │ │ └── PlayerTimer.tsx │ │ │ └── index.ts │ │ │ ├── WaitingPage │ │ │ ├── WaitingPage.tsx │ │ │ └── index.ts │ │ │ └── index.ts │ ├── Telegram │ │ ├── TelegramThemeProvider.tsx │ │ └── Types.ts │ ├── UI │ │ ├── Telegram │ │ │ ├── ColorPicker │ │ │ │ ├── ColorPicker.tsx │ │ │ │ ├── assets │ │ │ │ │ ├── king-outlined.svg │ │ │ │ │ └── question-mark.svg │ │ │ │ └── index.ts │ │ │ ├── Menu │ │ │ │ ├── Menu.tsx │ │ │ │ └── index.ts │ │ │ ├── MenuDivider │ │ │ │ ├── MenuDivider.tsx │ │ │ │ └── index.ts │ │ │ ├── MenuItem │ │ │ │ ├── MenuItem.tsx │ │ │ │ └── index.ts │ │ │ ├── MenuItemSlider │ │ │ │ ├── MenuItemSlider.tsx │ │ │ │ └── index.ts │ │ │ ├── MenuItemSwitch │ │ │ │ ├── MenuItemSwitch.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── ThinkingIndicator │ │ │ ├── ThinkingIndicator.tsx │ │ │ └── index.ts │ │ ├── UserAvatar │ │ │ ├── UserAvatar.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── assets │ │ └── audio │ │ │ └── move.wav │ ├── i18n.ts │ ├── index.css │ ├── index.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── server ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── config └── default.json5 ├── locales ├── en.json ├── ru.json └── uk.json ├── package-lock.json ├── package.json ├── src ├── GameServer │ ├── DataModel.ts │ ├── Database.ts │ ├── Errors.ts │ ├── GameServer.ts │ ├── PausableTimer.ts │ ├── ServerRoom.ts │ ├── SocketErrorHandler.ts │ └── TypedEventEmitter.ts ├── Telegram │ ├── ChessNowBot.ts │ ├── MessageGenerator.ts │ ├── Types.ts │ ├── joinFullname.ts │ ├── parseGameRulesQuery.ts │ └── parseInitData.ts ├── i18n.ts └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Quatern1on 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: {browser: true, es2020: true}, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh', 'simple-import-sort'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | {allowConstantExport: true}, 16 | ], 17 | "simple-import-sort/imports": "error", 18 | "@typescript-eslint/no-unused-vars": "off", 19 | "@typescript-eslint/no-explicit-any": "off" 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | build 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | .env -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "es5", 4 | "tabWidth": 4, 5 | "singleQuote": false, 6 | "bracketSameLine": true, 7 | "bracketSpacing": false 8 | } 9 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chess Now 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "start": "npm run dev", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.11.1", 15 | "@emotion/styled": "^11.11.0", 16 | "@fontsource/roboto": "^5.0.8", 17 | "@mui/icons-material": "^5.14.11", 18 | "@mui/material": "^5.14.11", 19 | "@types/events": "^3.0.1", 20 | "chess.js": "^1.0.0-beta.6", 21 | "events": "^3.3.0", 22 | "i18next": "^23.5.1", 23 | "react": "^18.2.0", 24 | "react-chessboard": "^4.2.1", 25 | "react-countdown": "^2.3.5", 26 | "react-dom": "^18.2.0", 27 | "react-i18next": "^13.2.2", 28 | "socket.io-client": "^4.7.2", 29 | "tinycolor2": "^1.6.0" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^20.7.0", 33 | "@types/react": "^18.2.15", 34 | "@types/react-dom": "^18.2.7", 35 | "@types/tinycolor2": "^1.4.4", 36 | "@typescript-eslint/eslint-plugin": "^6.0.0", 37 | "@typescript-eslint/parser": "^6.0.0", 38 | "@vitejs/plugin-basic-ssl": "^1.0.1", 39 | "@vitejs/plugin-react": "^4.0.3", 40 | "eslint": "^8.45.0", 41 | "eslint-plugin-react-hooks": "^4.6.0", 42 | "eslint-plugin-react-refresh": "^0.4.3", 43 | "eslint-plugin-simple-import-sort": "^10.0.0", 44 | "prettier": "^3.0.3", 45 | "typescript": "^5.0.2", 46 | "vite": "^4.4.5", 47 | "vite-plugin-checker": "^0.6.1", 48 | "vite-plugin-handlebars": "^1.6.0", 49 | "vite-plugin-svgr": "^3.2.0", 50 | "vite-tsconfig-paths": "^4.2.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import {FakeUserSelector} from "GameView/pages/FakeUserSelector"; 2 | import type {FC} from "react"; 3 | 4 | import {GameSettingsView} from "@/GameSettingsView/GameSettings"; 5 | import {GameView} from "@/GameView/GameView"; 6 | import {TelegramThemeProvider} from "@/Telegram/TelegramThemeProvider.js"; 7 | 8 | export const App: FC = () => { 9 | if ( 10 | window.Telegram.WebApp.initDataUnsafe.start_param || 11 | (import.meta.env.VITE_ROOM_ID && window.Telegram.WebApp.platform === "unknown") 12 | ) { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | return ( 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/GameClient/DataModel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Color of the chest pieces 3 | */ 4 | export enum Color { 5 | White = "w", 6 | Black = "b", 7 | } 8 | 9 | /** 10 | * A 8x8 board square address in Algebraic notation 11 | * @see {@link https://en.wikipedia.org/wiki/Algebraic_notation_(chess)} 12 | */ 13 | export enum Square { 14 | A8 = "a8", 15 | B8 = "b8", 16 | C8 = "c8", 17 | D8 = "d8", 18 | E8 = "e8", 19 | F8 = "f8", 20 | G8 = "g8", 21 | H8 = "h8", 22 | A7 = "a7", 23 | B7 = "b7", 24 | C7 = "c7", 25 | D7 = "d7", 26 | E7 = "e7", 27 | F7 = "f7", 28 | G7 = "g7", 29 | H7 = "h7", 30 | A6 = "a6", 31 | B6 = "b6", 32 | C6 = "c6", 33 | D6 = "d6", 34 | E6 = "e6", 35 | F6 = "f6", 36 | G6 = "g6", 37 | H6 = "h6", 38 | A5 = "a5", 39 | B5 = "b5", 40 | C5 = "c5", 41 | D5 = "d5", 42 | E5 = "e5", 43 | F5 = "f5", 44 | G5 = "g5", 45 | H5 = "h5", 46 | A4 = "a4", 47 | B4 = "b4", 48 | C4 = "c4", 49 | D4 = "d4", 50 | E4 = "e4", 51 | F4 = "f4", 52 | G4 = "g4", 53 | H4 = "h4", 54 | A3 = "a3", 55 | B3 = "b3", 56 | C3 = "c3", 57 | D3 = "d3", 58 | E3 = "e3", 59 | F3 = "f3", 60 | G3 = "g3", 61 | H3 = "h3", 62 | A2 = "a2", 63 | B2 = "b2", 64 | C2 = "c2", 65 | D2 = "d2", 66 | E2 = "e2", 67 | F2 = "f2", 68 | G2 = "g2", 69 | H2 = "h2", 70 | A1 = "a1", 71 | B1 = "b1", 72 | C1 = "c1", 73 | D1 = "d1", 74 | E1 = "e1", 75 | F1 = "f1", 76 | G1 = "g1", 77 | H1 = "h1", 78 | } 79 | 80 | /** 81 | * Piece symbol (without color) 82 | */ 83 | export enum PieceSymbol { 84 | Pawn = "p", 85 | Knight = "n", 86 | Bishop = "b", 87 | Rook = "r", 88 | Queen = "q", 89 | King = "k", 90 | } 91 | 92 | /** 93 | * Represents a user authenticated through Telegram 94 | */ 95 | export interface User { 96 | /** 97 | * Telegram user's id 98 | * @see {@link https://core.telegram.org/bots/api#user} 99 | */ 100 | id: number; 101 | 102 | /** 103 | * Telegram user's first_name and last_name joined 104 | * @see {@link https://core.telegram.org/bots/api#user} 105 | */ 106 | fullName: string; 107 | 108 | /** 109 | * Telegram user's username 110 | * @see {@link https://core.telegram.org/bots/api#user} 111 | */ 112 | username?: string; 113 | 114 | /** 115 | * Data-URL of Telegram user's first profile picture 116 | */ 117 | avatarURL?: string; 118 | } 119 | 120 | /** 121 | * Represents dynamic state of room member 122 | */ 123 | export interface MemberState { 124 | /** 125 | * Whether the member has active connection to the room. A member could be present in the room but not connected, 126 | * for example if he/she leaves mid-game. 127 | *
128 | * Also, the user who created the room (the host) will always be present in the room (even if not connected) 129 | */ 130 | connected: boolean; 131 | 132 | /** 133 | * Whether the member has ability to participate in the game (e.g. make moves). All members have ability to watch 134 | * the game by default. 135 | */ 136 | isPlayer: boolean; 137 | 138 | /** 139 | * The color of the chest pieces the player controls. Guaranteed to be unique in the room. 140 | *
141 | * Present if {@link isPlayer} equals true 142 | */ 143 | color?: Color; 144 | } 145 | 146 | /** 147 | * Represents a member of the room and its state 148 | * @see Room 149 | */ 150 | export interface Member { 151 | /** 152 | * User profile of this member 153 | */ 154 | user: User; 155 | 156 | /** 157 | * Member's state 158 | */ 159 | state: MemberState; 160 | } 161 | 162 | /** 163 | * Represents the parameters of the game in current room, that could be set up before room creation 164 | * @see Room.gameRules 165 | */ 166 | export interface GameRules { 167 | /** 168 | * If host selected to play as a specific color, this field is equal to that color. Undefined is it should be picked 169 | * randomly. 170 | */ 171 | hostPreferredColor?: Color; 172 | 173 | /** 174 | * Whether players are limited in time to make a move. The timer does not reset when player makes a move. If the 175 | * player runs out of time he/she looses. 176 | */ 177 | timer: boolean; 178 | 179 | /** 180 | * Initial time on the timer for each player. Expressed in seconds. 181 | *
182 | * Present if {@link timer} equals true. 183 | */ 184 | initialTime?: number; 185 | 186 | /** 187 | * Amount by which the timer of the player should be incremented after he/she made a move 188 | *
189 | * Present if {@link plays} equals true. 190 | */ 191 | timerIncrement?: number; 192 | } 193 | 194 | /** 195 | * Status of the game in the current room 196 | * @see GameState.status 197 | */ 198 | export enum GameStatus { 199 | NotStarted = "not-started", 200 | InProgress = "in-progress", 201 | Finished = "finished", 202 | } 203 | 204 | /** 205 | * The result which the game was finished with 206 | */ 207 | export enum GameResolution { 208 | /** 209 | * Means in any game position a player's king is in check 210 | *
211 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 212 | * @see {@link https://en.wikipedia.org/wiki/Checkmate} 213 | */ 214 | Checkmate = "checkmate", 215 | 216 | /** 217 | * One of the players run out of timer time 218 | *
219 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 220 | */ 221 | OutOfTime = "out-of-time", 222 | 223 | /** 224 | * One of the players disconnected from the game and did not reconnect back in time 225 | *
226 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 227 | */ 228 | PlayerQuit = "player-quit", 229 | 230 | /** 231 | * One of the players gave up 232 | *
233 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 234 | */ 235 | GiveUp = "give-up", 236 | 237 | /** 238 | * A situation where the player whose turn it is to move is not in check and has no legal move 239 | *
240 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set 241 | * @see https://en.wikipedia.org/wiki/Stalemate 242 | */ 243 | Stalemate = "stalemate", 244 | 245 | /** 246 | * Draw 247 | *
248 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set 249 | * @see https://en.wikipedia.org/wiki/Draw_(chess) 250 | */ 251 | Draw = "draw", 252 | } 253 | 254 | /** 255 | * State of the game in current room 256 | */ 257 | export interface GameState { 258 | /** 259 | * Whether the game not-started, started or finished 260 | * @see GameStatus 261 | */ 262 | status: GameStatus; 263 | 264 | /** 265 | * History of all moves in PGN notation 266 | * @see {@link https://en.wikipedia.org/wiki/Portable_Game_Notation} 267 | */ 268 | pgn: string; 269 | 270 | /** 271 | * Whose side the current turn is 272 | */ 273 | turn: Color; 274 | 275 | /** 276 | * State of player's timers. Defined if {@link GameRules.timer} equals true. 277 | */ 278 | timer?: TimerState; 279 | 280 | /** 281 | * How the game finished 282 | *
283 | * Present if {@link status} equals {@link GameStatus.Finished} 284 | */ 285 | resolution?: GameResolution; 286 | 287 | /** 288 | * If the game finished in a win of one player, the ID of the user, who won the game 289 | */ 290 | winnerID?: number; 291 | } 292 | 293 | /** 294 | * Represents a game session, which players or spectators could connect 295 | */ 296 | export interface Room { 297 | /** 298 | * Unique string identifier 24 characters long 299 | */ 300 | id: string; 301 | 302 | /** 303 | * List of members currently present in the room. Not all present members are guaranteed to be connected. 304 | * @see MemberState.connected 305 | */ 306 | members: Member[]; 307 | 308 | /** 309 | * ID of the user who created the room. Guaranteed to be present in the {@link members}. 310 | * @see User.id 311 | */ 312 | hostID: number; 313 | 314 | /** 315 | * UNIX time (number of seconds since 00:00:00 UTC January 1, 1970) of the moment, when this room was created 316 | * @see {@link https://en.wikipedia.org/wiki/Unix_time} 317 | */ 318 | createdTimestamp: number; 319 | 320 | /** 321 | * Parameters of the game 322 | * @see GameRules 323 | */ 324 | gameRules: GameRules; 325 | 326 | /** 327 | * State of the game 328 | * @see GameState 329 | */ 330 | gameState: GameState; 331 | } 332 | 333 | /** 334 | * Represents a chess move 335 | */ 336 | export interface Move { 337 | /** 338 | * Which square the piece was moved from 339 | */ 340 | from: Square; 341 | 342 | /** 343 | * Which square the piece was moved to 344 | */ 345 | to: Square; 346 | 347 | /** 348 | * Present if the pawn was promoted to another piece. Contains which piece it was promoted to. 349 | */ 350 | promotion?: PieceSymbol; 351 | } 352 | 353 | /** 354 | * Represents a state of two player timers 355 | */ 356 | export interface TimerState { 357 | /** 358 | * Amount of time in milliseconds left on white player's timer 359 | */ 360 | whiteTimeLeft: number; 361 | 362 | /** 363 | * Amount of time in milliseconds left on black player's timer 364 | */ 365 | blackTimeLeft: number; 366 | } 367 | 368 | /** 369 | * Payload sent by client with new connection 370 | */ 371 | export interface AuthPayload { 372 | /** 373 | * initData field from window.Telegram.WebApp in the client. Needed fot authentication. Its genuinity will be 374 | * verified by the server. 375 | * @see {@link https://core.telegram.org/bots/webapps#initializing-mini-apps} 376 | * @see {@link https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app} 377 | */ 378 | initData: string; 379 | } 380 | 381 | /** 382 | * List of events that server could send to connected client 383 | */ 384 | export interface ServerToClientEvents { 385 | /** 386 | * Sent when error happens while processing client event. After an error happens, server closes the connection with 387 | * the client. 388 | * @param name Name of the Error class 389 | * @param message Message explaining the details of the error in english 390 | */ 391 | error: (name: string, message: string) => void; 392 | 393 | /** 394 | * Sent to client, when it initially connects to the room 395 | * @param room Room details the client connected to 396 | * @param userID ID of the user this connection is assigned to 397 | */ 398 | init: (room: Room, userID: number) => void; 399 | 400 | /** 401 | * Sent when another user joins the room 402 | * @param user The user who joined the room 403 | */ 404 | memberJoin: (member: Member) => void; 405 | 406 | /** 407 | * Sent when the members, present in the room leaves it. When the game begins, users who became players 408 | * ({@link MemberState.isPlayer} equals true) can not leave, they only can disconnect, and 409 | * {@link ServerToClientEvents.memberUpdate} will be called in this case. 410 | * @param userID ID of the user who left the room 411 | */ 412 | memberLeave: (userID: number) => void; 413 | 414 | /** 415 | * Sent when the member's state, that is currently present in the room updated 416 | * @param userID ID of the user, whose state is updated 417 | * @param state New state of the member 418 | */ 419 | memberUpdate: (userID: number, state: MemberState) => void; 420 | 421 | /** 422 | * Sent when current gameState of the room transitions from {@link GameStatus.NotStarted} to 423 | * {@link GameStatus.InProgress} 424 | */ 425 | gameStart: () => void; 426 | 427 | /** 428 | * Sent when current gameState of the room transitions from {@link GameStatus.InProgress} to 429 | * {@link GameStatus.Finished} 430 | * @param resolution How the game ended 431 | * @param winnerID ID of the user who won 432 | * @param timer Last timer recordings. Defined if {@link GameRules.timer} equals true. 433 | * @see GameState.winnerID 434 | */ 435 | gameEnd: (resolution: GameResolution, winnerID?: number, timer?: TimerState) => void; 436 | 437 | /** 438 | * Sent when a move was registered by server 439 | * @param move Move that was registered 440 | * @param timer State of player's timers. Defined if {@link GameRules.timer} equals true. 441 | */ 442 | move: (move: Move, timer?: TimerState) => void; 443 | } 444 | 445 | /** 446 | * List of events that client could send to server 447 | */ 448 | export interface ClientToServerEvents { 449 | /** 450 | * Make a move. The move should be validated on both server and client sides. If the move is illegal, or it is not 451 | * the players turn, server will return an error and close the connection. 452 | * @param move A move requested by client 453 | */ 454 | makeMove: (move: Move) => void; 455 | 456 | /** 457 | * User agreed to take a loss and voluntarily give up 458 | */ 459 | giveUp: () => void; 460 | } 461 | -------------------------------------------------------------------------------- /client/src/GameClient/GameClient.ts: -------------------------------------------------------------------------------- 1 | import * as ChessJS from "chess.js"; 2 | import {Chess} from "chess.js"; 3 | import * as SocketIO from "socket.io-client"; 4 | 5 | import { 6 | ClientToServerEvents, 7 | Color, 8 | GameResolution, 9 | GameState, 10 | GameStatus, 11 | Member, 12 | MemberState, 13 | Move, 14 | PieceSymbol, 15 | Room, 16 | ServerToClientEvents, 17 | Square, 18 | TimerState, 19 | User, 20 | } from "@/GameClient/DataModel"; 21 | import {TypedEventEmitter} from "@/GameClient/TypedEventEmitter"; 22 | 23 | export interface ErrorState { 24 | isError: boolean; 25 | serverSide?: boolean; 26 | name?: string; 27 | message?: string; 28 | } 29 | 30 | export interface GameClientEvents { 31 | errorUpdate: [serverSide: boolean, name: string, message: string]; 32 | anyUpdate: [state: ClientState]; 33 | move: [move: Move]; 34 | } 35 | 36 | export interface ClientRoom extends Omit { 37 | members: {[key: number]: Member}; 38 | gameState: ClientGameState; 39 | me: Member; 40 | opponent?: Member; 41 | whitePlayer?: Member; 42 | blackPlayer?: Member; 43 | } 44 | 45 | export interface ClientGameState extends Omit { 46 | fen: string; 47 | lastMove?: Move; 48 | winner?: User; 49 | } 50 | 51 | export interface ClientState { 52 | room?: ClientRoom; 53 | error: ErrorState; 54 | makingMove: boolean; 55 | connected: boolean; 56 | } 57 | 58 | export interface PossibleMove { 59 | to: Square; 60 | promotion: boolean; 61 | } 62 | 63 | type Socket = SocketIO.Socket; 64 | 65 | export class GameClient extends TypedEventEmitter { 66 | private _state: ClientState; 67 | 68 | private readonly socket: Socket; 69 | 70 | private readonly chess: Chess; 71 | 72 | public static swapColor(color: Color): Color { 73 | return color === Color.White ? Color.Black : Color.White; 74 | } 75 | 76 | public constructor() { 77 | super(); 78 | 79 | this.socket = SocketIO.io(import.meta.env.VITE_SERVER_URL, { 80 | auth: { 81 | initData: window.Telegram.WebApp.initData || window.fakeInitData, 82 | }, 83 | transports: ["polling", "websocket"], 84 | }); 85 | this.chess = new Chess(); 86 | 87 | this._state = { 88 | connected: false, 89 | error: {isError: false}, 90 | makingMove: false, 91 | }; 92 | 93 | this.socket.on("error", this.handleServerError); 94 | this.socket.on("init", this.handleInit); 95 | this.socket.on("memberJoin", this.handleMemberJoin); 96 | this.socket.on("memberLeave", this.handleMemberLeave); 97 | this.socket.on("memberUpdate", this.handleMemberUpdate); 98 | this.socket.on("gameStart", this.handleGameStart); 99 | this.socket.on("gameEnd", this.handleGameEnd); 100 | this.socket.on("move", this.handleMove); 101 | this.socket.on("disconnect", this.handleDisconnect); 102 | } 103 | 104 | get state(): ClientState { 105 | return this._state; 106 | } 107 | 108 | public readonly makeMove = (move: Move): boolean => { 109 | if ( 110 | this.chess.turn() !== this._state.room!.me.state.color || 111 | this._state.room!.gameState.status !== GameStatus.InProgress 112 | ) { 113 | return false; 114 | } 115 | 116 | try { 117 | this.chess.move(move); 118 | this._state.makingMove = true; 119 | this.socket.emit("makeMove", move); 120 | this.chess.undo(); 121 | return true; 122 | } catch (e) { 123 | return false; 124 | } 125 | }; 126 | 127 | public readonly disconnect = () => { 128 | this.socket.disconnect(); 129 | this._state.connected = false; 130 | 131 | this.emit("anyUpdate", this._state); 132 | }; 133 | 134 | public getPossibleMoves = (from: Square): PossibleMove[] => { 135 | const squareToMoveMap: {[key: string]: PossibleMove} = {}; 136 | const moves = this.chess.moves({verbose: true, square: from}); 137 | for (const move of moves) { 138 | if (!squareToMoveMap[move.to]) { 139 | squareToMoveMap[move.to] = { 140 | to: move.to as Square, 141 | promotion: false, 142 | }; 143 | } 144 | 145 | if (move.promotion) { 146 | squareToMoveMap[move.to].promotion = true; 147 | } 148 | } 149 | 150 | return Object.values(squareToMoveMap); 151 | }; 152 | 153 | public getCheck = (): Square | undefined => { 154 | if (this.chess.inCheck()) { 155 | const board = this.chess.board(); 156 | const color = this.chess.turn(); 157 | for (const row of board) { 158 | for (const square of row) { 159 | if (square?.type === ChessJS.KING && square.color === color) { 160 | return square.square as Square; 161 | } 162 | } 163 | } 164 | } 165 | }; 166 | 167 | public getPieceColor = (square: Square): Color | undefined => { 168 | return this.chess.get(square as ChessJS.Square).color as Color; 169 | }; 170 | 171 | public readonly giveUp = (): void => { 172 | this.socket.emit("giveUp"); 173 | }; 174 | 175 | private readonly handleServerError = (name: string, message: string): void => { 176 | this._state.error.isError = true; 177 | this._state.error.serverSide = true; 178 | this._state.error.name = name; 179 | this._state.error.message = message; 180 | 181 | this.emit("errorUpdate", true, name, message); 182 | this.emit("anyUpdate", this._state); 183 | }; 184 | 185 | private readonly findPlayers = (): void => { 186 | const me = this._state.room!.me; 187 | 188 | for (const member of Object.values(this._state.room!.members)) { 189 | if (member.user.id !== me.user.id && member.state.isPlayer && me.state.isPlayer) { 190 | this._state.room!.opponent = member; 191 | } 192 | if (member.state.isPlayer && member.state.color === Color.White) { 193 | this._state.room!.whitePlayer = member; 194 | } 195 | if (member.state.isPlayer && member.state.color === Color.Black) { 196 | this._state.room!.blackPlayer = member; 197 | } 198 | } 199 | }; 200 | 201 | private readonly handleInit = (room: Room, userID: number): void => { 202 | const membersDict = {}; 203 | let me: Member; 204 | const opponent: Member | undefined = undefined; 205 | 206 | for (const member of room.members) { 207 | membersDict[member.user.id] = member; 208 | if (member.user.id === userID) { 209 | me = member; 210 | } 211 | } 212 | 213 | this.chess.loadPgn(room.gameState.pgn); 214 | 215 | const clientGameState: ClientGameState = { 216 | ...room.gameState, 217 | fen: this.chess.fen(), 218 | }; 219 | 220 | const history = this.chess.history({verbose: true}); 221 | const lastHistoryEntry = history[history.length - 1]; 222 | if (lastHistoryEntry) { 223 | clientGameState.lastMove = { 224 | from: lastHistoryEntry.from as Square, 225 | to: lastHistoryEntry.to as Square, 226 | promotion: lastHistoryEntry.promotion as PieceSymbol, 227 | }; 228 | } 229 | 230 | if (room.gameState.winnerID) { 231 | clientGameState.winner = membersDict[room.gameState.winnerID].user; 232 | } 233 | 234 | const clientRoom: ClientRoom = { 235 | id: room.id, 236 | members: membersDict, 237 | hostID: room.hostID, 238 | createdTimestamp: room.createdTimestamp, 239 | gameRules: room.gameRules, 240 | gameState: clientGameState, 241 | me: me!, 242 | opponent: opponent, 243 | }; 244 | 245 | this._state.makingMove = false; 246 | this._state.error = {isError: false}; 247 | this._state.connected = true; 248 | this._state.room = clientRoom; 249 | 250 | this.findPlayers(); 251 | 252 | this.emit("anyUpdate", this._state); 253 | }; 254 | 255 | private readonly handleMemberJoin = (member: Member): void => { 256 | this._state.room!.members[member.user.id] = member; 257 | this.findPlayers(); 258 | this.emit("anyUpdate", this._state); 259 | }; 260 | 261 | private readonly handleMemberLeave = (userID: number): void => { 262 | delete this._state.room!.members[userID]; 263 | this.emit("anyUpdate", this._state); 264 | }; 265 | 266 | private readonly handleMemberUpdate = (userID: number, state: MemberState): void => { 267 | this._state.room!.members[userID].state = state; 268 | this.findPlayers(); 269 | this.emit("anyUpdate", this._state); 270 | }; 271 | 272 | private readonly handleGameStart = (): void => { 273 | this._state.room!.gameState.status = GameStatus.InProgress; 274 | if (this._state.room!.gameRules.timer) { 275 | this._state.room!.gameState.timer = { 276 | whiteTimeLeft: this._state.room!.gameRules.initialTime! * 1000, 277 | blackTimeLeft: this._state.room!.gameRules.initialTime! * 1000, 278 | }; 279 | } 280 | this.emit("anyUpdate", this._state); 281 | }; 282 | 283 | private readonly handleGameEnd = (resolution: GameResolution, winnerID?: number, timer?: TimerState): void => { 284 | this._state.room!.gameState.status = GameStatus.Finished; 285 | this._state.room!.gameState.resolution = resolution; 286 | this._state.room!.gameState.winnerID = winnerID; 287 | if (winnerID) { 288 | this._state.room!.gameState.winner = this._state.room!.members[winnerID].user; 289 | } 290 | if (timer) { 291 | this._state.room!.gameState.timer = timer; 292 | } 293 | this.emit("anyUpdate", this._state); 294 | }; 295 | 296 | private readonly handleMove = (move: Move, timer?: TimerState): void => { 297 | this.chess.move(move); 298 | 299 | this._state.room!.gameState.fen = this.chess.fen(); 300 | this._state.room!.gameState.turn = this.chess.turn() as Color; 301 | this._state.room!.gameState.lastMove = move; 302 | this._state.makingMove = false; 303 | if (timer) { 304 | this._state.room!.gameState.timer = timer; 305 | } 306 | 307 | this.emit("move", move); 308 | this.emit("anyUpdate", this._state); 309 | }; 310 | 311 | private readonly handleDisconnect = (): void => { 312 | this._state.room = undefined; 313 | this._state.connected = false; 314 | this.emit("anyUpdate", this._state); 315 | }; 316 | } 317 | -------------------------------------------------------------------------------- /client/src/GameClient/TypedEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | 3 | export class TypedEventEmitter> { 4 | private emitter = new EventEmitter(); 5 | 6 | protected emit(eventName: TEventName, ...eventArg: TEvents[TEventName]) { 7 | this.emitter.emit(eventName, ...(eventArg as [])); 8 | } 9 | 10 | public on( 11 | eventName: TEventName, 12 | handler: (...eventArg: TEvents[TEventName]) => void 13 | ) { 14 | this.emitter.on(eventName, handler as unknown as any); 15 | } 16 | 17 | public off( 18 | eventName: TEventName, 19 | handler: (...eventArg: TEvents[TEventName]) => void 20 | ) { 21 | this.emitter.off(eventName, handler as any); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/GameSettingsView/GameSettings.tsx: -------------------------------------------------------------------------------- 1 | import SwapVertOutlinedIcon from "@mui/icons-material/SwapVertOutlined"; 2 | import TimerOutlinedIcon from "@mui/icons-material/TimerOutlined"; 3 | import {Collapse, Container} from "@mui/material"; 4 | import {FC, useEffect, useState} from "react"; 5 | import {useTranslation} from "react-i18next"; 6 | 7 | import { 8 | ColorPicker, 9 | ColorPickerValueType, 10 | Menu, 11 | MenuDivider, 12 | MenuItem, 13 | MenuItemSlider, 14 | MenuItemSwitch, 15 | } from "@/UI/Telegram"; 16 | 17 | const initTimeSlider = [ 18 | {text: "1/4", value: 15}, 19 | {text: "1/2", value: 30}, 20 | {text: "3/4", value: 45}, 21 | {text: "1", value: 60}, 22 | {text: "2", value: 60 * 2}, 23 | {text: "3", value: 60 * 3}, 24 | {text: "5", value: 60 * 5}, 25 | {text: "10", value: 60 * 10}, 26 | {text: "15", value: 60 * 15}, 27 | {text: "20", value: 60 * 20}, 28 | {text: "30", value: 60 * 30}, 29 | {text: "45", value: 60 * 45}, 30 | {text: "60", value: 60 * 60}, 31 | {text: "90", value: 60 * 90}, 32 | {text: "120", value: 60 * 120}, 33 | {text: "180", value: 60 * 180}, 34 | ]; 35 | 36 | const incrementTimeSlider = [ 37 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 30, 35, 40, 45, 50, 55, 60, 90, 120, 38 | 150, 180, 39 | ]; 40 | 41 | export const GameSettingsView: FC = () => { 42 | const {t} = useTranslation(); 43 | 44 | const [enableTimer, setEnableTimer] = useState(true); 45 | const [initTimeIndex, setInitTimeIndex] = useState(7); 46 | const [incrementTimeIndex, setIncrementTimeIndex] = useState(0); 47 | const [playerColor, setPlayerColor] = useState("random"); 48 | 49 | useEffect(() => { 50 | window.Telegram.WebApp.MainButton.setText(t("settings.done")); 51 | window.Telegram.WebApp.MainButton.show(); 52 | }, [t]); 53 | 54 | useEffect(() => { 55 | const generateInlineQuery = (): string => { 56 | let timerToken = enableTimer ? "1:" : "0:"; 57 | 58 | if (enableTimer) { 59 | timerToken += initTimeSlider[initTimeIndex].value.toString() + ":"; 60 | timerToken += incrementTimeSlider[incrementTimeIndex].toString() + ":"; 61 | } 62 | 63 | const colorToken = playerColor.slice(0, 1); 64 | return "$" + timerToken + colorToken; 65 | }; 66 | 67 | const handleMainButtonClick = () => { 68 | window.Telegram.WebApp.switchInlineQuery(generateInlineQuery()); 69 | }; 70 | 71 | window.Telegram.WebApp.MainButton.onClick(handleMainButtonClick); 72 | return () => { 73 | window.Telegram.WebApp.MainButton.offClick(handleMainButtonClick); 74 | }; 75 | }, [enableTimer, initTimeIndex, incrementTimeIndex, playerColor]); 76 | 77 | return ( 78 | 79 | 80 | } 84 | primary={t("settings.timer")} 85 | /> 86 | 87 | { 95 | setInitTimeIndex(newValue as number); 96 | }, 97 | }} 98 | /> 99 | { 107 | setIncrementTimeIndex(newValue as number); 108 | }, 109 | }} 110 | /> 111 | 112 | 113 | } 115 | primary={t("settings.color")} 116 | direction="column"> 117 | 118 | 119 | 120 | 121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /client/src/GameView/GameView.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useEffect, useMemo, useState} from "react"; 2 | 3 | import moveSound from "@/assets/audio/move.wav"; 4 | import {GameStatus} from "@/GameClient/DataModel"; 5 | import {ClientState, GameClient} from "@/GameClient/GameClient"; 6 | 7 | import {ConnectingPage, ErrorPage, GamePage, WaitingPage} from "./pages"; 8 | 9 | const moveSoundPlayer = new Audio(moveSound); 10 | 11 | export const GameView: FC = () => { 12 | const [clientState, setClientState] = useState(); 13 | 14 | const client = useMemo(() => new GameClient(), []); 15 | 16 | const handleAnyUpdate = (state: ClientState) => { 17 | setClientState({...state}); 18 | }; 19 | 20 | const handleMove = () => { 21 | moveSoundPlayer.play(); 22 | }; 23 | 24 | useEffect(() => { 25 | client.on("anyUpdate", handleAnyUpdate); 26 | client.on("move", handleMove); 27 | 28 | return () => { 29 | client.disconnect(); 30 | }; 31 | // eslint-disable-next-line react-hooks/exhaustive-deps 32 | }, []); 33 | 34 | if (!clientState) { 35 | return ; 36 | } 37 | 38 | const error = clientState.error; 39 | const connected = clientState.connected; 40 | const room = clientState.room; 41 | const makingMove = clientState.makingMove; 42 | 43 | if (error.isError) { 44 | return ; 45 | } 46 | 47 | if (!connected || !room) { 48 | return ; 49 | } 50 | 51 | if (room.gameState.status === GameStatus.NotStarted) { 52 | return ; 53 | } 54 | 55 | return ; 56 | }; 57 | -------------------------------------------------------------------------------- /client/src/GameView/pages/ConnectingPage/ConnectingPage.tsx: -------------------------------------------------------------------------------- 1 | import {CircularProgress, Stack, Typography} from "@mui/material"; 2 | import type {FC} from "react"; 3 | import {useTranslation} from "react-i18next"; 4 | 5 | export const ConnectingPage: FC = () => { 6 | const {t} = useTranslation(); 7 | return ( 8 | 9 | 10 | 11 | {t("game.connecting")} 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /client/src/GameView/pages/ConnectingPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ConnectingPage"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/ErrorPage/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import {Container, Typography} from "@mui/material"; 2 | import type {FC} from "react"; 3 | import {useTranslation} from "react-i18next"; 4 | 5 | export const ErrorPage: FC = () => { 6 | const {t} = useTranslation(); 7 | 8 | return ( 9 | 10 | 11 | {t("error.title")} 12 | 13 | 14 | {t("error.message")} 15 | 16 | 17 | {t("error.possibleSolutions")} 18 | 19 |
    20 |
  • 21 | {t("error.checkInternet")} 22 |
  • 23 |
  • 24 | {t("error.refreshPage")} 25 |
  • 26 |
  • 27 | {t("error.contactSupport")} 28 |
  • 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /client/src/GameView/pages/ErrorPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ErrorPage"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/FakeUserSelector/FakeUserSelector.tsx: -------------------------------------------------------------------------------- 1 | import {Stack, Typography} from "@mui/material"; 2 | import React, {FC, ReactNode, useState} from "react"; 3 | 4 | import {User} from "@/GameClient/DataModel"; 5 | import {WebAppUser} from "@/Telegram/Types"; 6 | import {UserAvatar} from "@/UI"; 7 | 8 | export type FakeUserSelectorProps = { 9 | children?: ReactNode; 10 | }; 11 | 12 | declare global { 13 | interface Window { 14 | fakeInitData?: string; 15 | } 16 | } 17 | 18 | const generateFakeInitData = (user: User, startParam: string) => { 19 | const fakeUser: WebAppUser = { 20 | id: user.id, 21 | first_name: user.fullName.split(" ", 2)[0], 22 | last_name: user.fullName.split(" ", 2)[1], 23 | username: user.username, 24 | language_code: "en", 25 | allows_write_to_pm: true, 26 | }; 27 | 28 | return encodeURI(`user=${JSON.stringify(fakeUser)}&start_param=${startParam}`); 29 | }; 30 | 31 | const fakeUsers: Array = [ 32 | { 33 | id: 1, 34 | fullName: "John Doe", 35 | username: "john_doe", 36 | }, 37 | { 38 | id: 2, 39 | fullName: "Michael Brown", 40 | username: "michael_brown", 41 | }, 42 | { 43 | id: 3, 44 | fullName: "Leonard Nimoy", 45 | username: "leonard_nimoy", 46 | }, 47 | ]; 48 | 49 | export const FakeUserSelector: FC = ({children}) => { 50 | const [selected, setSelected] = useState(false); 51 | const initDataAvailable = Boolean(window.Telegram.WebApp.initData); 52 | 53 | if (selected || initDataAvailable || import.meta.env.PROD) { 54 | return <>{children}; 55 | } else { 56 | return ( 57 | 63 | 64 | Select fake user 65 | 66 | 67 | {fakeUsers.map((fakeUser) => ( 68 | { 74 | window.fakeInitData = generateFakeInitData(fakeUser, import.meta.env.VITE_ROOM_ID); 75 | setSelected(true); 76 | }}> 77 | 78 | {fakeUser.fullName} 79 | 80 | ))} 81 | 82 | 83 | ); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /client/src/GameView/pages/FakeUserSelector/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FakeUserSelector"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/GamePage.tsx: -------------------------------------------------------------------------------- 1 | import {Box} from "@mui/material"; 2 | import React, {useEffect, useState} from "react"; 3 | 4 | import {Color, GameStatus, Member} from "@/GameClient/DataModel"; 5 | import {ClientRoom, GameClient} from "@/GameClient/GameClient"; 6 | import {Board, PlayerBar} from "@/GameView/pages"; 7 | import {GameControlPanel} from "@/GameView/pages/GamePage/UI/GameControlPanel"; 8 | import {GameResultPopup} from "@/GameView/pages/GamePage/UI/GameResultPopup"; 9 | 10 | export interface GameProps { 11 | room: ClientRoom; 12 | makingMove: boolean; 13 | gameClient: GameClient; 14 | } 15 | 16 | export const GamePage: React.FC = ({room, makingMove, gameClient}) => { 17 | const [isResultOpen, setIsResultOpen] = useState(room.gameState.status === GameStatus.Finished); 18 | 19 | const handleResultClose = () => { 20 | setIsResultOpen(false); 21 | }; 22 | 23 | const handleResultOpen = () => { 24 | setIsResultOpen(true); 25 | }; 26 | 27 | useEffect(() => { 28 | if (room.gameState.status === GameStatus.Finished) { 29 | handleResultOpen(); 30 | } 31 | }, [room.gameState.status]); 32 | 33 | const [topPlayer, bottomPlayer]: [Member, Member] = room.me.state.isPlayer 34 | ? [room.opponent!, room.me] 35 | : [room.blackPlayer!, room.whitePlayer!]; 36 | 37 | const isTopPlayerWhite = topPlayer.state.color === Color.White; 38 | const [topPlayerTimer, bottomPlayerTimer]: [number?, number?] = isTopPlayerWhite 39 | ? [room.gameState.timer?.whiteTimeLeft, room.gameState.timer?.blackTimeLeft] 40 | : [room.gameState.timer?.blackTimeLeft, room.gameState.timer?.whiteTimeLeft]; 41 | 42 | return ( 43 | 50 | 58 | 66 | 67 | 68 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/Board/Board.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useState} from "react"; 2 | import * as ChessboardTypes from "react-chessboard/dist/chessboard/types"; 3 | import {PromotionPieceOption} from "react-chessboard/dist/chessboard/types"; 4 | 5 | import {Color, PieceSymbol, Square} from "@/GameClient/DataModel"; 6 | import {ClientRoom, GameClient, PossibleMove} from "@/GameClient/GameClient"; 7 | import {BoardWrapper} from "@/GameView/pages/GamePage/UI/Board/BoardWrapper"; 8 | 9 | export interface BoardProps { 10 | room: ClientRoom; 11 | makingMove: boolean; 12 | gameClient: GameClient; 13 | } 14 | 15 | const MOVE_FROM_SQUARE_STYLE = { 16 | background: "rgba(255, 255, 0, 0.4)", 17 | }; 18 | 19 | const OPTION_SQUARE_STYLE = { 20 | background: "radial-gradient(circle, rgba(0,0,0,.2) 25%, transparent 25%)", 21 | }; 22 | 23 | const OPTION_BEAT_SQUARE_STYLE = { 24 | background: "radial-gradient(circle, rgba(201, 18, 9, 0.4) 70%, transparent 70%)", 25 | }; 26 | 27 | const LAST_MOVE_SQUARE_STYLE = { 28 | background: "rgba(255, 180, 0, 0.4)", 29 | }; 30 | 31 | const CHECK_SQUARE_STYLE = { 32 | background: "radial-gradient(circle, rgba(201, 18, 9, 1) 30%, transparent 75%)", 33 | }; 34 | 35 | export const Board: FC = ({room, makingMove, gameClient}) => { 36 | const [moveFrom, setMoveFrom] = useState(); 37 | const [possibleMoves, setPossibleMoves] = useState([]); 38 | const [promotionToSquare, setPromotionToSquare] = useState(); 39 | 40 | const myColor = room.me.state.color; 41 | const turn = room.gameState.turn; 42 | const canMove: boolean = myColor === turn && !makingMove; 43 | 44 | const idDraggablePiece = ({piece}: {piece: ChessboardTypes.Piece}): boolean => { 45 | return canMove && piece.startsWith(myColor as string); 46 | }; 47 | 48 | const highlightMoves = (square: ChessboardTypes.Square) => { 49 | const possibleMoves = gameClient.getPossibleMoves(square as Square); 50 | setMoveFrom(square as Square); 51 | setPossibleMoves(possibleMoves); 52 | }; 53 | 54 | const clearHighlight = () => { 55 | setMoveFrom(undefined); 56 | setPossibleMoves([]); 57 | }; 58 | 59 | const onSquareClick = (square: ChessboardTypes.Square): void => { 60 | if (!canMove) { 61 | return; 62 | } 63 | 64 | const possibleMove = possibleMoves.find((p) => p.to === square); 65 | 66 | if (possibleMove) { 67 | if (possibleMove.promotion) { 68 | setPromotionToSquare(square); 69 | } else { 70 | gameClient.makeMove({from: moveFrom! as Square, to: square as Square}); 71 | clearHighlight(); 72 | } 73 | } else { 74 | if (gameClient.getPieceColor(square as Square) !== room.me.state.color || square === moveFrom) { 75 | clearHighlight(); 76 | } else { 77 | highlightMoves(square); 78 | } 79 | } 80 | }; 81 | 82 | const onPieceDragBegin = (piece: ChessboardTypes.Piece, sourceSquare: Square) => { 83 | highlightMoves(sourceSquare); 84 | }; 85 | 86 | const onPieceDrop = (sourceSquare: ChessboardTypes.Square, targetSquare: ChessboardTypes.Square): boolean => { 87 | const possibleMove = possibleMoves.find((p) => p.to === targetSquare); 88 | if (possibleMove && possibleMove.promotion) { 89 | setPromotionToSquare(targetSquare); 90 | return true; 91 | } else { 92 | clearHighlight(); 93 | return gameClient.makeMove({from: moveFrom! as Square, to: targetSquare as Square}); 94 | } 95 | }; 96 | 97 | const onPromotionPieceSelect = (piece?: PromotionPieceOption) => { 98 | if (piece) { 99 | clearHighlight(); 100 | const promotionPiece = piece[1].toLowerCase() as PieceSymbol; 101 | return gameClient.makeMove({ 102 | from: moveFrom! as Square, 103 | to: promotionToSquare as Square, 104 | promotion: promotionPiece, 105 | }); 106 | } 107 | 108 | setPromotionToSquare(undefined); 109 | return false; 110 | }; 111 | 112 | const moveFromSquareStyle = {}; 113 | if (moveFrom) { 114 | moveFromSquareStyle[moveFrom] = MOVE_FROM_SQUARE_STYLE; 115 | } 116 | 117 | const optionSquareStyles = {}; 118 | for (const possibleMove of possibleMoves) { 119 | const color = gameClient.getPieceColor(possibleMove.to as Square); 120 | 121 | if (color && color === GameClient.swapColor(room.me.state.color!)) { 122 | optionSquareStyles[possibleMove.to] = OPTION_BEAT_SQUARE_STYLE; 123 | } else { 124 | optionSquareStyles[possibleMove.to] = OPTION_SQUARE_STYLE; 125 | } 126 | } 127 | 128 | const lastMoveSquareStyles = {}; 129 | if (room.gameState.lastMove) { 130 | const lastMove = room.gameState.lastMove; 131 | 132 | lastMoveSquareStyles[lastMove.from] = LAST_MOVE_SQUARE_STYLE; 133 | lastMoveSquareStyles[lastMove.to] = LAST_MOVE_SQUARE_STYLE; 134 | } 135 | 136 | const checkSquareStyle = {}; 137 | const checkKingSquare = gameClient.getCheck(); 138 | if (checkKingSquare) { 139 | checkSquareStyle[checkKingSquare] = CHECK_SQUARE_STYLE; 140 | } 141 | 142 | return ( 143 | false} 154 | customSquareStyles={{ 155 | ...lastMoveSquareStyles, 156 | ...checkSquareStyle, 157 | ...moveFromSquareStyle, 158 | ...optionSquareStyles, 159 | }} 160 | /> 161 | ); 162 | }; 163 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/Board/BoardWrapper.tsx: -------------------------------------------------------------------------------- 1 | import {ComponentProps, FC, useEffect, useRef, useState} from "react"; 2 | import {Chessboard} from "react-chessboard"; 3 | 4 | export const BoardWrapper: FC> = ({...props}) => { 5 | const wrapperRef = useRef(null); 6 | 7 | const [boardWidth, setBoardWidth] = useState(1); 8 | 9 | useEffect(() => { 10 | if (wrapperRef.current?.offsetHeight) { 11 | const resizeObserver = new ResizeObserver(() => { 12 | setBoardWidth(wrapperRef.current?.offsetHeight as number); 13 | }); 14 | resizeObserver.observe(wrapperRef.current); 15 | 16 | return () => { 17 | resizeObserver.disconnect(); 18 | }; 19 | } 20 | }, []); 21 | 22 | return ( 23 |
24 | 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/Board/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Board"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameControlPanel/GameControlPanel.tsx: -------------------------------------------------------------------------------- 1 | import {Stack} from "@mui/material"; 2 | import {FC, MouseEvent, useState} from "react"; 3 | 4 | import {GameStatus, Member} from "@/GameClient/DataModel"; 5 | import {ClientRoom} from "@/GameClient/GameClient"; 6 | import {GiveUpPopup} from "@/GameView/pages/GamePage/UI/GameControlPanel/GiveUpPopup"; 7 | import {PlayerActionsBlock} from "@/GameView/pages/GamePage/UI/GameControlPanel/PlayerActionsBlock"; 8 | import {SpectatorsBlock} from "@/GameView/pages/GamePage/UI/GameControlPanel/SpectatorsBlock"; 9 | 10 | export type GameControlPanelProps = { 11 | room: ClientRoom; 12 | onGiveUp: () => void; 13 | }; 14 | 15 | const filterSpectators = (members: {[key: number]: Member}): Member[] => { 16 | return Object.values(members).filter((member) => !member.state.isPlayer); 17 | }; 18 | 19 | const sortMembers = (members: Member[], currentUserId) => { 20 | return members.sort((a, b) => (b.user.id === currentUserId ? 1 : -1)); 21 | }; 22 | 23 | export const GameControlPanel: FC = ({room, onGiveUp}) => { 24 | const [giveUpOpen, setGiveUpOpen] = useState(false); 25 | const [giveUpAnchor, setGiveUpAnchor] = useState(null); 26 | 27 | const handleGiveUpClick = (event: MouseEvent) => { 28 | setGiveUpAnchor(event.currentTarget); 29 | setGiveUpOpen((previousOpen) => !previousOpen); 30 | }; 31 | 32 | const handleGiveUpConfirm = () => { 33 | setGiveUpOpen(false); 34 | onGiveUp(); 35 | }; 36 | 37 | const handleGiveUpClose = () => { 38 | setGiveUpOpen(false); 39 | }; 40 | 41 | const spectators = filterSpectators(room.members); 42 | const sortedSpectators = sortMembers(spectators, room.me.user.id); 43 | const isSpectatorsPresent = spectators.length > 0; 44 | 45 | const isPlayerActionsVisible = room.gameState.status === GameStatus.InProgress && room.me.state.isPlayer; 46 | 47 | if (!isSpectatorsPresent && !isPlayerActionsVisible) { 48 | return null; 49 | } 50 | 51 | return ( 52 | 56 | {isPlayerActionsVisible ? : null} 57 | {isSpectatorsPresent ? : null} 58 | 59 | 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameControlPanel/GiveUpPopup.tsx: -------------------------------------------------------------------------------- 1 | import {Button, DialogActions, DialogContent, DialogTitle, Popover} from "@mui/material"; 2 | import type {FC} from "react"; 3 | import {useTranslation} from "react-i18next"; 4 | 5 | export type GiveUpPopupProps = { 6 | open: boolean; 7 | anchorEl: HTMLElement | null; 8 | onCancel: () => void; 9 | onGiveUp: () => void; 10 | }; 11 | 12 | export const GiveUpPopup: FC = ({open, anchorEl, onCancel, onGiveUp}) => { 13 | const {t} = useTranslation(); 14 | 15 | return ( 16 | 39 | {t("game.giveUpPopup.title")} 40 | {t("game.giveUpPopup.content")} 41 | 42 | 43 | 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameControlPanel/PlayerActionsBlock.tsx: -------------------------------------------------------------------------------- 1 | import EmojiFlagsRoundedIcon from "@mui/icons-material/EmojiFlagsRounded"; 2 | import {Box, IconButton} from "@mui/material"; 3 | import type {FC, MouseEvent} from "react"; 4 | 5 | export type PlayerActionsBlockProps = { 6 | onGiveUp: (event: MouseEvent) => void; 7 | }; 8 | 9 | export const PlayerActionsBlock: FC = ({onGiveUp}) => { 10 | return ( 11 | 12 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameControlPanel/SpectatorsBlock.tsx: -------------------------------------------------------------------------------- 1 | import VisibilityIcon from "@mui/icons-material/Visibility"; 2 | import {AvatarGroup, Stack} from "@mui/material"; 3 | import type {FC} from "react"; 4 | 5 | import {Member} from "@/GameClient/DataModel"; 6 | import {UserAvatar} from "@/UI"; 7 | 8 | export type SpectatorsBlockProps = { 9 | spectators: Member[]; 10 | }; 11 | 12 | export const SpectatorsBlock: FC = ({spectators}) => { 13 | return ( 14 | 15 | 20 | {spectators.map((member) => ( 21 | 22 | ))} 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameControlPanel/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GameControlPanel"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameResultPopup/GameResultPopup.tsx: -------------------------------------------------------------------------------- 1 | import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Slide} from "@mui/material"; 2 | import {TransitionProps} from "@mui/material/transitions"; 3 | import {FC, forwardRef, ReactElement, Ref} from "react"; 4 | import {useTranslation} from "react-i18next"; 5 | 6 | import {GameResolution, GameStatus, User} from "@/GameClient/DataModel"; 7 | import {ClientRoom} from "@/GameClient/GameClient"; 8 | 9 | export type GameResultPopupProps = { 10 | isOpen: boolean; 11 | room: ClientRoom; 12 | onClose: () => void; 13 | }; 14 | 15 | const Transition = forwardRef(function Transition( 16 | props: TransitionProps & { 17 | children: ReactElement; 18 | }, 19 | ref: Ref 20 | ) { 21 | return ; 22 | }); 23 | 24 | export const GameResultPopup: FC = ({isOpen, room, onClose}) => { 25 | const {t} = useTranslation(); 26 | 27 | if (room.gameState.status !== GameStatus.Finished) { 28 | return null; 29 | } 30 | 31 | const rematch = () => { 32 | window.Telegram.WebApp.switchInlineQuery(" "); 33 | }; 34 | 35 | const resolution = room.gameState.resolution; 36 | 37 | let title: string; 38 | let explanation: string; 39 | const showRematchButton: boolean = room.me.state.isPlayer; 40 | if (room.me.state.isPlayer) { 41 | if ( 42 | resolution === GameResolution.Checkmate || 43 | resolution === GameResolution.OutOfTime || 44 | resolution === GameResolution.PlayerQuit || 45 | resolution === GameResolution.GiveUp 46 | ) { 47 | const resultKey = room.me.user.id === room.gameState.winner!.id ? "victory" : "defeat"; 48 | title = t(`gameResult.${resultKey}.title`); 49 | explanation = t(`gameResult.${resultKey}.explanation.${resolution}`); 50 | } else { 51 | title = t("gameResult.draw.title"); 52 | explanation = t(`gameResult.draw.explanation.${resolution}`); 53 | } 54 | } else { 55 | const winner: User | undefined = room.gameState.winner; 56 | let loser: User | undefined; 57 | if (winner) { 58 | if (winner.id === room.whitePlayer!.user.id) { 59 | loser = room.blackPlayer!.user; 60 | } else { 61 | loser = room.whitePlayer!.user; 62 | } 63 | } 64 | 65 | title = t("gameResult.spectator.title"); 66 | explanation = t(`gameResult.spectator.explanation.${resolution}`, { 67 | winner: winner?.fullName, 68 | loser: loser?.fullName, 69 | }); 70 | } 71 | 72 | return ( 73 | 74 | {title} 75 | 76 | {explanation} 77 | 78 | 79 | {showRematchButton && ( 80 | 83 | )} 84 | 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/GameResultPopup/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GameResultPopup"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/PlayerBar/PlayerBar.tsx: -------------------------------------------------------------------------------- 1 | import {Box, Stack, Typography} from "@mui/material"; 2 | import {FC, ReactNode, useEffect, useRef, useState} from "react"; 3 | 4 | import {Member} from "@/GameClient/DataModel"; 5 | import {PlayerTimer} from "@/GameView/pages/GamePage/UI/PlayerBar/PlayerTimer"; 6 | import {UserAvatar} from "@/UI"; 7 | import {ThinkingIndicator} from "@/UI"; 8 | 9 | export type PlayerBarProps = { 10 | member: Member; 11 | turnIndicator: boolean; 12 | timeLeft?: number; 13 | timerGoing?: boolean; 14 | }; 15 | 16 | export const PlayerBar: FC = ({member, turnIndicator, timeLeft, timerGoing}) => { 17 | const turnIndicatorBackground = turnIndicator ? {bgcolor: "background.paper"} : {bgcolor: "background.default"}; 18 | let timerNode: ReactNode = null; 19 | if (timeLeft !== undefined && timerGoing !== undefined) { 20 | timerNode = ; 21 | } else { 22 | if (turnIndicator) { 23 | timerNode = ; 24 | } 25 | } 26 | 27 | const rootRef = useRef(null); 28 | 29 | const [avatarSize, setAvatarSize] = useState(1); 30 | 31 | useEffect(() => { 32 | if (rootRef.current?.offsetHeight) { 33 | const resizeObserver = new ResizeObserver((entries) => { 34 | setAvatarSize(entries[0].contentRect.height); 35 | }); 36 | resizeObserver.observe(rootRef.current); 37 | 38 | return () => { 39 | resizeObserver.disconnect(); 40 | }; 41 | } 42 | }, []); 43 | 44 | return ( 45 | 60 | ({ 62 | position: "absolute", 63 | left: theme.spacing(1), 64 | top: theme.spacing(1), 65 | })}> 66 | 76 | 77 | {timerNode} 78 | 90 | {member.user.fullName} 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/UI/PlayerBar/PlayerTimer.tsx: -------------------------------------------------------------------------------- 1 | import {Typography} from "@mui/material"; 2 | import {FC, useEffect, useRef, useState} from "react"; 3 | import Countdown, {zeroPad} from "react-countdown"; 4 | 5 | export type PlayerTimerProps = { 6 | timeLeft: number; 7 | going: boolean; 8 | turnIndicator: boolean; 9 | }; 10 | 11 | export const PlayerTimer: FC = ({timeLeft, going, turnIndicator}) => { 12 | const countdownRef = useRef(null); 13 | const [date, setDate] = useState(Date.now() + timeLeft); 14 | 15 | useEffect(() => { 16 | if (going) { 17 | countdownRef.current!.start(); 18 | } else { 19 | countdownRef.current!.stop(); 20 | } 21 | }, [going]); 22 | 23 | useEffect(() => { 24 | setDate(Date.now() + timeLeft); 25 | }, [timeLeft]); 26 | 27 | return ( 28 | 34 | ( 41 | 42 | {zeroPad(minutes)}:{zeroPad(seconds)}.{milliseconds / 100} 43 | 44 | )} 45 | /> 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /client/src/GameView/pages/GamePage/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GamePage"; 2 | export * from "./UI/PlayerBar/PlayerBar"; 3 | export * from "./UI/Board/Board"; 4 | -------------------------------------------------------------------------------- /client/src/GameView/pages/WaitingPage/WaitingPage.tsx: -------------------------------------------------------------------------------- 1 | import {AvatarGroup, Divider, Stack, Typography} from "@mui/material"; 2 | import React from "react"; 3 | import {useTranslation} from "react-i18next"; 4 | 5 | import {ClientRoom} from "@/GameClient/GameClient"; 6 | import {UserAvatar} from "@/UI"; 7 | 8 | export interface WaitingForUserProps { 9 | room: ClientRoom; 10 | } 11 | 12 | export const WaitingPage: React.FC = ({room}) => { 13 | const {t} = useTranslation(); 14 | 15 | const isHost = room.hostID === room.me.user.id; 16 | const host = room.members[room.hostID]; 17 | 18 | let avatars; 19 | let text; 20 | 21 | if (isHost) { 22 | text = t("game.waitingForAnyone"); 23 | avatars = ; 24 | } else { 25 | text = t("game.waitingForUser", {user: host.user.fullName}); 26 | 27 | const presentUsers = Object.values(room.members) 28 | .sort((a) => (a.user.id === room.me.user.id ? -1 : 1)) 29 | .filter((member) => member.user.id !== room.hostID) 30 | .map((member) => ( 31 | 32 | )); 33 | 34 | avatars = ( 35 | 36 | {presentUsers} 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | return ( 44 | 45 | {avatars} 46 | 47 | {text} 48 | 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /client/src/GameView/pages/WaitingPage/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./WaitingPage"; 2 | -------------------------------------------------------------------------------- /client/src/GameView/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ConnectingPage"; 2 | export * from "./GamePage"; 3 | export * from "./ErrorPage"; 4 | export * from "./WaitingPage"; 5 | -------------------------------------------------------------------------------- /client/src/Telegram/TelegramThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import {createTheme, CssBaseline, Theme, ThemeProvider} from "@mui/material"; 2 | import {ThemeOptions} from "@mui/material/styles/createTheme"; 3 | import React, {FC, ReactNode, useEffect, useState} from "react"; 4 | 5 | const generateTheme = (): Theme => { 6 | const telegramTheme = { 7 | colorScheme: window.Telegram.WebApp.colorScheme, 8 | bg_color: window.Telegram.WebApp.themeParams.bg_color, 9 | text_color: window.Telegram.WebApp.themeParams.text_color, 10 | hint_color: window.Telegram.WebApp.themeParams.hint_color, 11 | link_color: window.Telegram.WebApp.themeParams.link_color, 12 | button_color: window.Telegram.WebApp.themeParams.button_color, 13 | button_text_color: window.Telegram.WebApp.themeParams.button_text_color, 14 | secondary_bg_color: window.Telegram.WebApp.themeParams.secondary_bg_color, 15 | }; 16 | 17 | const themeOptions: ThemeOptions = { 18 | palette: { 19 | mode: telegramTheme.colorScheme, 20 | }, 21 | components: { 22 | MuiSwitch: { 23 | defaultProps: { 24 | disableRipple: true, 25 | }, 26 | styleOverrides: { 27 | root: ({ownerState, theme}) => ({ 28 | width: 52, 29 | }), 30 | switchBase: ({ownerState, theme}) => ({ 31 | padding: "10px", 32 | "&:hover": { 33 | background: "none", 34 | }, 35 | "&.Mui-checked": { 36 | transform: "translateX(13px)", 37 | color: theme.palette.primary.main, 38 | "&:hover": { 39 | background: "none", 40 | }, 41 | "& + .MuiSwitch-track": { 42 | opacity: 1, 43 | }, 44 | "& .MuiSwitch-thumb": { 45 | borderColor: theme.palette.primary.main, 46 | }, 47 | }, 48 | }), 49 | thumb: ({ownerState, theme}) => ({ 50 | background: theme.palette.background.default, 51 | border: "solid 2px", 52 | borderColor: theme.palette.text.disabled, 53 | opacity: 1, 54 | boxShadow: "none", 55 | transition: "border-color .3s ease-in-out", 56 | width: 18, 57 | height: 18, 58 | 59 | "&.Mui-checked": { 60 | borderColor: theme.palette.primary.main, 61 | }, 62 | }), 63 | track: ({ownerState, theme}) => ({ 64 | background: theme.palette.text.disabled, 65 | transition: "background-color .3s ease-in-out", 66 | opacity: 1, 67 | }), 68 | }, 69 | }, 70 | MuiSlider: { 71 | styleOverrides: { 72 | rail: ({ownerState, theme}) => ({ 73 | height: 3, 74 | background: theme.palette.text.disabled, 75 | opacity: 1, 76 | }), 77 | track: ({ownerState, theme}) => ({ 78 | height: 3, 79 | border: 0, 80 | background: theme.palette.primary.main, 81 | }), 82 | thumb: { 83 | width: 15, 84 | height: 15, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }; 90 | 91 | if (telegramTheme.button_color && telegramTheme.button_text_color) { 92 | themeOptions.palette!.primary = { 93 | main: telegramTheme.button_color, 94 | contrastText: telegramTheme.button_text_color, 95 | }; 96 | themeOptions.palette!.secondary = { 97 | main: telegramTheme.button_color, 98 | contrastText: telegramTheme.button_text_color, 99 | }; 100 | } 101 | 102 | if (telegramTheme.bg_color && telegramTheme.secondary_bg_color) { 103 | themeOptions.palette!.background = { 104 | default: telegramTheme.bg_color, 105 | paper: telegramTheme.secondary_bg_color, 106 | }; 107 | } 108 | 109 | if (telegramTheme.text_color) { 110 | themeOptions.palette!.text = { 111 | primary: telegramTheme.text_color, 112 | secondary: telegramTheme.text_color, 113 | }; 114 | 115 | if (telegramTheme.hint_color) { 116 | themeOptions.palette!.text.disabled = telegramTheme.hint_color; 117 | } 118 | } 119 | 120 | return createTheme(themeOptions); 121 | }; 122 | 123 | type ThemeProviderProps = { 124 | children?: ReactNode; 125 | }; 126 | 127 | export const TelegramThemeProvider: FC = ({children}) => { 128 | const [theme, setTheme] = useState(() => generateTheme()); 129 | 130 | useEffect(() => { 131 | const handleThemeChange = () => { 132 | setTheme(generateTheme()); 133 | }; 134 | 135 | window.Telegram.WebApp.onEvent("themeChanged", handleThemeChange); 136 | 137 | return () => { 138 | window.Telegram.WebApp.offEvent("themeChanged", handleThemeChange); 139 | }; 140 | }, []); 141 | 142 | return ( 143 | 144 | 145 | {children} 146 | 147 | ); 148 | }; 149 | -------------------------------------------------------------------------------- /client/src/Telegram/Types.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | Telegram: Telegram; 4 | } 5 | } 6 | 7 | export interface Telegram { 8 | WebApp: WebApp; 9 | } 10 | 11 | interface MiniAppsEvents { 12 | themeChanged: () => void; 13 | } 14 | 15 | interface WebApp { 16 | /** 17 | * A string with raw data transferred to the Mini App, convenient for validating data. 18 | *
19 | * WARNING: Validate data from this field before using it on the bot's server. 20 | */ 21 | initData: string; 22 | 23 | /** 24 | * An object with input data transferred to the Mini App. 25 | *
26 | * WARNING: Data from this field should not be trusted. You should only use data from initData on the bot's 27 | * server and only after it has been validated. 28 | */ 29 | initDataUnsafe: WebAppInitData; 30 | 31 | /** 32 | * The version of the Bot API available in the user's Telegram app. 33 | */ 34 | version: string; 35 | 36 | /** 37 | * The name of the platform of the user's Telegram app. 38 | */ 39 | platform: string; 40 | 41 | /** 42 | * The color scheme currently used in the Telegram app. Either “light” or “dark”. 43 | *
44 | * Also available as the CSS variable var(--tg-color-scheme). 45 | */ 46 | colorScheme: "light" | "dark"; 47 | 48 | /** 49 | * An object containing the current theme settings used in the Telegram app. 50 | */ 51 | themeParams: ThemeParams; 52 | 53 | /** 54 | * True, if the Mini App is expanded to the maximum available height. False, if the Mini App occupies part of the 55 | * screen and can be expanded to the full height using the {@link expand()} method. 56 | */ 57 | isExpanded: boolean; 58 | 59 | /** 60 | * The current height of the visible area of the Mini App. Also available in CSS as the variable 61 | * var(--tg-viewport-height). 62 | *
63 | *
64 | * The application can display just the top part of the Mini App, with its lower part remaining outside the screen 65 | * area. From this position, the user can “pull” the Mini App to its maximum height, while the bot can do the same 66 | * by calling the {@link expand()} method. As the position of the Mini App changes, the current height value of the 67 | * visible area will be updated in real time. 68 | *
69 | *
70 | * Please note that the refresh rate of this value is not sufficient to smoothly follow the lower border of the 71 | * window. It should not be used to pin interface elements to the bottom of the visible area. It's more appropriate 72 | * to use the value of the viewportStableHeight field for this purpose. 73 | */ 74 | viewportHeight: number; 75 | 76 | /** 77 | * The height of the visible area of the Mini App in its last stable state. Also available in CSS as a variable 78 | * var(--tg-viewport-stable-height). 79 | *
80 | *
81 | * The application can display just the top part of the Mini App, with its lower part remaining outside the screen 82 | * area. From this position, the user can “pull” the Mini App to its maximum height, while the bot can do the same 83 | * by calling the {@link expand()} method. Unlike the value of viewportHeight, the value of viewportStableHeight 84 | * does not change as the position of the Mini App changes with user gestures or during animations. The value of 85 | * viewportStableHeight will be updated after all gestures and animations are completed and the Mini App reaches its 86 | * final size. 87 | *
88 | *
89 | * Note the event viewportChanged with the passed parameter isStateStable=true, which will allow you to track 90 | * when the stable state of the height of the visible area changes. 91 | */ 92 | viewportStableHeight: number; 93 | 94 | /** 95 | * Current header color in the #RRGGBB format. 96 | */ 97 | headerColor: string; 98 | 99 | /** 100 | * Current background color in the #RRGGBB format. 101 | */ 102 | backgroundColor: string; 103 | 104 | /** 105 | * True, if the confirmation dialog is enabled while the user is trying to close the Mini App. False, 106 | * if the confirmation dialog is disabled. 107 | */ 108 | isClosingConfirmationEnabled: boolean; 109 | 110 | /** 111 | * An object for controlling the back button which can be displayed in the header of the Mini App in the Telegram 112 | * interface. 113 | */ 114 | BackButton: BackButton; 115 | 116 | /** 117 | * An object for controlling the main button, which is displayed at the bottom of the Mini App in the Telegram 118 | * interface. 119 | */ 120 | MainButton: MainButton; 121 | 122 | //... 123 | 124 | /** 125 | * A method that sets the app event handler. Check the list of available events. 126 | */ 127 | onEvent( 128 | eventType: EventType, 129 | eventHandler: MiniAppsEvents[EventType] 130 | ): void; 131 | 132 | /** 133 | * A method that deletes a previously set event handler. 134 | */ 135 | offEvent( 136 | eventType: EventType, 137 | eventHandler: MiniAppsEvents[EventType] 138 | ): void; 139 | 140 | //... 141 | 142 | /** 143 | * Bot API 6.7+ A method that inserts the bot's username and the specified inline query in the current chat's input 144 | * field. Query may be empty, in which case only the bot's username will be inserted. If an optional 145 | * choose_chat_types parameter was passed, the client prompts the user to choose a specific chat, then opens that 146 | * chat and inserts the bot's username and the specified inline query in the input field. You can specify which 147 | * types of chats the user will be able to choose from. It can be one or more of the following types: users, bots, 148 | * groups, channels. 149 | */ 150 | switchInlineQuery(query: string, choose_chat_types?: string): void; 151 | 152 | //... 153 | 154 | /** 155 | * A method that expands the Mini App to the maximum available height. To find out if the Mini App is expanded to 156 | * the maximum height, refer to the value of the {@link Telegram.WebApp.isExpanded} parameter 157 | */ 158 | expand: () => void; 159 | } 160 | 161 | export interface ThemeParams { 162 | /** 163 | * Background color in the #RRGGBB format. 164 | *
165 | * Also available as the CSS variable var(--tg-theme-bg-color). 166 | */ 167 | bg_color?: string; 168 | 169 | /** 170 | * Main text color in the #RRGGBB format. 171 | *
172 | * Also available as the CSS variable var(--tg-theme-text-color). 173 | */ 174 | text_color?: string; 175 | 176 | /** 177 | * Hint text color in the #RRGGBB format. 178 | *
179 | * Also available as the CSS variable var(--tg-theme-hint-color). 180 | */ 181 | hint_color?: string; 182 | 183 | /** 184 | * Link color in the #RRGGBB format. 185 | *
186 | * Also available as the CSS variable var(--tg-theme-link-color). 187 | */ 188 | link_color?: string; 189 | 190 | /** 191 | * Button color in the #RRGGBB format. 192 | *
193 | * Also available as the CSS variable var(--tg-theme-button-color). 194 | */ 195 | button_color?: string; 196 | 197 | /** 198 | * Button text color in the #RRGGBB format. 199 | *
200 | * Also available as the CSS variable var(--tg-theme-button-text-color). 201 | */ 202 | button_text_color?: string; 203 | 204 | /** 205 | * Bot API 6.1+ Secondary background color in the #RRGGBB format. 206 | *
207 | * Also available as the CSS variable var(--tg-theme-secondary-bg-color). 208 | */ 209 | secondary_bg_color?: string; 210 | } 211 | 212 | /** 213 | * This object controls the back button, which can be displayed in the header of the Mini App in the Telegram interface. 214 | */ 215 | export interface BackButton { 216 | /** 217 | * Shows whether the button is visible. Set to false by default. 218 | */ 219 | isVisible: boolean; 220 | 221 | /** 222 | * Bot API 6.1+ A method that sets the button press event handler. An alias for 223 | *
Telegram.WebApp.onEvent('backButtonClicked', callback)
224 | */ 225 | onClick(callback: () => void): void; 226 | 227 | /** 228 | * Bot API 6.1+ A method that removes the button press event handler. An alias for 229 | *
Telegram.WebApp.offEvent('backButtonClicked', callback)
230 | */ 231 | offClick(callback: () => void): void; 232 | 233 | /** 234 | * Bot API 6.1+ A method to make the button active and visible. 235 | */ 236 | show(): void; 237 | 238 | /** 239 | * Bot API 6.1+ A method to hide the button. 240 | */ 241 | hide(): void; 242 | } 243 | 244 | export interface MainButton { 245 | /** 246 | * Current button text. Set to CONTINUE by default. 247 | */ 248 | text: string; 249 | 250 | /** 251 | * Current button color. Set to themeParams.button_color by default. 252 | */ 253 | color: string; 254 | 255 | /** 256 | * Current button text color. Set to themeParams.button_text_color by default. 257 | */ 258 | textColor: string; 259 | 260 | /** 261 | * Shows whether the button is visible. Set to false by default. 262 | */ 263 | isVisible: boolean; 264 | 265 | /** 266 | * Shows whether the button is active. Set to true by default. 267 | */ 268 | isActive: boolean; 269 | 270 | /** 271 | * Readonly. Shows whether the button is displaying a loading indicator. 272 | */ 273 | isProgressVisible: boolean; 274 | 275 | /** 276 | * A method to set the button text. 277 | */ 278 | setText(text: string): void; 279 | 280 | /** 281 | * A method that sets the button press event handler. An alias for 282 | *
Telegram.WebApp.onEvent('mainButtonClicked', callback)
283 | */ 284 | onClick(callback: () => void): void; 285 | 286 | /** 287 | * A method that removes the button press event handler. An alias for 288 | *
Telegram.WebApp.offEvent('mainButtonClicked', callback)
289 | */ 290 | offClick(callback: () => void): void; 291 | 292 | /** 293 | * A method to make the button visible. 294 | * Note that opening the Mini App from the attachment menu hides the main button until the user interacts with the 295 | * Mini App interface. 296 | */ 297 | show(): void; 298 | 299 | /** 300 | * A method to hide the button. 301 | */ 302 | hide(): void; 303 | 304 | /** 305 | * A method to enable the button. 306 | */ 307 | enable(): void; 308 | 309 | /** 310 | * A method to disable the button. 311 | */ 312 | disable(): void; 313 | 314 | /** 315 | * A method to show a loading indicator on the button. 316 | * It is recommended to display loading progress if the action tied to the button may take a long time. By default, 317 | * the button is disabled while the action is in progress. If the parameter leaveActive=true is passed, the button 318 | * remains enabled. 319 | */ 320 | showProgress(leaveActive: boolean): void; 321 | 322 | /** 323 | * A method to hide the loading indicator. 324 | */ 325 | hideProgress(): void; 326 | 327 | /** 328 | * A method to set the button parameters. The params parameter is an object containing one or several fields that 329 | * need to be changed: 330 | * text - button text; 331 | * color - button color; 332 | * text_color - button text color; 333 | * is_active - enable the button; 334 | * is_visible - show the button. 335 | */ 336 | setParams(params: { 337 | text?: string; 338 | color?: string; 339 | text_color?: string; 340 | is_active?: boolean; 341 | is_visible?: boolean; 342 | }): void; 343 | } 344 | 345 | export interface WebAppUser { 346 | id: number; 347 | is_bot?: boolean; 348 | first_name: string; 349 | last_name?: string; 350 | username?: string; 351 | language_code?: string; 352 | is_premium?: true; 353 | added_to_attachment_menu?: true; 354 | allows_write_to_pm?: true; 355 | photo_url?: string; 356 | } 357 | 358 | export interface WebAppChat { 359 | id: number; 360 | type: string; 361 | title: string; 362 | username?: string; 363 | photo_url?: string; 364 | } 365 | 366 | export interface WebAppInitData { 367 | query_id?: string; 368 | user?: WebAppUser; 369 | receiver?: WebAppUser; 370 | chat?: WebAppChat; 371 | chat_type?: string; 372 | chat_instance?: string; 373 | start_param?: string; 374 | can_send_after?: number; 375 | auth_date: number; 376 | hash: string; 377 | } 378 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/ColorPicker/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import {Button, FormControlLabel, Stack, styled} from "@mui/material"; 2 | import {FC} from "react"; 3 | import {useTranslation} from "react-i18next"; 4 | 5 | import {ReactComponent as KingOutlined} from "@/UI/Telegram/ColorPicker/assets/king-outlined.svg"; 6 | import {ReactComponent as QuestionMark} from "@/UI/Telegram/ColorPicker/assets/question-mark.svg"; 7 | 8 | export type ColorPickerValueType = "white" | "black" | "random"; 9 | 10 | export type ColorPickerProps = { 11 | value: ColorPickerValueType; 12 | onChange: (value: ColorPickerValueType) => void; 13 | }; 14 | 15 | type ColorPickerRadioProps = { 16 | value: ColorPickerValueType; 17 | label: string; 18 | selected: boolean; 19 | onClick: (value: ColorPickerValueType) => void; 20 | }; 21 | 22 | type ColorPickerBoxProps = { 23 | value: ColorPickerValueType; 24 | }; 25 | 26 | const ColorPickerBox = styled(Button)(({theme, value}) => { 27 | const before = { 28 | content: '""', 29 | position: "absolute", 30 | left: 0, 31 | right: 0, 32 | top: 0, 33 | bottom: 0, 34 | clipPath: "polygon(0 100%, 100% 0, 100% 100%)", 35 | background: "#eee", 36 | zIndex: 0, 37 | }; 38 | 39 | return { 40 | padding: theme.spacing(3), 41 | borderRadius: theme.shape.borderRadius, 42 | background: value === "white" ? "#eee" : "#222", 43 | flexGrow: 1, 44 | cursor: "pointer", 45 | alignSelf: "stretch", 46 | display: "flex", 47 | alignItems: "center", 48 | gap: theme.spacing(1), 49 | flexDirection: "column", 50 | color: value === "white" ? "#111" : "#eee", 51 | position: "relative", 52 | overflow: "hidden", 53 | transition: "box-shadow .2s ease-in-out", 54 | 55 | "&:hover": { 56 | background: value === "white" ? "#eee" : "#222", 57 | }, 58 | 59 | "& .MuiTouchRipple-root": { 60 | color: value === "random" ? "#888" : undefined, 61 | }, 62 | 63 | "&:before": value === "random" ? before : undefined, 64 | }; 65 | }); 66 | 67 | const ColorPickerRadio: FC = ({value, label, selected, onClick}) => { 68 | const icon = 69 | value === "random" ? : ; 70 | 71 | const selectedSx = (theme) => ({ 72 | boxShadow: `0 0 0 4px ${theme.palette.primary.main}`, 73 | }); 74 | 75 | return ( 76 | onClick(value)}> 88 | {icon} 89 | 90 | } 91 | label={label} 92 | /> 93 | ); 94 | }; 95 | 96 | export const ColorPicker: FC = ({value, onChange}) => { 97 | const {t} = useTranslation(); 98 | 99 | return ( 100 | 101 | 107 | 113 | 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/ColorPicker/assets/king-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/ColorPicker/assets/question-mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/ColorPicker/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ColorPicker"; 2 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/Menu/Menu.tsx: -------------------------------------------------------------------------------- 1 | import {List, styled} from "@mui/material"; 2 | 3 | export const Menu = styled(List)(({theme}) => ({ 4 | background: theme.palette.background.default, 5 | })); 6 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/Menu/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Menu"; 2 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuDivider/MenuDivider.tsx: -------------------------------------------------------------------------------- 1 | import {Divider, styled} from "@mui/material"; 2 | 3 | export const MenuDivider = styled(Divider)(({theme}) => ({ 4 | background: theme.palette.background.paper, 5 | height: 5, 6 | border: 0, 7 | })); 8 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuDivider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MenuDivider"; 2 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuItem/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import {ListItem, ListItemButton, ListItemIcon, ListItemText, Stack} from "@mui/material"; 2 | import {FC, ReactNode} from "react"; 3 | 4 | export type MenuItemProps = { 5 | icon?: ReactNode; 6 | primary?: ReactNode; 7 | secondary?: ReactNode; 8 | onClick?: () => void; 9 | children?: ReactNode; 10 | direction?: "row" | "column"; 11 | }; 12 | 13 | export const MenuItem: FC = ({icon, primary, secondary, onClick, children, direction = "row"}) => { 14 | const RootComponent = onClick ? ListItemButton : ListItem; 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuItem/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MenuItem"; 2 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuItemSlider/MenuItemSlider.tsx: -------------------------------------------------------------------------------- 1 | import {Slider, SliderProps, Stack, Typography} from "@mui/material"; 2 | import {FC} from "react"; 3 | 4 | import {MenuItem, MenuItemProps} from "@/UI/Telegram"; 5 | 6 | export type MenuItemSliderProps = { 7 | tooltip?: string; 8 | sliderProps?: SliderProps; 9 | } & Omit; 10 | 11 | export const MenuItemSlider: FC = ({icon, tooltip, sliderProps, ...props}) => { 12 | return ( 13 | 14 | 15 | 16 | {tooltip} 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuItemSlider/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MenuItemSlider"; 2 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuItemSwitch/MenuItemSwitch.tsx: -------------------------------------------------------------------------------- 1 | import {Switch} from "@mui/material"; 2 | import {FC} from "react"; 3 | 4 | import {MenuItem, MenuItemProps} from "@/UI/Telegram"; 5 | 6 | export type MenuItemSwitchProps = { 7 | onChange: (value: boolean) => void; 8 | value: boolean; 9 | } & Omit; 10 | 11 | export const MenuItemSwitch: FC = ({onChange, value, ...props}) => { 12 | return ( 13 | onChange(!value)} {...props}> 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/MenuItemSwitch/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MenuItemSwitch"; 2 | -------------------------------------------------------------------------------- /client/src/UI/Telegram/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ColorPicker"; 2 | export * from "./Menu"; 3 | export * from "./MenuDivider"; 4 | export * from "./MenuItem"; 5 | export * from "./MenuItemSwitch"; 6 | export * from "./MenuItemSlider"; 7 | -------------------------------------------------------------------------------- /client/src/UI/ThinkingIndicator/ThinkingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import {Box} from "@mui/material"; 2 | import {FC} from "react"; 3 | 4 | export const ThinkingIndicator: FC = () => { 5 | return ( 6 | 42 |
43 |
44 |
45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /client/src/UI/ThinkingIndicator/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ThinkingIndicator"; 2 | -------------------------------------------------------------------------------- /client/src/UI/UserAvatar/UserAvatar.tsx: -------------------------------------------------------------------------------- 1 | import {Avatar, Badge, styled, SxProps, Theme} from "@mui/material"; 2 | import React from "react"; 3 | import tinycolor from "tinycolor2"; 4 | 5 | import {User} from "@/GameClient/DataModel"; 6 | 7 | function stringToColor(string: string) { 8 | let hash = 0; 9 | let i; 10 | 11 | /* eslint-disable no-bitwise */ 12 | for (i = 0; i < string.length; i += 1) { 13 | hash = string.charCodeAt(i) + ((hash << 5) - hash); 14 | } 15 | 16 | let color = "#"; 17 | 18 | for (i = 0; i < 3; i += 1) { 19 | const value = (hash >> (i * 8)) & 0xff; 20 | color += `00${value.toString(16)}`.slice(-2); 21 | } 22 | /* eslint-enable no-bitwise */ 23 | 24 | return color; 25 | } 26 | 27 | function stringAvatar(name: string, sx?: SxProps) { 28 | const hue = tinycolor(stringToColor(name)).toHsl().h; 29 | 30 | const top: string = `hsl(${hue}, 90%, 70%)`; 31 | const bottom: string = `hsl(${hue}, 45%, 45%)`; 32 | const gradient: string = `linear-gradient(20deg, ${bottom} 0%, ${top} 100%);`; 33 | 34 | const letters = name 35 | .split(" ") 36 | .map((s) => s[0]) 37 | .slice(0, 2) 38 | .join(""); 39 | 40 | return { 41 | sx: [ 42 | {background: gradient, color: "white", fontSize: 15, paddingTop: "2px"}, 43 | ...(Array.isArray(sx) ? sx : [sx]), 44 | ], 45 | children: letters.toUpperCase(), 46 | }; 47 | } 48 | 49 | type OnlineBadgeProps = { 50 | paperBackground?: boolean; 51 | }; 52 | 53 | const OnlineBadge = styled(Badge, { 54 | shouldForwardProp: (prop) => prop !== "paperBackground", 55 | })(({theme, paperBackground}) => ({ 56 | "& .MuiBadge-badge": { 57 | backgroundColor: theme.palette.primary.main, 58 | color: theme.palette.primary.main, 59 | boxShadow: `0 0 0 2px ${paperBackground ? theme.palette.background.paper : theme.palette.background.default}`, 60 | "&::after": { 61 | position: "absolute", 62 | top: 0, 63 | left: 0, 64 | width: "100%", 65 | height: "100%", 66 | borderRadius: "50%", 67 | animation: "ripple 1.2s infinite ease-in-out", 68 | border: "1px solid currentColor", 69 | content: '""', 70 | }, 71 | }, 72 | "@keyframes ripple": { 73 | "0%": { 74 | transform: "scale(.8)", 75 | opacity: 1, 76 | }, 77 | "100%": { 78 | transform: "scale(2.4)", 79 | opacity: 0, 80 | }, 81 | }, 82 | })); 83 | 84 | export interface PlayerAvatarProps { 85 | user: User; 86 | sx?: SxProps; 87 | online?: boolean; 88 | paperBackground?: boolean; 89 | } 90 | 91 | export const UserAvatar: React.FC = ({user, sx, online, paperBackground}) => { 92 | let avatar: JSX.Element; 93 | if (user.avatarURL) { 94 | avatar = ; 95 | } else { 96 | avatar = ; 97 | } 98 | 99 | if (online) { 100 | return ( 101 | 106 | {avatar} 107 | 108 | ); 109 | } else { 110 | return avatar; 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /client/src/UI/UserAvatar/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UserAvatar"; 2 | -------------------------------------------------------------------------------- /client/src/UI/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UserAvatar"; 2 | export * from "./ThinkingIndicator"; 3 | -------------------------------------------------------------------------------- /client/src/assets/audio/move.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quatern1on/ChessNowBot/58461fb15d689e2841ac4d4b4275c06a4ac9801a/client/src/assets/audio/move.wav -------------------------------------------------------------------------------- /client/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import {initReactI18next} from "react-i18next"; 3 | 4 | const urlLang = new URLSearchParams(window.location.search).get("lang"); 5 | const webAppLang = window.Telegram.WebApp.initDataUnsafe.user?.language_code; 6 | 7 | i18n.use(initReactI18next).init({ 8 | resources: { 9 | en: { 10 | translation: { 11 | settings: { 12 | timer: "Enable timer", 13 | initTime: "Minutes per player", 14 | increment: "Increment in seconds", 15 | color: "Your color", 16 | colors: { 17 | black: "Black", 18 | random: "Random", 19 | white: "White", 20 | }, 21 | done: "Create", 22 | }, 23 | game: { 24 | connecting: "Connecting", 25 | waitingForUser: "Waiting for {{user}} to connect", 26 | waitingForAnyone: "Waiting for anyone to join", 27 | you: "You", 28 | giveUpPopup: { 29 | title: "Give up", 30 | content: "Are you sure you want to give up?", 31 | cancel: "Cancel", 32 | confirm: "Give up", 33 | }, 34 | }, 35 | error: { 36 | title: "Oops! Something went wrong.", 37 | message: "We apologize for the inconvenience. There was an error processing your request.", 38 | possibleSolutions: "Possible solutions:", 39 | checkInternet: "Check your internet connection.", 40 | refreshPage: "Try refreshing the page.", 41 | contactSupport: "Contact our support team for further assistance.", 42 | }, 43 | gameResult: { 44 | rematch: "Rematch", 45 | victory: { 46 | title: "Victory", 47 | explanation: { 48 | checkmate: "You checkmated your opponent", 49 | "out-of-time": "Your opponent ran out of time", 50 | "player-quit": "Your opponent quit the game", 51 | "give-up": "Your opponent gave up", 52 | }, 53 | }, 54 | defeat: { 55 | title: "Defeat", 56 | explanation: { 57 | checkmate: "Your opponent checkmated you", 58 | "out-of-time": "You ran out of time", 59 | "player-quit": "You quit the game", 60 | "give-up": "You gave up", 61 | }, 62 | }, 63 | draw: { 64 | title: "Draw", 65 | explanation: { 66 | stalemate: "Stalemate", 67 | draw: "Technical draw", 68 | }, 69 | }, 70 | spectator: { 71 | title: "Game Over", 72 | explanation: { 73 | checkmate: "{{winner}} checkmated {{loser}}", 74 | "out-of-time": "{{loser}}'s timer ran out", 75 | "player-quit": "{{loser}} left the game", 76 | "give-up": "{{loser}} gave up", 77 | stalemate: "Stalemate", 78 | draw: "Technical Draw", 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | ru: { 85 | translation: { 86 | settings: { 87 | timer: "Использовать таймер", 88 | initTime: "Минут на игрока", 89 | increment: "Бонусное время в секундах", 90 | color: "Ваш цвет", 91 | colors: { 92 | black: "Чёрный", 93 | random: "Случайно", 94 | white: "Белый", 95 | }, 96 | done: "Создать", 97 | }, 98 | game: { 99 | connecting: "Подключение", 100 | waitingForUser: "Ожидание подключения пользователя {{user}}", 101 | waitingForAnyone: "Ожидание пока кто-нибудь присоеденится", 102 | you: "Вы", 103 | giveUpPopup: { 104 | title: "Сдаться", 105 | content: "Вы уверены, что хотите сдаться?", 106 | cancel: "Отмена", 107 | confirm: "Сдаться", 108 | }, 109 | }, 110 | error: { 111 | title: "Упс! Что-то пошло не так.", 112 | message: "Приносим извинения за неудобства. Произошла ошибка при обработке вашего запроса.", 113 | possibleSolutions: "Возможные решения:", 114 | checkInternet: "Проверьте подключение к интернету.", 115 | refreshPage: "Попробуйте обновить страницу.", 116 | contactSupport: "Свяжитесь с нашей службой поддержки для получения дополнительной помощи.", 117 | }, 118 | gameResult: { 119 | rematch: "Реванш", 120 | victory: { 121 | title: "Победа", 122 | explanation: { 123 | checkmate: "Вы поставили мат вашему противнику", 124 | "out-of-time": "У вашего противника закончилось время", 125 | "player-quit": "Ваш противник покинул игру", 126 | "give-up": "Ваш противник сдался", 127 | }, 128 | }, 129 | defeat: { 130 | title: "Поражение", 131 | explanation: { 132 | checkmate: "Противник поставил вам мат", 133 | "out-of-time": "У вас закончилось время", 134 | "player-quit": "Вы покинули игру", 135 | "give-up": "Вы сдались", 136 | }, 137 | }, 138 | draw: { 139 | title: "Ничья", 140 | explanation: { 141 | stalemate: "Пат", 142 | draw: "Техническая ничья", 143 | }, 144 | }, 145 | spectator: { 146 | title: "Игра завершена", 147 | explanation: { 148 | checkmate: "{{winner}} поставили мат {{loser}}", 149 | "out-of-time": "Таймер {{loser}} опустился до нуля", 150 | "player-quit": "{{loser}} покинул игру", 151 | "give-up": "{{loser}} сдался", 152 | stalemate: "Пат", 153 | draw: "Техническая ничья", 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | uk: { 160 | translation: { 161 | settings: { 162 | timer: "Використовувати таймер", 163 | initTime: "Хвилин на гравця", 164 | increment: "Бонусний час у секундах", 165 | color: "Ваш колір", 166 | colors: { 167 | black: "Чорний", 168 | random: "Випадковий", 169 | white: "Білий", 170 | }, 171 | done: "Створити", 172 | }, 173 | game: { 174 | connecting: "Підключення", 175 | waitingForUser: "Очікування підключення користувача {{user}}", 176 | waitingForAnyone: "Очікування, доки хтось приєднається", 177 | you: "Ви", 178 | giveUpPopup: { 179 | title: "Здатися", 180 | content: "Ви впевнені, що хочете здатися?", 181 | cancel: "Скасувати", 182 | confirm: "Здатися", 183 | }, 184 | }, 185 | error: { 186 | title: "Ой! Щось пішло не так.", 187 | message: "Вибачте за незручності. Виникла помилка при обробці вашого запиту.", 188 | possibleSolutions: "Можливі рішення:", 189 | checkInternet: "Перевірте підключення до Інтернету.", 190 | refreshPage: "Спробуйте оновити сторінку.", 191 | contactSupport: "Зверніться до нашої служби підтримки для отримання додаткової допомоги.", 192 | }, 193 | gameResult: { 194 | rematch: "Реванш", 195 | victory: { 196 | title: "Перемога", 197 | explanation: { 198 | checkmate: "Ви поставили мат вашому супротивнику", 199 | "out-of-time": "У вашого супротивника закінчився час", 200 | "player-quit": "Ваш супротивник покинув гру", 201 | "give-up": "Ваш супротивник здався", 202 | }, 203 | }, 204 | defeat: { 205 | title: "Поразка", 206 | explanation: { 207 | checkmate: "Супротивник поставив вам мат", 208 | "out-of-time": "У вас закінчився час", 209 | "player-quit": "Ви покинули гру", 210 | "give-up": "Ви здались", 211 | }, 212 | }, 213 | draw: { 214 | title: "Нічия", 215 | explanation: { 216 | stalemate: "Пат", 217 | draw: "Технічна нічия", 218 | }, 219 | }, 220 | spectator: { 221 | title: "Гра завершена", 222 | explanation: { 223 | checkmate: "{{winner}} поставили мат {{loser}}", 224 | "out-of-time": "Таймер {{loser}} опустився до нуля", 225 | "player-quit": "{{loser}} покинув гру", 226 | "give-up": "{{loser}} здався", 227 | stalemate: "Пат", 228 | draw: "Технічна нічия", 229 | }, 230 | }, 231 | }, 232 | }, 233 | }, 234 | }, 235 | lng: urlLang || webAppLang || "en", 236 | fallbackLng: "en", 237 | 238 | interpolation: { 239 | escapeValue: false, 240 | }, 241 | }); 242 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | width: 100%; 4 | overflow: hidden; 5 | } 6 | 7 | body, #root { 8 | height: var(--tg-viewport-stable-height); 9 | transition: height .3s ease-in-out; 10 | overflow: visible; 11 | } 12 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import "@/i18n"; 3 | import "@fontsource/roboto/300.css"; 4 | import "@fontsource/roboto/400.css"; 5 | import "@fontsource/roboto/500.css"; 6 | import "@fontsource/roboto/700.css"; 7 | 8 | import React from "react"; 9 | import ReactDOM from "react-dom/client"; 10 | 11 | import {App} from "@/App"; 12 | 13 | ReactDOM.createRoot(document.getElementById("root")!).render(); 14 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Projects */ 4 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 5 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 6 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 7 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 8 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 9 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 10 | 11 | /* Language and Environment */ 12 | "target": "esnext" 13 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 14 | "lib": [ 15 | "dom", 16 | "dom.iterable", 17 | "esnext" 18 | ], 19 | /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 20 | "jsx": "react-jsx" 21 | /* Specify what JSX code is generated. */, 22 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 23 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 24 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 25 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 26 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 27 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 28 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 29 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 30 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 31 | 32 | /* Modules */ 33 | "module": "esnext" 34 | /* Specify what module code is generated. */, 35 | // "rootDir": "./", /* Specify the root folder within your source files. */ 36 | "moduleResolution": "node", 37 | /* Specify how TypeScript looks up a file from a given module specifier. */ 38 | "baseUrl": "./src" 39 | /* Specify the base directory to resolve non-relative module names. */, 40 | "paths": { 41 | "@/*": [ 42 | "*" 43 | ] 44 | } 45 | /* Specify a set of entries that re-map imports to additional lookup locations. */, 46 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 47 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 48 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 49 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 50 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 51 | "resolveJsonModule": true 52 | /* Enable importing .json files. */, 53 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 54 | 55 | /* JavaScript Support */ 56 | "allowJs": true 57 | /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 58 | "checkJs": false 59 | /* Enable error reporting in type-checked JavaScript files. */, 60 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 61 | 62 | /* Emit */ 63 | // "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 64 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 65 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 66 | "sourceMap": true 67 | /* Create source map files for emitted JavaScript files. */, 68 | // "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. */ 69 | "outDir": "./build", 70 | /* Specify an output folder for all emitted files. */ 71 | // "removeComments": true, /* Disable emitting comments. */ 72 | "noEmit": true, 73 | /* Disable emitting files from a compilation. */ 74 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 75 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 76 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 77 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 78 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 79 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 80 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 81 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 82 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 83 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 84 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 85 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 86 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 87 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 88 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 89 | 90 | /* Interop Constraints */ 91 | "isolatedModules": true, 92 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 93 | "allowSyntheticDefaultImports": true, 94 | /* Allow 'import x from y' when a module doesn't have a default export. */ 95 | "esModuleInterop": true 96 | /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 97 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 98 | "forceConsistentCasingInFileNames": true 99 | /* Ensure that casing is correct in imports. */, 100 | /* Type Checking */ 101 | "strict": false 102 | /* Enable all strict type-checking options. */, 103 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 104 | "strictNullChecks": true 105 | /* When type checking, take into account 'null' and 'undefined'. */, 106 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 107 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 108 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 109 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 110 | "useUnknownInCatchVariables": true, 111 | /* Default catch clause variables as 'unknown' instead of 'any'. */ 112 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 113 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 114 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 115 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 116 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 117 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 118 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 119 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 120 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 121 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 122 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 123 | 124 | /* Completeness */ 125 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 126 | "skipLibCheck": true, 127 | /* Skip type checking all .d.ts files. */ 128 | "types": [ 129 | "vite/client", 130 | "vite-plugin-svgr/client" 131 | ] 132 | }, 133 | "exclude": [ 134 | "node_modules", 135 | "build" 136 | ], 137 | "include": [ 138 | "./src" 139 | ] 140 | } 141 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import {defineConfig} from "vite"; 3 | import checker from "vite-plugin-checker"; 4 | import svgr from "vite-plugin-svgr"; 5 | import viteTsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | checker({ 11 | overlay: {initialIsOpen: false, position: "br"}, 12 | typescript: true, 13 | eslint: { 14 | lintCommand: 'eslint "./src/**/*.{ts,tsx}"', 15 | }, 16 | }), 17 | viteTsconfigPaths(), 18 | svgr(), 19 | ], 20 | 21 | server: { 22 | host: "0.0.0.0", 23 | port: 5173, 24 | }, 25 | build: {outDir: "build"}, 26 | 27 | optimizeDeps: { 28 | esbuildOptions: { 29 | // Node.js global to browser globalThis 30 | define: { 31 | global: "globalThis", 32 | }, 33 | }, 34 | }, 35 | 36 | css: { 37 | preprocessorOptions: { 38 | scss: { 39 | quietDeps: true, 40 | }, 41 | }, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint", 17 | "simple-import-sort" 18 | ], 19 | "rules": { 20 | "simple-import-sort/imports": "error", 21 | "@typescript-eslint/no-unused-vars": "off", 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "no-unused-vars": "off" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env 27 | config/local* 28 | public 29 | db.sqlite -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "es5", 4 | "tabWidth": 4, 5 | "singleQuote": false, 6 | "bracketSameLine": true, 7 | "bracketSpacing": false 8 | } 9 | -------------------------------------------------------------------------------- /server/config/default.json5: -------------------------------------------------------------------------------- 1 | { 2 | bot: { 3 | testEnv: false, 4 | }, 5 | server: { 6 | https: false, 7 | port: 3000, 8 | static: false, 9 | }, 10 | gameServer: { 11 | validateInitData: true, 12 | inactivityTimeout: 300, 13 | disconnectTimeout: 180, 14 | fakeRoom: { 15 | create: false, 16 | }, 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /server/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "button": { 3 | "wait": "Wait", 4 | "join": "Join", 5 | "play-again": "Play again", 6 | "play-another": "Play with someone else", 7 | "play": "Play" 8 | }, 9 | "message": { 10 | "creating-room": "Creating a gaming session.\n\n_Please wait a moment..._", 11 | "not-started": { 12 | "invitation": "User {user} wants to play chess.", 13 | "rules": "*Game Rules:* {rules}", 14 | "guide": "_Click the button below to join the game._" 15 | }, 16 | "in-game": { 17 | "introduction": "The game has started.", 18 | "description": "*Playing as White:* {whitePlayer}\n*Playing as Black:* {blackPlayer}", 19 | "rules": "*Game Rules:* {rules}", 20 | "guide": "_Click the button below to spectate the game._" 21 | }, 22 | "finished": { 23 | "resolution": { 24 | "victory": { 25 | "white": "The game ended with a victory for White.", 26 | "black": "The game ended with a victory for Black." 27 | }, 28 | "draw": "The game ended in a draw." 29 | }, 30 | "explanation": { 31 | "checkmate": "{winner} checkmated {loser}.", 32 | "out-of-time": "{loser} ran out of time on the timer.\n*Winner:* {winner}.", 33 | "player-quit": "{loser} exited the game.\n*Winner:* {winner}.", 34 | "give-up": "{loser} gave up.\n*Winner:* {winner}.", 35 | "stalemate": "Stalemate", 36 | "draw": "Technical draw." 37 | }, 38 | "description": "*Played as White:* {whitePlayer}\n*Played as Black:* {blackPlayer}", 39 | "guide": "_To create a new game invitation, click one of the buttons below or go to the chat which you want to send the invitation to, type in @{botName}, and add a space._" 40 | }, 41 | "destroyed": "No one joined the game, the invitation is no longer valid.\n\n_To create a new game invitation, click one of the buttons below or go to the chat which you want to send the invitation to, type in @{botName}, and add a space._" 42 | }, 43 | "rules": { 44 | "timer": { 45 | "absent": "No timer.", 46 | "present": "Timer: {start} min + {increment} sec." 47 | }, 48 | "color-1st-person": { 49 | "random": "Random color.", 50 | "white": "You start as White.", 51 | "black": "You start as Black." 52 | }, 53 | "color-3rd-person": { 54 | "random": "Random color.", 55 | "white": "The host starts as White.", 56 | "black": "The host starts as Black." 57 | }, 58 | "custom": "Custom game" 59 | }, 60 | "commands": { 61 | "start": "*Want to play chess with any contact from Telegram?*\nIt's very easy to do so, click the button below or go to the chat which you want to send the invitation to, type in @{botName}, and add a space.\nYou can also send the invitation to a group or channel. In that case, the first person to click the 'Join' button will be your opponent.", 62 | "stats": "*Your statistics:*\nGames played: *{userGamesPlayed}*\nGames won: *{userGamesWon}*\n\n*Bot statistics:*\nTotal unique users: *{totalUniqueUsers}*\nUnique users today: *{todayUniqueUsers}*\nTotal games played: *{totalGamesPlayed}*\nGames played today: *{todayGamesPlayed}*" 63 | } 64 | } -------------------------------------------------------------------------------- /server/locales/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "button": { 3 | "wait": "Подождите", 4 | "join": "Присоедениться", 5 | "play-again": "Сыграть ещё раз", 6 | "play-another": "Сыграть с кем-то другим", 7 | "play": "Сыграть" 8 | }, 9 | "message": { 10 | "creating-room": "Создаём игровую сессию.\n\n_Подождите немного..._", 11 | "not-started": { 12 | "invitation": "Пользователь {user} хочет сыграть в шахматы.", 13 | "rules": "*Правила игры:* {rules}", 14 | "guide": "_Нажмите на кнопку внизу что-бы присоедениться к игре._" 15 | }, 16 | "in-game": { 17 | "introduction": "Игра началась.", 18 | "description": "*За белых играет:* {whitePlayer}\n*За чёрных играет:* {blackPlayer}", 19 | "rules": "*Правила игры:* {rules}", 20 | "guide": "_Нажмите на кнопку внизу что-бы наблюдать за игрой._" 21 | }, 22 | "finished": { 23 | "resolution": { 24 | "victory": { 25 | "white": "Игра завершилась победой белых.", 26 | "black": "Игра завершилась победой чёрных." 27 | }, 28 | "draw": "Игра завершилась ничьёй." 29 | }, 30 | "explanation": { 31 | "checkmate": "{winner} поставил мат {loser}.", 32 | "out-of-time": "У {loser} истекло время на таймере.\n*Победитель:* {winner}.", 33 | "player-quit": "{loser} вышел из игры.\n*Победитель:* {winner}.", 34 | "give-up": "{loser} сдался.\n*Победитель:* {winner}.", 35 | "stalemate": "Пат", 36 | "draw": "Техническая ничья." 37 | }, 38 | "description": "*За белых играл:* {whitePlayer}\n*За чёрных играл:* {blackPlayer}", 39 | "guide": "_Что бы создать новое приглашение в игру, нажмите на одну из кнопок ниже или зайдите в чат, в который вы хотите отправить приглашение, напишите @{botName} и поставьте пробел._" 40 | }, 41 | "destroyed": "В игру так никто и не зашёл, приглашение больше недействительно.\n\n_Что бы создать новое приглашение в игру, нажмите на одну из кнопок ниже или зайдите в чат, в который вы хотите отправить приглашение, напишите @{botName} и поставьте пробел._" 42 | }, 43 | "rules": { 44 | "timer": { 45 | "absent": "Без таймера.", 46 | "present": "Таймер: {start} мин + {increment} сек." 47 | }, 48 | "color-1st-person": { 49 | "random": "Случайный цвет.", 50 | "white": "Вы начинаете за белых.", 51 | "black": "Вы начинаете за чёрных." 52 | }, 53 | "color-3rd-person": { 54 | "random": "Случайный цвет.", 55 | "white": "Хост начинает за белых.", 56 | "black": "Хост начинает за чёрных." 57 | }, 58 | "custom": "Своя игра" 59 | }, 60 | "commands": { 61 | "start": "*Хотите сыграть в шахматы с любым контактом из Telegram?*\nЭто очень просто сделать, нажмите на кнопку внизу либо зайдите в чат, в который вы хотите отправить приглашение, напишите @{botName} и поставьте пробел.\nПриглашение так же можно отправить в группу или канал. Тогда вашим оппонентом будет тот, кто первый нажмёт на кнопку \"Присоедениться\".", 62 | "stats": "*Ваша статистика:*\nИгр сыграно: *{userGamesPlayed}*\nИгр выиграно: *{userGamesWon}*\n\n*Статистика бота:*\nВсего уникальных пользователей: *{totalUniqueUsers}*\nУникальных пользователей за сегодня: *{todayUniqueUsers}*\nВсего игр сыграно: *{totalGamesPlayed}*\nИгр сыграно за сегодня: *{todayGamesPlayed}*" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /server/locales/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "button": { 3 | "wait": "Зачекайте", 4 | "join": "Приєднатися", 5 | "play-again": "Зіграти ще раз", 6 | "play-another": "Зіграти з кимось іншим", 7 | "play": "Зіграти" 8 | }, 9 | "message": { 10 | "creating-room": "Створюємо гру.\n\n_Зачекайте трошки..._", 11 | "not-started": { 12 | "invitation": "Користувач {user} бажає грати в шахи.", 13 | "rules": "*Правила гри:* {rules}", 14 | "guide": "_Натисніть на кнопку внизу, щоб приєднатися до гри._" 15 | }, 16 | "in-game": { 17 | "introduction": "Гра розпочалася.", 18 | "description": "*Грають за білих:* {whitePlayer}\n*Грають за чорних:* {blackPlayer}", 19 | "rules": "*Правила гри:* {rules}", 20 | "guide": "_Натисніть на кнопку внизу, щоб спостерігати за грою._" 21 | }, 22 | "finished": { 23 | "resolution": { 24 | "victory": { 25 | "white": "Гра завершилася перемогою білих.", 26 | "black": "Гра завершилася перемогою чорних." 27 | }, 28 | "draw": "Гра завершилася нічиєю." 29 | }, 30 | "explanation": { 31 | "checkmate": "{winner} виставив мат {loser}.", 32 | "out-of-time": "{loser} вичерпав час на таймері.\n*Переможець:* {winner}.", 33 | "player-quit": "{loser} вийшов з гри.\n*Переможець:* {winner}.", 34 | "give-up": "{loser} здався.\n*Переможець:* {winner}.", 35 | "stalemate": "Пат", 36 | "draw": "Технічна нічия." 37 | }, 38 | "description": "*Грали за білих:* {whitePlayer}\n*Грали за чорних:* {blackPlayer}", 39 | "guide": "_Щоб створити нове запрошення до гри, натисніть на одну з кнопок нижче або зайдіть в чат, в який ви хочете відправити запрошення, напишіть @{botName} і поставте пробіл._" 40 | }, 41 | "destroyed": "В гру так ніхто і не зайшов, запрошення більше неактивне.\n\n_Щоб створити нове запрошення до гри, натисніть на одну з кнопок нижче або зайдіть в чат, в який ви хочете відправити запрошення, напишіть @{botName} і поставте пробіл._" 42 | }, 43 | "rules": { 44 | "timer": { 45 | "absent": "Без таймера.", 46 | "present": "Таймер: {start} хв + {increment} сек." 47 | }, 48 | "color-1st-person": { 49 | "random": "Випадковий колір.", 50 | "white": "Ви починаєте за білих.", 51 | "black": "Ви починаєте за чорних." 52 | }, 53 | "color-3rd-person": { 54 | "random": "Випадковий колір.", 55 | "white": "Хост починає за білих.", 56 | "black": "Хост починає за чорних." 57 | }, 58 | "custom": "Власна гра" 59 | }, 60 | "commands": { 61 | "start": "*Хочете зіграти в шахи з будь-яким контактом з Telegram?*\nЦе дуже просто зробити, натисніть на кнопку внизу або зайдіть в чат, в який ви хочете відправити запрошення, напишіть @{botName} і поставте пробіл.\nЗапрошення також можна відправити в групу або канал. Тоді вашим суперником буде той, хто перший натисне на кнопку \"Приєднатися\".", 62 | "stats": "*Ваша статистика:*\nІгор зіграно: *{userGamesPlayed}*\nІгор виграно: *{userGamesWon}*\n\n*Статистика бота:*\nВсього унікальних користувачів: *{totalUniqueUsers}*\nУнікальних користувачів за сьогодні: *{todayUniqueUsers}*\nВсього ігор зіграно: *{totalGamesPlayed}*\nІгор зіграно за сьогодні: *{todayGamesPlayed}*" 63 | } 64 | } -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chess-now-server", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nodemon -r tsconfig-paths/register ./src/index.ts", 7 | "build": "rimraf ./dist && tsc", 8 | "start:dev": "npm run dev", 9 | "start:prod": "node -r tsconfig-paths/register dist" 10 | }, 11 | "dependencies": { 12 | "@types/config": "^3.3.1", 13 | "@types/debug": "^4.1.9", 14 | "@types/i18n": "^0.13.6", 15 | "@types/js-yaml": "^4.0.6", 16 | "@types/module-alias": "^2.0.2", 17 | "@types/serve-static": "^1.15.3", 18 | "axios": "^1.5.0", 19 | "chess.js": "^1.0.0-beta.6", 20 | "config": "^3.3.9", 21 | "debug": "^4.3.4", 22 | "dotenv": "^16.3.1", 23 | "i18n": "^0.15.1", 24 | "luxon": "^3.4.3", 25 | "sequelize": "^6.33.0", 26 | "serve-static": "^1.15.0", 27 | "short-unique-id": "^5.0.3", 28 | "socket.io": "^4.7.2", 29 | "sqlite3": "^5.1.6", 30 | "telegraf": "^4.14.0" 31 | }, 32 | "devDependencies": { 33 | "@types/luxon": "^3.3.2", 34 | "@types/node": "^20.6.3", 35 | "@typescript-eslint/eslint-plugin": "^6.7.3", 36 | "@typescript-eslint/parser": "^6.7.3", 37 | "eslint": "^8.50.0", 38 | "eslint-plugin-simple-import-sort": "^10.0.0", 39 | "nodemon": "^3.0.1", 40 | "prettier": "^3.0.3", 41 | "rimraf": "^5.0.5", 42 | "ts-node": "^10.9.1", 43 | "tsconfig-paths": "^4.2.0", 44 | "typescript": "^5.2.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/src/GameServer/DataModel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Color of the chest pieces 3 | */ 4 | export enum Color { 5 | White = "w", 6 | Black = "b", 7 | } 8 | 9 | /** 10 | * A 8x8 board square address in Algebraic notation 11 | * @see {@link https://en.wikipedia.org/wiki/Algebraic_notation_(chess)} 12 | */ 13 | export enum Square { 14 | A8 = "a8", 15 | B8 = "b8", 16 | C8 = "c8", 17 | D8 = "d8", 18 | E8 = "e8", 19 | F8 = "f8", 20 | G8 = "g8", 21 | H8 = "h8", 22 | A7 = "a7", 23 | B7 = "b7", 24 | C7 = "c7", 25 | D7 = "d7", 26 | E7 = "e7", 27 | F7 = "f7", 28 | G7 = "g7", 29 | H7 = "h7", 30 | A6 = "a6", 31 | B6 = "b6", 32 | C6 = "c6", 33 | D6 = "d6", 34 | E6 = "e6", 35 | F6 = "f6", 36 | G6 = "g6", 37 | H6 = "h6", 38 | A5 = "a5", 39 | B5 = "b5", 40 | C5 = "c5", 41 | D5 = "d5", 42 | E5 = "e5", 43 | F5 = "f5", 44 | G5 = "g5", 45 | H5 = "h5", 46 | A4 = "a4", 47 | B4 = "b4", 48 | C4 = "c4", 49 | D4 = "d4", 50 | E4 = "e4", 51 | F4 = "f4", 52 | G4 = "g4", 53 | H4 = "h4", 54 | A3 = "a3", 55 | B3 = "b3", 56 | C3 = "c3", 57 | D3 = "d3", 58 | E3 = "e3", 59 | F3 = "f3", 60 | G3 = "g3", 61 | H3 = "h3", 62 | A2 = "a2", 63 | B2 = "b2", 64 | C2 = "c2", 65 | D2 = "d2", 66 | E2 = "e2", 67 | F2 = "f2", 68 | G2 = "g2", 69 | H2 = "h2", 70 | A1 = "a1", 71 | B1 = "b1", 72 | C1 = "c1", 73 | D1 = "d1", 74 | E1 = "e1", 75 | F1 = "f1", 76 | G1 = "g1", 77 | H1 = "h1", 78 | } 79 | 80 | /** 81 | * Piece symbol (without color) 82 | */ 83 | export enum PieceSymbol { 84 | Pawn = "p", 85 | Knight = "n", 86 | Bishop = "b", 87 | Rook = "r", 88 | Queen = "q", 89 | King = "k", 90 | } 91 | 92 | /** 93 | * Represents a user authenticated through Telegram 94 | */ 95 | export interface User { 96 | /** 97 | * Telegram user's id 98 | * @see {@link https://core.telegram.org/bots/api#user} 99 | */ 100 | id: number; 101 | 102 | /** 103 | * Telegram user's first_name and last_name joined 104 | * @see {@link https://core.telegram.org/bots/api#user} 105 | */ 106 | fullName: string; 107 | 108 | /** 109 | * Telegram user's username 110 | * @see {@link https://core.telegram.org/bots/api#user} 111 | */ 112 | username?: string; 113 | 114 | /** 115 | * Data-URL of Telegram user's first profile picture 116 | */ 117 | avatarURL?: string; 118 | } 119 | 120 | /** 121 | * Represents dynamic state of room member 122 | */ 123 | export interface MemberState { 124 | /** 125 | * Whether the member has active connection to the room. A member could be present in the room but not connected, 126 | * for example if he/she leaves mid-game. 127 | *
128 | * Also, the user who created the room (the host) will always be present in the room (even if not connected) 129 | */ 130 | connected: boolean; 131 | 132 | /** 133 | * Whether the member has ability to participate in the game (e.g. make moves). All members have ability to watch 134 | * the game by default. 135 | */ 136 | isPlayer: boolean; 137 | 138 | /** 139 | * The color of the chest pieces the player controls. Guaranteed to be unique in the room. 140 | *
141 | * Present if {@link isPlayer} equals true 142 | */ 143 | color?: Color; 144 | } 145 | 146 | /** 147 | * Represents a member of the room and its state 148 | * @see Room 149 | */ 150 | export interface Member { 151 | /** 152 | * User profile of this member 153 | */ 154 | user: User; 155 | 156 | /** 157 | * Member's state 158 | */ 159 | state: MemberState; 160 | } 161 | 162 | /** 163 | * Represents the parameters of the game in current room, that could be set up before room creation 164 | * @see Room.gameRules 165 | */ 166 | export interface GameRules { 167 | /** 168 | * If host selected to play as a specific color, this field is equal to that color. Undefined is it should be picked 169 | * randomly. 170 | */ 171 | hostPreferredColor?: Color; 172 | 173 | /** 174 | * Whether players are limited in time to make a move. The timer does not reset when player makes a move. If the 175 | * player runs out of time he/she looses. 176 | */ 177 | timer: boolean; 178 | 179 | /** 180 | * Initial time on the timer for each player. Expressed in seconds. 181 | *
182 | * Present if {@link timer} equals true. 183 | */ 184 | initialTime?: number; 185 | 186 | /** 187 | * Amount by which the timer of the player should be incremented after he/she made a move 188 | *
189 | * Present if {@link plays} equals true. 190 | */ 191 | timerIncrement?: number; 192 | } 193 | 194 | /** 195 | * Status of the game in the current room 196 | * @see GameState.status 197 | */ 198 | export enum GameStatus { 199 | NotStarted = "not-started", 200 | InProgress = "in-progress", 201 | Finished = "finished", 202 | } 203 | 204 | /** 205 | * The result which the game was finished with 206 | */ 207 | export enum GameResolution { 208 | /** 209 | * Means in any game position a player's king is in check 210 | *
211 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 212 | * @see {@link https://en.wikipedia.org/wiki/Checkmate} 213 | */ 214 | Checkmate = "checkmate", 215 | 216 | /** 217 | * One of the players run out of timer time 218 | *
219 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 220 | */ 221 | OutOfTime = "out-of-time", 222 | 223 | /** 224 | * One of the players disconnected from the game and did not reconnect back in time 225 | *
226 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 227 | */ 228 | PlayerQuit = "player-quit", 229 | 230 | /** 231 | * One of the players gave up 232 | *
233 | * This resolution means there is a winner, so {@link GameState.winnerID} field will be set 234 | */ 235 | GiveUp = "give-up", 236 | 237 | /** 238 | * A situation where the player whose turn it is to move is not in check and has no legal move 239 | *
240 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set 241 | * @see https://en.wikipedia.org/wiki/Stalemate 242 | */ 243 | Stalemate = "stalemate", 244 | 245 | /** 246 | * Draw 247 | *
248 | * This resolution means there is a draw, so {@link GameState.winnerID} field will NOT be set 249 | * @see https://en.wikipedia.org/wiki/Draw_(chess) 250 | */ 251 | Draw = "draw", 252 | } 253 | 254 | /** 255 | * State of the game in current room 256 | */ 257 | export interface GameState { 258 | /** 259 | * Whether the game not-started, started or finished 260 | * @see GameStatus 261 | */ 262 | status: GameStatus; 263 | 264 | /** 265 | * History of all moves in PGN notation 266 | * @see {@link https://en.wikipedia.org/wiki/Portable_Game_Notation} 267 | */ 268 | pgn: string; 269 | 270 | /** 271 | * Whose side the current turn is 272 | */ 273 | turn: Color; 274 | 275 | /** 276 | * State of player's timers. Defined if {@link GameRules.timer} equals true. 277 | */ 278 | timer?: TimerState; 279 | 280 | /** 281 | * How the game finished 282 | *
283 | * Present if {@link status} equals {@link GameStatus.Finished} 284 | */ 285 | resolution?: GameResolution; 286 | 287 | /** 288 | * If the game finished in a win of one player, the ID of the user, who won the game 289 | */ 290 | winnerID?: number; 291 | } 292 | 293 | /** 294 | * Represents a game session, which players or spectators could connect 295 | */ 296 | export interface Room { 297 | /** 298 | * Unique string identifier 24 characters long 299 | */ 300 | id: string; 301 | 302 | /** 303 | * List of members currently present in the room. Not all present members are guaranteed to be connected. 304 | * @see MemberState.connected 305 | */ 306 | members: Member[]; 307 | 308 | /** 309 | * ID of the user who created the room. Guaranteed to be present in the {@link members}. 310 | * @see User.id 311 | */ 312 | hostID: number; 313 | 314 | /** 315 | * UNIX time (number of seconds since 00:00:00 UTC January 1, 1970) of the moment, when this room was created 316 | * @see {@link https://en.wikipedia.org/wiki/Unix_time} 317 | */ 318 | createdTimestamp: number; 319 | 320 | /** 321 | * Parameters of the game 322 | * @see GameRules 323 | */ 324 | gameRules: GameRules; 325 | 326 | /** 327 | * State of the game 328 | * @see GameState 329 | */ 330 | gameState: GameState; 331 | } 332 | 333 | /** 334 | * Represents a chess move 335 | */ 336 | export interface Move { 337 | /** 338 | * Which square the piece was moved from 339 | */ 340 | from: Square; 341 | 342 | /** 343 | * Which square the piece was moved to 344 | */ 345 | to: Square; 346 | 347 | /** 348 | * Present if the pawn was promoted to another piece. Contains which piece it was promoted to. 349 | */ 350 | promotion?: PieceSymbol; 351 | } 352 | 353 | /** 354 | * Represents a state of two player timers 355 | */ 356 | export interface TimerState { 357 | /** 358 | * Amount of time in milliseconds left on white player's timer 359 | */ 360 | whiteTimeLeft: number; 361 | 362 | /** 363 | * Amount of time in milliseconds left on black player's timer 364 | */ 365 | blackTimeLeft: number; 366 | } 367 | 368 | /** 369 | * Payload sent by client with new connection 370 | */ 371 | export interface AuthPayload { 372 | /** 373 | * initData field from window.Telegram.WebApp in the client. Needed fot authentication. Its genuinity will be 374 | * verified by the server. 375 | * @see {@link https://core.telegram.org/bots/webapps#initializing-mini-apps} 376 | * @see {@link https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app} 377 | */ 378 | initData: string; 379 | } 380 | 381 | /** 382 | * List of events that server could send to connected client 383 | */ 384 | export interface ServerToClientEvents { 385 | /** 386 | * Sent when error happens while processing client event. After an error happens, server closes the connection with 387 | * the client. 388 | * @param name Name of the Error class 389 | * @param message Message explaining the details of the error in english 390 | */ 391 | error: (name: string, message: string) => void; 392 | 393 | /** 394 | * Sent to client, when it initially connects to the room 395 | * @param room Room details the client connected to 396 | * @param userID ID of the user this connection is assigned to 397 | */ 398 | init: (room: Room, userID: number) => void; 399 | 400 | /** 401 | * Sent when another user joins the room 402 | * @param user The user who joined the room 403 | */ 404 | memberJoin: (member: Member) => void; 405 | 406 | /** 407 | * Sent when the members, present in the room leaves it. When the game begins, users who became players 408 | * ({@link MemberState.isPlayer} equals true) can not leave, they only can disconnect, and 409 | * {@link ServerToClientEvents.memberUpdate} will be called in this case. 410 | * @param userID ID of the user who left the room 411 | */ 412 | memberLeave: (userID: number) => void; 413 | 414 | /** 415 | * Sent when the member's state, that is currently present in the room updated 416 | * @param userID ID of the user, whose state is updated 417 | * @param state New state of the member 418 | */ 419 | memberUpdate: (userID: number, state: MemberState) => void; 420 | 421 | /** 422 | * Sent when current gameState of the room transitions from {@link GameStatus.NotStarted} to 423 | * {@link GameStatus.InProgress} 424 | */ 425 | gameStart: () => void; 426 | 427 | /** 428 | * Sent when current gameState of the room transitions from {@link GameStatus.InProgress} to 429 | * {@link GameStatus.Finished} 430 | * @param resolution How the game ended 431 | * @param winnerID ID of the user who won 432 | * @param timer Last timer recordings. Defined if {@link GameRules.timer} equals true. 433 | * @see GameState.winnerID 434 | */ 435 | gameEnd: (resolution: GameResolution, winnerID?: number, timer?: TimerState) => void; 436 | 437 | /** 438 | * Sent when a move was registered by server 439 | * @param move Move that was registered 440 | * @param timer State of player's timers. Defined if {@link GameRules.timer} equals true. 441 | */ 442 | move: (move: Move, timer?: TimerState) => void; 443 | } 444 | 445 | /** 446 | * List of events that client could send to server 447 | */ 448 | export interface ClientToServerEvents { 449 | /** 450 | * Make a move. The move should be validated on both server and client sides. If the move is illegal, or it is not 451 | * the players turn, server will return an error and close the connection. 452 | * @param move A move requested by client 453 | */ 454 | makeMove: (move: Move) => void; 455 | 456 | /** 457 | * User agreed to take a loss and voluntarily give up 458 | */ 459 | giveUp: () => void; 460 | } 461 | -------------------------------------------------------------------------------- /server/src/GameServer/Database.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Association, 3 | BelongsToCreateAssociationMixin, 4 | BelongsToGetAssociationMixin, 5 | BelongsToSetAssociationMixin, 6 | CreationOptional, 7 | DataTypes, 8 | ForeignKey, 9 | HasManyAddAssociationMixin, 10 | HasManyAddAssociationsMixin, 11 | HasManyCountAssociationsMixin, 12 | HasManyCreateAssociationMixin, 13 | HasManyGetAssociationsMixin, 14 | HasManyHasAssociationMixin, 15 | HasManyHasAssociationsMixin, 16 | HasManyRemoveAssociationMixin, 17 | HasManyRemoveAssociationsMixin, 18 | HasManySetAssociationsMixin, 19 | InferAttributes, 20 | InferCreationAttributes, 21 | Model, 22 | Sequelize, 23 | } from "sequelize"; 24 | 25 | import {Color, GameResolution} from "@/GameServer/DataModel.js"; 26 | 27 | const sequelize = new Sequelize({ 28 | dialect: "sqlite", 29 | storage: "db.sqlite", 30 | logging: false, 31 | define: { 32 | freezeTableName: true, 33 | }, 34 | }); 35 | 36 | export class UserProfile extends Model, InferCreationAttributes> { 37 | declare id: number; 38 | declare fullName: string; 39 | declare username: string | null; 40 | declare languageCode: string | null; 41 | 42 | declare createdAt: CreationOptional; 43 | declare updatedAt: CreationOptional; 44 | 45 | declare getWhiteGames: HasManyGetAssociationsMixin; 46 | declare addWhiteGame: HasManyAddAssociationMixin; 47 | declare addWhiteGames: HasManyAddAssociationsMixin; 48 | declare setWhiteGames: HasManySetAssociationsMixin; 49 | declare removeWhiteGame: HasManyRemoveAssociationMixin; 50 | declare removeWhiteGames: HasManyRemoveAssociationsMixin; 51 | declare hasWhiteGame: HasManyHasAssociationMixin; 52 | declare hasWhiteGames: HasManyHasAssociationsMixin; 53 | declare countWhiteGames: HasManyCountAssociationsMixin; 54 | declare createWhiteGame: HasManyCreateAssociationMixin; 55 | 56 | declare getBlackGames: HasManyGetAssociationsMixin; 57 | declare addBlackGame: HasManyAddAssociationMixin; 58 | declare addBlackGames: HasManyAddAssociationsMixin; 59 | declare setBlackGames: HasManySetAssociationsMixin; 60 | declare removeBlackGame: HasManyRemoveAssociationMixin; 61 | declare removeBlackGames: HasManyRemoveAssociationsMixin; 62 | declare hasBlackGame: HasManyHasAssociationMixin; 63 | declare hasBlackGames: HasManyHasAssociationsMixin; 64 | declare countBlackGames: HasManyCountAssociationsMixin; 65 | declare createBlackGame: HasManyCreateAssociationMixin; 66 | 67 | declare static associations: { 68 | whiteGames: Association; 69 | blackGames: Association; 70 | }; 71 | } 72 | 73 | UserProfile.init( 74 | { 75 | id: { 76 | type: DataTypes.INTEGER, 77 | primaryKey: true, 78 | allowNull: false, 79 | }, 80 | fullName: { 81 | type: DataTypes.STRING, 82 | allowNull: false, 83 | }, 84 | username: { 85 | type: DataTypes.STRING, 86 | defaultValue: null, 87 | }, 88 | languageCode: { 89 | type: DataTypes.STRING, 90 | defaultValue: null, 91 | }, 92 | createdAt: DataTypes.DATE, 93 | updatedAt: DataTypes.DATE, 94 | }, 95 | {sequelize} 96 | ); 97 | 98 | export class PlayedGame extends Model, InferCreationAttributes> { 99 | declare id: string; 100 | declare timerEnabled: boolean; 101 | declare timerInit: number; 102 | declare timerIncrement: number; 103 | declare pgn: string; 104 | declare resolution: GameResolution; 105 | declare winner: Color | null; 106 | 107 | declare createdAt: CreationOptional; 108 | 109 | declare whitePlayerID: ForeignKey; 110 | declare blackPlayerID: ForeignKey; 111 | 112 | declare getWhitePlayer: BelongsToGetAssociationMixin; 113 | declare setWhitePlayer: BelongsToSetAssociationMixin; 114 | declare createWhitePlayer: BelongsToCreateAssociationMixin; 115 | 116 | declare getBlackPlayer: BelongsToGetAssociationMixin; 117 | declare setBlackPlayer: BelongsToSetAssociationMixin; 118 | declare createBlackPlayer: BelongsToCreateAssociationMixin; 119 | 120 | declare static associations: { 121 | whitePlayer: Association; 122 | blackPlayer: Association; 123 | }; 124 | } 125 | 126 | PlayedGame.init( 127 | { 128 | id: { 129 | type: DataTypes.STRING, 130 | primaryKey: true, 131 | allowNull: false, 132 | }, 133 | timerEnabled: { 134 | type: DataTypes.BOOLEAN, 135 | allowNull: false, 136 | }, 137 | timerInit: { 138 | type: DataTypes.NUMBER, 139 | allowNull: false, 140 | }, 141 | timerIncrement: { 142 | type: DataTypes.NUMBER, 143 | allowNull: false, 144 | }, 145 | pgn: { 146 | type: DataTypes.STRING, 147 | allowNull: false, 148 | }, 149 | resolution: { 150 | type: DataTypes.STRING, 151 | allowNull: false, 152 | }, 153 | winner: { 154 | type: DataTypes.STRING, 155 | allowNull: true, 156 | }, 157 | createdAt: DataTypes.DATE, 158 | }, 159 | { 160 | sequelize, 161 | updatedAt: false, 162 | indexes: [ 163 | { 164 | unique: false, 165 | fields: ["winner"], 166 | }, 167 | { 168 | unique: false, 169 | fields: ["whitePlayerID"], 170 | }, 171 | { 172 | unique: false, 173 | fields: ["blackPlayerID"], 174 | }, 175 | ], 176 | } 177 | ); 178 | 179 | UserProfile.hasMany(PlayedGame, { 180 | as: "whiteGames", 181 | foreignKey: { 182 | name: "whitePlayerID", 183 | allowNull: false, 184 | }, 185 | onDelete: "RESTRICT", 186 | onUpdate: "RESTRICT", 187 | }); 188 | PlayedGame.belongsTo(UserProfile, { 189 | as: "whitePlayer", 190 | foreignKey: { 191 | name: "whitePlayerID", 192 | allowNull: false, 193 | }, 194 | onDelete: "RESTRICT", 195 | onUpdate: "RESTRICT", 196 | }); 197 | 198 | UserProfile.hasMany(PlayedGame, { 199 | as: "blackGames", 200 | foreignKey: { 201 | name: "blackPlayerID", 202 | allowNull: false, 203 | }, 204 | onDelete: "RESTRICT", 205 | onUpdate: "RESTRICT", 206 | }); 207 | PlayedGame.belongsTo(UserProfile, { 208 | as: "blackPlayer", 209 | foreignKey: { 210 | name: "blackPlayerID", 211 | allowNull: false, 212 | }, 213 | onDelete: "RESTRICT", 214 | onUpdate: "RESTRICT", 215 | }); 216 | 217 | (async () => { 218 | await sequelize.sync(); 219 | })(); 220 | -------------------------------------------------------------------------------- /server/src/GameServer/Errors.ts: -------------------------------------------------------------------------------- 1 | export class AlreadyConnectedError extends Error { 2 | constructor(message?: string) { 3 | super(message); 4 | this.name = "AlreadyConnectedError"; 5 | } 6 | } 7 | 8 | export class AuthError extends Error { 9 | constructor(message?: string) { 10 | super(message); 11 | this.name = "AuthError"; 12 | } 13 | } 14 | 15 | export class RoomNotFoundError extends Error { 16 | constructor(message?: string) { 17 | super(message); 18 | this.name = "RoomNotFoundError"; 19 | } 20 | } 21 | 22 | export class IllegalMoveError extends Error { 23 | constructor(message?: string) { 24 | super(message); 25 | this.name = "IllegalMoveError"; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/GameServer/GameServer.ts: -------------------------------------------------------------------------------- 1 | import config from "config"; 2 | import debug from "debug"; 3 | import http from "http"; 4 | import https from "https"; 5 | import * as SocketIO from "socket.io"; 6 | 7 | import {UserProfile} from "@/GameServer/Database"; 8 | import {AuthPayload, ClientToServerEvents, GameRules, ServerToClientEvents, User} from "@/GameServer/DataModel"; 9 | import {AuthError, RoomNotFoundError} from "@/GameServer/Errors"; 10 | import {ServerRoom} from "@/GameServer/ServerRoom"; 11 | import {catchErrors} from "@/GameServer/SocketErrorHandler"; 12 | import {bot} from "@/index"; 13 | import {joinFullName} from "@/Telegram/joinFullname"; 14 | import {parseInitData} from "@/Telegram/parseInitData"; 15 | import {WebAppInitData} from "@/Telegram/Types"; 16 | 17 | const log = debug("GameServer"); 18 | 19 | export type Server = SocketIO.Server< 20 | ClientToServerEvents, 21 | ServerToClientEvents, 22 | Record, 23 | Record 24 | >; 25 | export type Socket = SocketIO.Socket< 26 | ClientToServerEvents, 27 | ServerToClientEvents, 28 | Record, 29 | Record 30 | >; 31 | 32 | export class GameServer { 33 | private readonly io: Server; 34 | 35 | private readonly rooms: {[key: string]: ServerRoom}; 36 | 37 | private readonly validateInitData: boolean; 38 | 39 | constructor(server?: http.Server | https.Server) { 40 | this.io = new SocketIO.Server(server, { 41 | cors: { 42 | origin: "*", 43 | }, 44 | pingInterval: 2000, 45 | pingTimeout: 2000, 46 | transports: ["polling", "websocket"], 47 | }); 48 | 49 | this.rooms = {}; 50 | 51 | this.validateInitData = config.get("gameServer.validateInitData"); 52 | 53 | this.io.on("connect", (socket: Socket) => { 54 | catchErrors(socket)(this.handleConnection)(socket); 55 | }); 56 | 57 | log("GameServer instance was created"); 58 | 59 | if (config.get("gameServer.fakeRoom.create")) { 60 | const id = config.get("gameServer.fakeRoom.id"); 61 | const host = config.get("gameServer.fakeRoom.host"); 62 | const fakeRoomGameRules = config.get("gameServer.fakeRoom.gameRules"); 63 | 64 | this.createRoom(host, fakeRoomGameRules, id); 65 | log('Fake room("%s") was created', id); 66 | } 67 | } 68 | 69 | public get roomCount(): number { 70 | return Object.keys(this.rooms).length; 71 | } 72 | 73 | public createRoom = (host: User, gameRules: GameRules, id?: string): ServerRoom => { 74 | const room = new ServerRoom(host, gameRules, id); 75 | this.rooms[room.id] = room; 76 | room.on("destroy", () => { 77 | this.handleRoomDestroy(room.id); 78 | }); 79 | return room; 80 | }; 81 | 82 | private handleRoomDestroy = async (roomID: string) => { 83 | delete this.rooms[roomID]; 84 | log('Room("%s") was destroyed', roomID); 85 | }; 86 | 87 | private handleConnection = async (socket: Socket) => { 88 | log("New connection"); 89 | 90 | const authPayload = socket.handshake.auth as AuthPayload; 91 | 92 | const initData: WebAppInitData = parseInitData(authPayload.initData, this.validateInitData); 93 | 94 | const roomID = initData.start_param; 95 | 96 | if (!roomID) { 97 | throw new AuthError('Launched with no "startapp" param'); 98 | } 99 | 100 | if (!initData.user) { 101 | throw new AuthError("Information about the user was not provided"); 102 | } 103 | 104 | if (!this.rooms[roomID]) { 105 | throw new RoomNotFoundError(`Room with id "${roomID}" was not found`); 106 | } 107 | 108 | const user: User = { 109 | id: initData.user.id, 110 | fullName: joinFullName(initData.user.first_name, initData.user.last_name), 111 | username: initData.user.username, 112 | avatarURL: await bot.getAvatar(initData.user.id), 113 | }; 114 | 115 | await UserProfile.upsert({ 116 | id: user.id, 117 | fullName: user.fullName, 118 | username: user.username, 119 | languageCode: initData.user.language_code, 120 | }); 121 | 122 | log("User(%d: %s) trying to connect to room %s", user.id, user.fullName, roomID); 123 | 124 | //The user may have already disconnected while the server was retrieving his profile picture 125 | if (socket.connected) { 126 | this.rooms[roomID].acceptConnection(socket, user); 127 | } 128 | }; 129 | } 130 | -------------------------------------------------------------------------------- /server/src/GameServer/PausableTimer.ts: -------------------------------------------------------------------------------- 1 | import {DateTime} from "luxon"; 2 | 3 | export class PausableTimer { 4 | private readonly callback: () => void; 5 | 6 | private _timeLeft: number; 7 | 8 | private startTime: DateTime | null; 9 | 10 | private timeoutID: NodeJS.Timeout | null; 11 | 12 | constructor(callback: () => void, timeout?: number) { 13 | this.callback = callback; 14 | if (timeout) { 15 | this._timeLeft = timeout; 16 | } else { 17 | this._timeLeft = 0; 18 | } 19 | 20 | this.startTime = null; 21 | this.timeoutID = null; 22 | } 23 | 24 | public get isGoing(): boolean { 25 | return this.startTime !== null; 26 | } 27 | 28 | public get timeLeft(): number { 29 | if (this.startTime) { 30 | const timeSpent = DateTime.now().toMillis() - this.startTime.toMillis(); 31 | return this._timeLeft - timeSpent; 32 | } 33 | 34 | return this._timeLeft; 35 | } 36 | 37 | public readonly start = (timeout?: number): void => { 38 | if (timeout !== undefined) { 39 | this._timeLeft = timeout; 40 | } 41 | 42 | this.timeoutID = setTimeout(this.handleTimeout, this._timeLeft); 43 | this.startTime = DateTime.now(); 44 | }; 45 | 46 | public readonly pause = (): void => { 47 | if (!this.timeoutID || !this.startTime) { 48 | return; 49 | } 50 | clearTimeout(this.timeoutID); 51 | 52 | const timeSpent = DateTime.now().toMillis() - this.startTime.toMillis(); 53 | this._timeLeft -= timeSpent; 54 | if (this.timeLeft < 0) { 55 | this._timeLeft = 0; 56 | } 57 | 58 | this.timeoutID = null; 59 | this.startTime = null; 60 | }; 61 | 62 | public readonly stop = (): void => { 63 | if (!this.timeoutID || !this.startTime) { 64 | return; 65 | } 66 | clearTimeout(this.timeoutID); 67 | 68 | this._timeLeft = 0; 69 | 70 | this.timeoutID = null; 71 | this.startTime = null; 72 | }; 73 | 74 | public readonly addTime = (time: number): void => { 75 | if (this.isGoing) { 76 | this.pause(); 77 | this._timeLeft += time; 78 | this.start(); 79 | } else { 80 | this._timeLeft += time; 81 | } 82 | }; 83 | 84 | private readonly handleTimeout = (): void => { 85 | this._timeLeft = 0; 86 | this.startTime = null; 87 | this.timeoutID = null; 88 | this.callback(); 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /server/src/GameServer/SocketErrorHandler.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | 3 | import {Socket} from "@/GameServer/GameServer"; 4 | 5 | const log = debug("GameServer:SocketErrorHandler"); 6 | 7 | type CallbackType = (...args: CallbackParameters) => void | Promise; 8 | 9 | export const catchErrors = (socket?: Socket) => { 10 | return (callback: CallbackType): CallbackType => { 11 | const handleError = (err: unknown) => { 12 | if (err instanceof Error) { 13 | if (socket && socket.connected) { 14 | let name = err.name; 15 | const message = err.message; 16 | 17 | if (!name) { 18 | name = "Unknown"; 19 | } 20 | 21 | try { 22 | socket.emit("error", name, message); 23 | socket.disconnect(); 24 | log("Socket was disconnected due to error: %O", err); 25 | } catch (e) { 26 | log("A new error was thrown while handling error: %O", e); 27 | } 28 | } else { 29 | if (socket) { 30 | log("An error happened, but no socket was provided: %O", err); 31 | } else { 32 | log("An error happened, but socket is already disconnected: %O", err); 33 | } 34 | } 35 | } else { 36 | if (socket && socket.connected) { 37 | try { 38 | socket.disconnect(); 39 | } catch (e) { 40 | log("A new error was thrown while closing the connection: %O", e); 41 | } 42 | } 43 | log("Thrown error was not an instance of Error class: %o", err); 44 | } 45 | }; 46 | 47 | return (...args: CallbackParameters): void | Promise => { 48 | try { 49 | const ret = callback.apply(this, args); 50 | if (ret && typeof ret.catch === "function") { 51 | // async handler 52 | ret.catch(handleError); 53 | } 54 | return ret; 55 | } catch (e) { 56 | // sync handler 57 | handleError(e); 58 | } 59 | }; 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /server/src/GameServer/TypedEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | 3 | export class TypedEventEmitter> { 4 | private emitter = new EventEmitter(); 5 | 6 | protected emit(eventName: TEventName, ...eventArg: TEvents[TEventName]) { 7 | this.emitter.emit(eventName, ...(eventArg as [])); 8 | } 9 | 10 | public on( 11 | eventName: TEventName, 12 | handler: (...eventArg: TEvents[TEventName]) => void 13 | ) { 14 | this.emitter.on(eventName, handler as unknown as any); 15 | } 16 | 17 | public off( 18 | eventName: TEventName, 19 | handler: (...eventArg: TEvents[TEventName]) => void 20 | ) { 21 | this.emitter.off(eventName, handler as any); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/src/Telegram/ChessNowBot.ts: -------------------------------------------------------------------------------- 1 | import {UserProfilePhotos} from "@telegraf/types/manage"; 2 | import {InlineKeyboardButton} from "@telegraf/types/markup"; 3 | import {PhotoSize} from "@telegraf/types/message"; 4 | import axios from "axios"; 5 | import config from "config"; 6 | import debug from "debug"; 7 | import i18n from "i18n"; 8 | import {isAbsolute} from "path"; 9 | import {Op} from "sequelize"; 10 | import {Context, NarrowedContext, Telegraf} from "telegraf"; 11 | import * as tg from "telegraf/src/core/types/typegram"; 12 | import {URL} from "url"; 13 | 14 | import {PlayedGame, UserProfile} from "@/GameServer/Database"; 15 | import {Color, GameRules, GameStatus, User} from "@/GameServer/DataModel"; 16 | import {GameServer} from "@/GameServer/GameServer"; 17 | import {catchErrors} from "@/GameServer/SocketErrorHandler"; 18 | import {joinFullName} from "@/Telegram/joinFullname"; 19 | import {MessageGenerator} from "@/Telegram/MessageGenerator"; 20 | import {parseGameRulesQuery} from "@/Telegram/parseGameRulesQuery"; 21 | 22 | interface InlineGameDescription { 23 | id: string; 24 | title: string; 25 | gameRules: GameRules; 26 | thumbnailUrl?: string; 27 | } 28 | 29 | type PredefinedGamesConfig = {[key: string]: InlineGameDescription}; 30 | 31 | const log = debug("ChessNowBot"); 32 | 33 | export class ChessNowBot { 34 | private readonly bot: Telegraf; 35 | 36 | private readonly gameServer: GameServer; 37 | 38 | private readonly token: string; 39 | 40 | private readonly predefinedGames: PredefinedGamesConfig; 41 | 42 | private readonly customThumbnailURL: string | undefined; 43 | 44 | private readonly webAppCustomizeURL: string; 45 | 46 | private readonly webAppGameName: string; 47 | 48 | private readonly messageGenerators: {[key: string]: MessageGenerator}; 49 | 50 | constructor(gameServer: GameServer) { 51 | this.gameServer = gameServer; 52 | 53 | this.token = config.get("bot.token"); 54 | 55 | this.predefinedGames = {}; 56 | 57 | if (config.has("gameServer.gameModes")) { 58 | const predefinedGamesConfig = config.get("gameServer.gameModes"); 59 | 60 | for (const id of Object.keys(predefinedGamesConfig)) { 61 | if (id !== "custom") { 62 | this.predefinedGames[id] = Object.assign({id: "#" + id}, predefinedGamesConfig[id]); 63 | } 64 | } 65 | } 66 | 67 | if (config.has("gameServer.gameModes.custom.thumbnailUrl")) { 68 | this.customThumbnailURL = config.get("gameServer.gameModes.custom.thumbnailUrl"); 69 | } 70 | 71 | this.webAppCustomizeURL = config.get("webApp.customize.url"); 72 | 73 | this.webAppGameName = config.get("webApp.game.name"); 74 | 75 | this.messageGenerators = {}; 76 | 77 | for (const locale of i18n.getLocales()) { 78 | this.messageGenerators[locale] = new MessageGenerator(locale); 79 | } 80 | 81 | this.bot = new Telegraf(this.token, { 82 | telegram: { 83 | testEnv: config.get("bot.testEnv"), 84 | }, 85 | }); 86 | 87 | this.bot.on("inline_query", this.handleInlineQuery); 88 | this.bot.on("chosen_inline_result", this.handleInlineResult); 89 | this.bot.command("start", this.handleStartCommand); 90 | this.bot.command("stats", this.handleStatsCommand); 91 | 92 | this.bot.catch((err, ctx) => { 93 | console.error(err); 94 | }); 95 | 96 | log("ChessNowBot instance was created"); 97 | } 98 | 99 | public readonly launch = async (): Promise => { 100 | log("ChessNowBot has been launched"); 101 | await this.bot.launch(); 102 | }; 103 | 104 | public readonly getAvatar = async (userID: number): Promise => { 105 | let userProfilePhotos: UserProfilePhotos; 106 | try { 107 | userProfilePhotos = await this.bot.telegram.getUserProfilePhotos(userID, 0, 1); 108 | } catch (e) { 109 | return undefined; 110 | } 111 | 112 | if (userProfilePhotos.total_count === 0) { 113 | return undefined; 114 | } 115 | 116 | const avatarSizes = userProfilePhotos.photos[0]; 117 | 118 | //Select the smallest PhotoSize that is still larger than targetResolution 119 | const targetResolution = 128; 120 | avatarSizes.sort((a, b) => b.width - a.width); 121 | let selectedSize: PhotoSize = avatarSizes[0]; 122 | for (const size of avatarSizes) { 123 | if (size.width > targetResolution && size.height > targetResolution) { 124 | selectedSize = size; 125 | } 126 | } 127 | 128 | const fileURL = await this.getFileLink(selectedSize.file_id); 129 | 130 | const response = await axios.get(fileURL.href, { 131 | responseType: "arraybuffer", 132 | }); 133 | const returnedB64 = Buffer.from(response.data).toString("base64"); 134 | 135 | return "data:image/jpeg;base64," + returnedB64; 136 | }; 137 | 138 | private readonly getMessageGenerator = (locale?: string): MessageGenerator => { 139 | if (locale) { 140 | return ( 141 | this.messageGenerators[locale] || 142 | this.messageGenerators[i18n.getLocale()] || 143 | this.messageGenerators["en"] 144 | ); 145 | } 146 | 147 | return this.messageGenerators[i18n.getLocale()] || this.messageGenerators["en"]; 148 | }; 149 | 150 | private readonly handleStartCommand = async ( 151 | ctx: Context<{message: tg.Update.New & tg.Update.NonChannel & tg.Message.TextMessage; update_id: number}> 152 | ): Promise => { 153 | const tgUser = ctx.update.message.from; 154 | const msg = this.getMessageGenerator(tgUser.language_code); 155 | 156 | const [userProfile] = await UserProfile.upsert({ 157 | id: tgUser.id, 158 | fullName: joinFullName(tgUser.first_name, tgUser.last_name), 159 | username: tgUser.username, 160 | languageCode: tgUser.language_code, 161 | }); 162 | 163 | await ctx.reply(msg.t("commands.start", {botName: ctx.botInfo.username}), { 164 | parse_mode: "Markdown", 165 | reply_markup: { 166 | inline_keyboard: [ 167 | [ 168 | { 169 | text: msg.t("button.play"), 170 | switch_inline_query_chosen_chat: { 171 | query: "", 172 | allow_bot_chats: false, 173 | allow_channel_chats: true, 174 | allow_group_chats: true, 175 | allow_user_chats: true, 176 | }, 177 | }, 178 | ], 179 | ], 180 | }, 181 | }); 182 | }; 183 | 184 | private readonly handleStatsCommand = async ( 185 | ctx: Context<{message: tg.Update.New & tg.Update.NonChannel & tg.Message.TextMessage; update_id: number}> 186 | ): Promise => { 187 | const tgUser = ctx.update.message.from; 188 | const msg = this.getMessageGenerator(tgUser.language_code); 189 | 190 | const [userProfile] = await UserProfile.upsert({ 191 | id: tgUser.id, 192 | fullName: joinFullName(tgUser.first_name, tgUser.last_name), 193 | username: tgUser.username, 194 | languageCode: tgUser.language_code, 195 | }); 196 | 197 | const userGamesPlayed = await PlayedGame.count({ 198 | where: { 199 | [Op.or]: [{whitePlayerID: userProfile.id}, {blackPlayerID: userProfile.id}], 200 | }, 201 | }); 202 | 203 | const userGamesWon = await PlayedGame.count({ 204 | where: { 205 | [Op.or]: [ 206 | { 207 | [Op.and]: [{whitePlayerID: userProfile.id}, {winner: Color.White}], 208 | }, 209 | { 210 | [Op.and]: [{blackPlayerID: userProfile.id}, {winner: Color.Black}], 211 | }, 212 | ], 213 | }, 214 | }); 215 | 216 | const totalUniqueUsers = await UserProfile.count({}); 217 | 218 | const todayUniqueUsers = await UserProfile.count({ 219 | where: { 220 | updatedAt: { 221 | [Op.gte]: new Date(new Date().getTime() - 24 * 60 * 60 * 1000), 222 | }, 223 | }, 224 | }); 225 | 226 | const totalGamesPlayed = await PlayedGame.count({}); 227 | 228 | const todayGamesPlayed = await PlayedGame.count({ 229 | where: { 230 | createdAt: { 231 | [Op.gte]: new Date(new Date().getTime() - 24 * 60 * 60 * 1000), 232 | }, 233 | }, 234 | }); 235 | 236 | await ctx.reply( 237 | msg.t("commands.stats", { 238 | userGamesPlayed: userGamesPlayed.toString(), 239 | userGamesWon: userGamesWon.toString(), 240 | totalUniqueUsers: totalUniqueUsers.toString(), 241 | todayUniqueUsers: todayUniqueUsers.toString(), 242 | totalGamesPlayed: totalGamesPlayed.toString(), 243 | todayGamesPlayed: todayGamesPlayed.toString(), 244 | }), 245 | { 246 | parse_mode: "Markdown", 247 | } 248 | ); 249 | }; 250 | 251 | private readonly handleInlineQuery = async (ctx: NarrowedContext) => { 252 | log( 253 | 'Received new inline query("%s") from user(%d) with language code "%s"', 254 | ctx.inlineQuery.query, 255 | ctx.inlineQuery.from.id, 256 | ctx.inlineQuery.from.language_code 257 | ); 258 | const msg = this.getMessageGenerator(ctx.inlineQuery.from.language_code); 259 | let games: InlineGameDescription[]; 260 | 261 | const query = ctx.update.inline_query.query; 262 | if (query) { 263 | try { 264 | games = [ 265 | { 266 | id: query, 267 | title: msg.t("rules.custom"), 268 | gameRules: parseGameRulesQuery(query), 269 | thumbnailUrl: this.customThumbnailURL, 270 | }, 271 | ]; 272 | } catch (e) { 273 | return; 274 | } 275 | } else { 276 | games = Object.values(this.predefinedGames); 277 | } 278 | 279 | const waitButton: tg.InlineKeyboardButton = { 280 | text: msg.t("button.wait"), 281 | callback_data: "creating-room", 282 | }; 283 | 284 | const results: tg.InlineQueryResult[] = games.map((game) => ({ 285 | type: "article", 286 | id: game.id, 287 | title: game.title, 288 | description: msg.gameRulesToString(game.gameRules, true), 289 | thumbnail_url: game.thumbnailUrl, 290 | input_message_content: { 291 | message_text: msg.creatingRoomMessage(), 292 | parse_mode: "Markdown", 293 | }, 294 | reply_markup: { 295 | inline_keyboard: [[waitButton]], 296 | }, 297 | })); 298 | 299 | await ctx.answerInlineQuery(results, { 300 | button: { 301 | web_app: { 302 | url: this.webAppCustomizeURL + "?lang=" + ctx.inlineQuery.from.language_code, 303 | }, 304 | text: msg.t("rules.custom"), 305 | }, 306 | cache_time: 0, 307 | }); 308 | }; 309 | 310 | private readonly handleInlineResult = async ( 311 | ctx: NarrowedContext 312 | ): Promise => { 313 | const tgUser = ctx.update.chosen_inline_result.from; 314 | const msg = this.getMessageGenerator(tgUser.language_code); 315 | 316 | const host: User = { 317 | id: tgUser.id, 318 | fullName: joinFullName(tgUser.first_name, tgUser.last_name), 319 | username: tgUser.username, 320 | avatarURL: await this.getAvatar(tgUser.id), 321 | }; 322 | 323 | log('User(%d: %s) selected inline result("%s")', host.id, host.fullName, ctx.chosenInlineResult.result_id); 324 | 325 | const resultID = ctx.chosenInlineResult.result_id; 326 | let gameRules: GameRules; 327 | if (resultID.startsWith("#")) { 328 | gameRules = this.predefinedGames[resultID.slice(1)].gameRules; 329 | } else { 330 | gameRules = parseGameRulesQuery(resultID); 331 | } 332 | 333 | const messageID = ctx.chosenInlineResult.inline_message_id!; 334 | 335 | await UserProfile.upsert({ 336 | id: host.id, 337 | fullName: host.fullName, 338 | username: host.username, 339 | languageCode: tgUser.language_code, 340 | }); 341 | 342 | const room = this.gameServer.createRoom(host, gameRules); 343 | 344 | const gameUrl = `https://t.me/${this.bot.botInfo!.username}/${this.webAppGameName}?startapp=${room.id}`; 345 | 346 | const updateMessage = async () => { 347 | let messageText: string; 348 | let keyboard: InlineKeyboardButton[][]; 349 | if (room.gameStatus === GameStatus.NotStarted) { 350 | messageText = msg.notStartedMessage(room); 351 | const joinButton: tg.InlineKeyboardButton = { 352 | text: msg.t("button.join"), 353 | url: gameUrl, 354 | }; 355 | keyboard = [[joinButton]]; 356 | } else if (room.gameStatus === GameStatus.InProgress) { 357 | messageText = msg.inGameMessage(room); 358 | const joinButton: tg.InlineKeyboardButton = { 359 | text: msg.t("button.join"), 360 | url: gameUrl, 361 | }; 362 | keyboard = [[joinButton]]; 363 | } else if (room.gameStatus === GameStatus.Finished) { 364 | messageText = msg.gameFinishedMessage(room, ctx.botInfo.username); 365 | const playAgainButton: tg.InlineKeyboardButton = { 366 | text: msg.t("button.play-again"), 367 | switch_inline_query_current_chat: "", 368 | }; 369 | const playAnotherButton: tg.InlineKeyboardButton = { 370 | text: msg.t("button.play-another"), 371 | switch_inline_query_chosen_chat: { 372 | query: "", 373 | allow_bot_chats: false, 374 | allow_channel_chats: true, 375 | allow_user_chats: true, 376 | allow_group_chats: true, 377 | }, 378 | }; 379 | keyboard = [[playAgainButton], [playAnotherButton]]; 380 | } else { 381 | throw new Error("Invalid game status"); 382 | } 383 | 384 | await this.bot.telegram.editMessageText(undefined, undefined, messageID, messageText, { 385 | parse_mode: "Markdown", 386 | reply_markup: { 387 | inline_keyboard: keyboard, 388 | }, 389 | }); 390 | }; 391 | 392 | room.on("gameStatusChange", catchErrors()(updateMessage)); 393 | 394 | room.on( 395 | "destroy", 396 | catchErrors()(async () => { 397 | if (room.gameStatus !== GameStatus.NotStarted) { 398 | return; 399 | } 400 | 401 | const playAgainButton: tg.InlineKeyboardButton = { 402 | text: msg.t("button.play-again"), 403 | switch_inline_query_current_chat: "", 404 | }; 405 | const playAnotherButton: tg.InlineKeyboardButton = { 406 | text: msg.t("button.play-another"), 407 | switch_inline_query_chosen_chat: { 408 | query: "", 409 | allow_bot_chats: false, 410 | allow_channel_chats: true, 411 | allow_user_chats: true, 412 | allow_group_chats: true, 413 | }, 414 | }; 415 | 416 | const messageText = msg.roomDestroyedMessage(ctx.botInfo.username); 417 | 418 | await this.bot.telegram.editMessageText(undefined, undefined, messageID, messageText, { 419 | parse_mode: "Markdown", 420 | reply_markup: { 421 | inline_keyboard: [[playAgainButton], [playAnotherButton]], 422 | }, 423 | }); 424 | }) 425 | ); 426 | 427 | await updateMessage(); 428 | }; 429 | 430 | private readonly getFileLink = async (fileId: string | tg.File): Promise => { 431 | if (typeof fileId === "string") { 432 | fileId = await this.bot.telegram.getFile(fileId); 433 | } else if (fileId.file_path === undefined) { 434 | fileId = await this.bot.telegram.getFile(fileId.file_id); 435 | } 436 | 437 | // Local bot API instances return the absolute path to the file 438 | if (fileId.file_path !== undefined && isAbsolute(fileId.file_path)) { 439 | const url = new URL(this.bot.telegram.options.apiRoot); 440 | url.port = ""; 441 | url.pathname = fileId.file_path; 442 | url.protocol = "file:"; 443 | return url; 444 | } 445 | 446 | let path: string; 447 | if (this.bot.telegram.options.testEnv) { 448 | path = `./file/${this.bot.telegram.options.apiMode}${this.token}/test/${fileId.file_path!}`; 449 | } else { 450 | path = `./file/${this.bot.telegram.options.apiMode}${this.token}/${fileId.file_path!}`; 451 | } 452 | 453 | return new URL(path, this.bot.telegram.options.apiRoot); 454 | }; 455 | } 456 | -------------------------------------------------------------------------------- /server/src/Telegram/MessageGenerator.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18n"; 2 | 3 | import {Color, GameRules, User} from "@/GameServer/DataModel"; 4 | import {ServerRoom} from "@/GameServer/ServerRoom"; 5 | 6 | export class MessageGenerator { 7 | public readonly locale: string; 8 | 9 | public constructor(locale: string) { 10 | this.locale = locale; 11 | } 12 | 13 | public readonly t = (phrase: string, replacements?: i18n.Replacements): string => { 14 | if (replacements) { 15 | return i18n.__mf({phrase, locale: this.locale}, replacements); 16 | } 17 | 18 | return i18n.__({phrase, locale: this.locale}); 19 | }; 20 | 21 | public readonly gameRulesToString = (gameRules: GameRules, firstPerson: boolean): string => { 22 | let description: string; 23 | 24 | if (gameRules.timer) { 25 | description = this.t("rules.timer.present", { 26 | start: (gameRules.initialTime! / 60).toString(), 27 | increment: gameRules.timerIncrement!.toString(), 28 | }); 29 | } else { 30 | description = this.t("rules.timer.absent"); 31 | } 32 | 33 | let colorPhrase = firstPerson ? "rules.color-1st-person" : "rules.color-3rd-person"; 34 | 35 | if (gameRules.hostPreferredColor === Color.White) { 36 | colorPhrase += ".white"; 37 | } else if (gameRules.hostPreferredColor === Color.Black) { 38 | colorPhrase += ".black"; 39 | } else { 40 | colorPhrase += ".random"; 41 | } 42 | description += " " + this.t(colorPhrase); 43 | 44 | return description; 45 | }; 46 | 47 | public readonly creatingRoomMessage = (): string => { 48 | return this.t("message.creating-room"); 49 | }; 50 | 51 | public readonly roomDestroyedMessage = (botName: string): string => { 52 | return this.t("message.destroyed", {botName}); 53 | }; 54 | 55 | public readonly notStartedMessage = (room: ServerRoom): string => { 56 | const invitation = this.t("message.not-started.invitation", { 57 | user: this.inlineUserLink(room.host), 58 | }); 59 | 60 | const rules = this.t("message.not-started.rules", { 61 | rules: this.gameRulesToString(room.gameRules, false), 62 | }); 63 | 64 | const guide = this.t("message.not-started.guide"); 65 | 66 | return `${invitation}\n\n${rules}\n\n${guide}`; 67 | }; 68 | 69 | public readonly inGameMessage = (room: ServerRoom): string => { 70 | const introduction = this.t("message.in-game.introduction"); 71 | 72 | const description = this.t("message.in-game.description", { 73 | whitePlayer: this.inlineUserLink(room.whitePlayer), 74 | blackPlayer: this.inlineUserLink(room.blackPlayer), 75 | }); 76 | 77 | const rules = this.t("message.in-game.rules", { 78 | rules: this.gameRulesToString(room.gameRules, false), 79 | }); 80 | 81 | const guide = this.t("message.in-game.guide"); 82 | 83 | return `${introduction}\n${description}\n\n${rules}\n\n${guide}`; 84 | }; 85 | 86 | public readonly gameFinishedMessage = (room: ServerRoom, botName: string): string => { 87 | const gameState = room.gameState(); 88 | 89 | let resolution; 90 | let replacements: i18n.Replacements | undefined; 91 | if (gameState.winnerID) { 92 | if (gameState.winnerID === room.whitePlayer.id) { 93 | replacements = { 94 | winner: this.inlineUserLink(room.whitePlayer), 95 | loser: this.inlineUserLink(room.blackPlayer), 96 | }; 97 | 98 | resolution = this.t("message.finished.resolution.victory.white"); 99 | } else { 100 | replacements = { 101 | winner: this.inlineUserLink(room.blackPlayer), 102 | loser: this.inlineUserLink(room.whitePlayer), 103 | }; 104 | resolution = this.t("message.finished.resolution.victory.black"); 105 | } 106 | } else { 107 | resolution = this.t("message.finished.resolution.draw"); 108 | } 109 | 110 | const explanation = this.t("message.finished.explanation." + gameState.resolution, replacements); 111 | 112 | const description = this.t("message.finished.description", { 113 | whitePlayer: this.inlineUserLink(room.whitePlayer), 114 | blackPlayer: this.inlineUserLink(room.blackPlayer), 115 | }); 116 | 117 | const guide = this.t("message.finished.guide", {botName}); 118 | 119 | return `${resolution}\n${explanation}\n\n${description}\n\n${guide}`; 120 | }; 121 | 122 | private readonly inlineUserLink = (user: User): string => { 123 | return `[${user.fullName + (user.username ? ` (@${user.username})` : "")}](tg://user?id=${user.id})`; 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /server/src/Telegram/Types.ts: -------------------------------------------------------------------------------- 1 | export interface WebAppUser { 2 | id: number; 3 | is_bot?: boolean; 4 | first_name: string; 5 | last_name?: string; 6 | username?: string; 7 | language_code?: string; 8 | is_premium?: true; 9 | added_to_attachment_menu?: true; 10 | allows_write_to_pm?: true; 11 | photo_url?: string; 12 | } 13 | 14 | export interface WebAppChat { 15 | id: number; 16 | type: string; 17 | title: string; 18 | username?: string; 19 | photo_url?: string; 20 | } 21 | 22 | export interface WebAppInitData { 23 | query_id?: string; 24 | user?: WebAppUser; 25 | receiver?: WebAppUser; 26 | chat?: WebAppChat; 27 | chat_type?: string; 28 | chat_instance?: string; 29 | start_param?: string; 30 | can_send_after?: number; 31 | auth_date: number; 32 | hash: string; 33 | } 34 | -------------------------------------------------------------------------------- /server/src/Telegram/joinFullname.ts: -------------------------------------------------------------------------------- 1 | export const joinFullName = (firstName: string, lastName?: string): string => { 2 | if (!lastName) { 3 | return firstName; 4 | } 5 | return `${firstName} ${lastName}`; 6 | }; 7 | -------------------------------------------------------------------------------- /server/src/Telegram/parseGameRulesQuery.ts: -------------------------------------------------------------------------------- 1 | import {Color, GameRules} from "@/GameServer/DataModel"; 2 | 3 | export const parseGameRulesQuery = (query: string): GameRules => { 4 | const regex = /^\$(0|1)(?::(\d+):(\d+))?:(w|b|r)$/; 5 | 6 | const match = query.trim().match(regex); 7 | 8 | if (!match) { 9 | throw new Error("Invalid query"); 10 | } 11 | 12 | const [, timerEnabledStr, initialTimeStr, timerIncrementStr, hostPreferredColorStr] = match; 13 | 14 | const timerEnabled: boolean = timerEnabledStr === "1"; 15 | const hostPreferredColor: Color | undefined = 16 | hostPreferredColorStr === "r" ? undefined : (hostPreferredColorStr as Color); 17 | 18 | let initialTime: number | undefined; 19 | let timerIncrement: number | undefined; 20 | 21 | if (timerEnabled) { 22 | if (!initialTimeStr || !timerIncrementStr) { 23 | throw new Error("Invalid query"); 24 | } 25 | 26 | initialTime = parseInt(initialTimeStr, 10); 27 | timerIncrement = parseInt(timerIncrementStr, 10); 28 | } 29 | 30 | return { 31 | hostPreferredColor, 32 | timer: timerEnabled, 33 | initialTime, 34 | timerIncrement, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /server/src/Telegram/parseInitData.ts: -------------------------------------------------------------------------------- 1 | import config from "config"; 2 | import crypto, {BinaryLike, BinaryToTextEncoding, KeyObject} from "crypto"; 3 | 4 | import {WebAppInitData} from "@/Telegram/Types"; 5 | 6 | function HmacSHA256(key: BinaryLike | KeyObject, data: string): Buffer; 7 | function HmacSHA256(key: BinaryLike | KeyObject, data: string, encoding: BinaryToTextEncoding): string; 8 | 9 | function HmacSHA256(key: BinaryLike | KeyObject, data: string, encoding?: BinaryToTextEncoding) { 10 | const hmac = crypto.createHmac("sha256", key); 11 | hmac.update(data); 12 | if (encoding) { 13 | return hmac.digest(encoding); 14 | } else { 15 | return hmac.digest(); 16 | } 17 | } 18 | 19 | const secretKey = HmacSHA256("WebAppData", config.get("bot.token")); 20 | 21 | class ValidationError extends Error { 22 | constructor(message?: string) { 23 | super(message); 24 | this.name = "ValidationError"; 25 | } 26 | } 27 | 28 | export function parseInitData(initData: string, validate: boolean = true): WebAppInitData { 29 | const params = new URLSearchParams(initData); 30 | 31 | if (validate) { 32 | const providedHash = params.get("hash"); 33 | if (!providedHash) { 34 | throw new ValidationError('"hash" parameter is not present in initData'); 35 | } 36 | params.delete("hash"); 37 | 38 | const entries = Array.from(params.entries()); 39 | entries.sort((a, b) => a[0].localeCompare(b[0])); 40 | 41 | const pairs = entries.map(([key, value]) => key + "=" + value); 42 | const dataCheckString = pairs.join("\n"); 43 | 44 | const calculatedHash = HmacSHA256(secretKey, dataCheckString, "hex"); 45 | 46 | if (providedHash !== calculatedHash) { 47 | throw new ValidationError("Hash mismatch"); 48 | } 49 | 50 | params.set("hash", providedHash); 51 | } 52 | 53 | const entries = Array.from(params.entries()); 54 | const initDataObject: {[key: string]: any} = {}; 55 | 56 | for (const [key, value] of entries) { 57 | if (key === "can_send_after" || key === "auth_date") { 58 | initDataObject[key] = Number.parseInt(value); 59 | } else if ( 60 | (value.slice(0, 1) == "{" && value.slice(-1) == "}") || 61 | (value.slice(0, 1) == "[" && value.slice(-1) == "]") 62 | ) { 63 | initDataObject[key] = JSON.parse(value); 64 | } else { 65 | initDataObject[key] = value; 66 | } 67 | } 68 | 69 | return initDataObject as WebAppInitData; 70 | } 71 | -------------------------------------------------------------------------------- /server/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18n"; 2 | import path from "path"; 3 | 4 | i18n.configure({ 5 | directory: path.join("locales"), 6 | defaultLocale: "en", 7 | objectNotation: true, 8 | retryInDefaultLocale: true, 9 | updateFiles: false, 10 | }); 11 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import "@/i18n"; 3 | 4 | import config from "config"; 5 | import * as fs from "fs"; 6 | import http from "http"; 7 | import https from "https"; 8 | import serveStatic from "serve-static"; 9 | 10 | import {GameServer} from "@/GameServer/GameServer"; 11 | import {ChessNowBot} from "@/Telegram/ChessNowBot"; 12 | 13 | const serve = serveStatic("public", { 14 | index: ["index.html"], 15 | }); 16 | 17 | let requestHandler; 18 | if (config.get("server.static")) { 19 | requestHandler = ( 20 | req: http.IncomingMessage, 21 | res: http.ServerResponse & {req: http.IncomingMessage} 22 | ) => { 23 | serve(req, res, (err) => { 24 | res.writeHead(err?.statusCode || 404); 25 | res.end(); 26 | }); 27 | }; 28 | } 29 | 30 | let server: http.Server | https.Server; 31 | 32 | if (config.get("server.https")) { 33 | const options = { 34 | key: fs.readFileSync(config.get("server.key")), 35 | cert: fs.readFileSync(config.get("server.cert")), 36 | }; 37 | 38 | server = https.createServer(options, requestHandler); 39 | } else { 40 | server = http.createServer(requestHandler); 41 | } 42 | 43 | const gameServer = new GameServer(server); 44 | 45 | export const bot = new ChessNowBot(gameServer); 46 | 47 | server.listen(config.get("server.port")); 48 | bot.launch(); 49 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", 15 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 27 | 28 | /* Modules */ 29 | "module": "commonjs", 30 | /* Specify what module code is generated. */ 31 | "rootDir": "./src", /* Specify the root folder within your source files. */ 32 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 33 | // "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ 34 | "paths": { 35 | "@/*": [ 36 | "./src/*", 37 | "./dist/*" 38 | ] 39 | }, 40 | /* Specify a set of entries that re-map imports to additional lookup locations. */ 41 | // "rootDirs": ["./src"], /* Allow multiple folders to be treated as one when resolving modules. */ 42 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 43 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 44 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 45 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 46 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 47 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 48 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 49 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 50 | // "resolveJsonModule": true, /* Enable importing .json files. */ 51 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 52 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 53 | 54 | /* JavaScript Support */ 55 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 56 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 57 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 58 | 59 | /* Emit */ 60 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 61 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 62 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 63 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 64 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 65 | // "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. */ 66 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 67 | // "removeComments": true, /* Disable emitting comments. */ 68 | // "noEmit": true, /* Disable emitting files from a compilation. */ 69 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 70 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 71 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 72 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 73 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 74 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 75 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 76 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 77 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 78 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 79 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 80 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 81 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 82 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 83 | 84 | /* Interop Constraints */ 85 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 86 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 87 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 88 | "esModuleInterop": true, 89 | /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 90 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 91 | "forceConsistentCasingInFileNames": true, 92 | /* Ensure that casing is correct in imports. */ 93 | 94 | /* Type Checking */ 95 | "strict": true, 96 | /* Enable all strict type-checking options. */ 97 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 98 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 99 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 100 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 101 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 102 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 103 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 104 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 105 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 106 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 107 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 108 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 109 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 110 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 111 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 112 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 113 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 114 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 115 | 116 | /* Completeness */ 117 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 118 | "skipLibCheck": true 119 | /* Skip type checking all .d.ts files. */ 120 | }, 121 | } 122 | --------------------------------------------------------------------------------