Ben Ashton
2 years ago
commit
f08b7da487
41 changed files with 27200 additions and 0 deletions
@ -0,0 +1,20 @@ |
|||||||
|
module.exports = { |
||||||
|
"env": { |
||||||
|
"es2021": true, |
||||||
|
"node": true |
||||||
|
}, |
||||||
|
"extends": [ |
||||||
|
"eslint:recommended", |
||||||
|
"plugin:@typescript-eslint/recommended" |
||||||
|
], |
||||||
|
"parser": "@typescript-eslint/parser", |
||||||
|
"parserOptions": { |
||||||
|
"ecmaVersion": "latest", |
||||||
|
"sourceType": "module" |
||||||
|
}, |
||||||
|
"plugins": [ |
||||||
|
"@typescript-eslint" |
||||||
|
], |
||||||
|
"rules": { |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
lerna-debug.log* |
||||||
|
.pnpm-debug.log* |
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html) |
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json |
||||||
|
|
||||||
|
# Runtime data |
||||||
|
pids |
||||||
|
*.pid |
||||||
|
*.seed |
||||||
|
*.pid.lock |
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover |
||||||
|
lib-cov |
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul |
||||||
|
coverage |
||||||
|
*.lcov |
||||||
|
|
||||||
|
# nyc test coverage |
||||||
|
.nyc_output |
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) |
||||||
|
.grunt |
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/) |
||||||
|
bower_components |
||||||
|
|
||||||
|
# node-waf configuration |
||||||
|
.lock-wscript |
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html) |
||||||
|
build/Release |
||||||
|
|
||||||
|
# Dependency directories |
||||||
|
node_modules/ |
||||||
|
jspm_packages/ |
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/) |
||||||
|
web_modules/ |
||||||
|
|
||||||
|
# TypeScript cache |
||||||
|
*.tsbuildinfo |
||||||
|
|
||||||
|
# Optional npm cache directory |
||||||
|
.npm |
||||||
|
|
||||||
|
# Optional eslint cache |
||||||
|
.eslintcache |
||||||
|
|
||||||
|
# Optional stylelint cache |
||||||
|
.stylelintcache |
||||||
|
|
||||||
|
# Microbundle cache |
||||||
|
.rpt2_cache/ |
||||||
|
.rts2_cache_cjs/ |
||||||
|
.rts2_cache_es/ |
||||||
|
.rts2_cache_umd/ |
||||||
|
|
||||||
|
# Optional REPL history |
||||||
|
.node_repl_history |
||||||
|
|
||||||
|
# Output of 'npm pack' |
||||||
|
*.tgz |
||||||
|
|
||||||
|
# Yarn Integrity file |
||||||
|
.yarn-integrity |
||||||
|
|
||||||
|
# dotenv environment variable files |
||||||
|
.env |
||||||
|
.env.development.local |
||||||
|
.env.test.local |
||||||
|
.env.production.local |
||||||
|
.env.local |
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/) |
||||||
|
.cache |
||||||
|
.parcel-cache |
||||||
|
|
||||||
|
# Next.js build output |
||||||
|
.next |
||||||
|
out |
||||||
|
|
||||||
|
# Nuxt.js build / generate output |
||||||
|
.nuxt |
||||||
|
dist |
||||||
|
|
||||||
|
# Gatsby files |
||||||
|
.cache/ |
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js |
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support |
||||||
|
# public |
||||||
|
|
||||||
|
# vuepress build output |
||||||
|
.vuepress/dist |
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory |
||||||
|
.temp |
||||||
|
.cache |
||||||
|
|
||||||
|
# Docusaurus cache and generated files |
||||||
|
.docusaurus |
||||||
|
|
||||||
|
# Serverless directories |
||||||
|
.serverless/ |
||||||
|
|
||||||
|
# FuseBox cache |
||||||
|
.fusebox/ |
||||||
|
|
||||||
|
# DynamoDB Local files |
||||||
|
.dynamodb/ |
||||||
|
|
||||||
|
# TernJS port file |
||||||
|
.tern-port |
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions |
||||||
|
.vscode-test |
||||||
|
|
||||||
|
# yarn v2 |
||||||
|
.yarn/cache |
||||||
|
.yarn/unplugged |
||||||
|
.yarn/build-state.yml |
||||||
|
.yarn/install-state.gz |
||||||
|
.pnp.* |
Binary file not shown.
@ -0,0 +1,24 @@ |
|||||||
|
{ |
||||||
|
"name": "snakes_and_ladders", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "", |
||||||
|
"main": "main.js", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"test": "echo \"Error: no test specified\" && exit 1", |
||||||
|
"build": "npx tsc", |
||||||
|
"start": "node ./dist/main.js", |
||||||
|
"sample": "node ./dist/main.js \"./sample_data/tournament_board.json\" \"./sample_data/game_logs.csv\"" |
||||||
|
}, |
||||||
|
"author": "Ben Ashton", |
||||||
|
"license": "ISC", |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^17.0.35", |
||||||
|
"@typescript-eslint/eslint-plugin": "^5.26.0", |
||||||
|
"@typescript-eslint/parser": "^5.26.0", |
||||||
|
"eslint": "^8.16.0", |
||||||
|
"eslint-config-prettier": "^8.5.0", |
||||||
|
"prettier": "2.6.2", |
||||||
|
"typescript": "^4.7.2" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
{ |
||||||
|
"size": 100, |
||||||
|
"ladders": [ |
||||||
|
{ "landing": 4, "destination": 10 }, |
||||||
|
{ "landing": 6, "destination": 14 }, |
||||||
|
{ "landing": 31, "destination": 40 }, |
||||||
|
{ "landing": 32, "destination": 34 }, |
||||||
|
{ "landing": 48, "destination": 55 }, |
||||||
|
{ "landing": 57, "destination": 58 }, |
||||||
|
{ "landing": 77, "destination": 87 }, |
||||||
|
{ "landing": 79, "destination": 80 } |
||||||
|
], |
||||||
|
"snakes": [ |
||||||
|
{ "landing": 13, "destination": 9 }, |
||||||
|
{ "landing": 21, "destination": 14 }, |
||||||
|
{ "landing": 28, "destination": 11 }, |
||||||
|
{ "landing": 39, "destination": 33 }, |
||||||
|
{ "landing": 62, "destination": 43 }, |
||||||
|
{ "landing": 65, "destination": 63 }, |
||||||
|
{ "landing": 66, "destination": 60 }, |
||||||
|
{ "landing": 75, "destination": 71 } |
||||||
|
] |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
{ |
||||||
|
"folders": |
||||||
|
[ |
||||||
|
{ |
||||||
|
"path": "/home/ben/Programming/Work/AuditSoft/snakes_and_ladders" |
||||||
|
} |
||||||
|
], |
||||||
|
"settings": |
||||||
|
{ |
||||||
|
"LSP": |
||||||
|
{ |
||||||
|
"lsp-typescript": |
||||||
|
{ |
||||||
|
"enabled": false |
||||||
|
}, |
||||||
|
"typescript-language-server": |
||||||
|
{ |
||||||
|
"enabled": true |
||||||
|
} |
||||||
|
}, |
||||||
|
"on_pre_save_language": |
||||||
|
[ |
||||||
|
{ |
||||||
|
"command": "js_prettier" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@ |
|||||||
|
export class BaseError extends Error {} |
@ -0,0 +1,124 @@ |
|||||||
|
import { readFile } from "fs/promises"; |
||||||
|
import { BoardLoadError } from "./errors/board_load_error.js"; |
||||||
|
|
||||||
|
interface ISnakeOrLadder { |
||||||
|
landing: number; |
||||||
|
destination: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface IBoardData { |
||||||
|
size: number; |
||||||
|
ladders: ISnakeOrLadder[]; |
||||||
|
snakes: ISnakeOrLadder[]; |
||||||
|
} |
||||||
|
|
||||||
|
export class Board { |
||||||
|
public size: number; |
||||||
|
private snakeMap: Map<number, number> = new Map(); |
||||||
|
private ladderMap: Map<number, number> = new Map(); |
||||||
|
|
||||||
|
static async loadFromFile( |
||||||
|
filename: string, |
||||||
|
encoding: BufferEncoding = "utf-8" |
||||||
|
) { |
||||||
|
let rawData; |
||||||
|
try { |
||||||
|
rawData = await readFile(filename, { encoding }); |
||||||
|
} catch (err) { |
||||||
|
throw new BoardLoadError(`Unable to read file: ${filename}`); |
||||||
|
} |
||||||
|
|
||||||
|
let jsonData; |
||||||
|
try { |
||||||
|
jsonData = JSON.parse(rawData); |
||||||
|
} catch (err) { |
||||||
|
throw new BoardLoadError("Invalid JSON"); |
||||||
|
} |
||||||
|
|
||||||
|
if (typeof jsonData !== "object") { |
||||||
|
throw new BoardLoadError("JSON does not contain board object"); |
||||||
|
} |
||||||
|
|
||||||
|
if ( |
||||||
|
!("ladders" in jsonData && "snakes" in jsonData && "size" in jsonData) |
||||||
|
) { |
||||||
|
throw new BoardLoadError("Missing properties in board object"); |
||||||
|
} |
||||||
|
|
||||||
|
if (!Number.isInteger(jsonData.size)) { |
||||||
|
throw new BoardLoadError("Invalid board size"); |
||||||
|
} |
||||||
|
|
||||||
|
const boardData: IBoardData = { |
||||||
|
size: jsonData.size, |
||||||
|
snakes: [], |
||||||
|
ladders: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const props: Array<"snakes" | "ladders"> = ["snakes", "ladders"]; |
||||||
|
for (const prop of props) { |
||||||
|
const transportData = jsonData[prop]; |
||||||
|
|
||||||
|
if (!Array.isArray(transportData)) { |
||||||
|
throw new BoardLoadError(`Invalid "${prop}" property in board object`); |
||||||
|
} |
||||||
|
|
||||||
|
for (const transport of transportData) { |
||||||
|
if ( |
||||||
|
typeof transport !== "object" || |
||||||
|
!("landing" in transport) || |
||||||
|
!Number.isInteger(transport.landing) || |
||||||
|
!("destination" in transport) || |
||||||
|
!Number.isInteger(transport.destination) |
||||||
|
) { |
||||||
|
const repr = JSON.stringify(transport); |
||||||
|
// Remove plural
|
||||||
|
const transportType = prop.replace(/s$/, prop); |
||||||
|
throw new BoardLoadError(`Invalid ${transportType} entry: ${repr}`); |
||||||
|
} |
||||||
|
|
||||||
|
boardData[prop].push({ |
||||||
|
landing: transport.landing, |
||||||
|
destination: transport.destination, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return new Board(boardData); |
||||||
|
} |
||||||
|
|
||||||
|
constructor(boardData: IBoardData) { |
||||||
|
this.size = boardData.size; |
||||||
|
for (const transport of boardData.snakes) { |
||||||
|
if (transport.landing <= transport.destination) { |
||||||
|
throw new BoardLoadError( |
||||||
|
"Invalid snake, landing not greater than destination" |
||||||
|
); |
||||||
|
} |
||||||
|
this.snakeMap.set(transport.landing, transport.destination); |
||||||
|
} |
||||||
|
for (const transport of boardData.ladders) { |
||||||
|
if (transport.landing >= transport.destination) { |
||||||
|
throw new BoardLoadError( |
||||||
|
"Invalid ladder, landing not less than destination" |
||||||
|
); |
||||||
|
} |
||||||
|
this.ladderMap.set(transport.landing, transport.destination); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
tileHasSnake(pos: number) { |
||||||
|
return this.snakeMap.has(pos); |
||||||
|
} |
||||||
|
|
||||||
|
tileHasLadder(pos: number) { |
||||||
|
return this.ladderMap.has(pos); |
||||||
|
} |
||||||
|
|
||||||
|
calculateLandingPosition(pos: number) { |
||||||
|
return Math.min( |
||||||
|
this.ladderMap.get(pos) ?? this.snakeMap.get(pos) ?? pos, |
||||||
|
this.size |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
import { GameError } from "./game_error.js"; |
||||||
|
|
||||||
|
export class BoardLoadError extends GameError { |
||||||
|
constructor(message: string, options?: ErrorOptions) { |
||||||
|
super(`Unable to load board: ${message}`, options); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
import { GameError } from "./game_error.js"; |
||||||
|
|
||||||
|
export class DuplicatePlayerError extends GameError { |
||||||
|
constructor(gameId: number, playerId: number, options?: ErrorOptions) { |
||||||
|
super(`Game: ${gameId} already has player ${playerId}`, options); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
import { GameError } from "./game_error.js"; |
||||||
|
|
||||||
|
export class GameCompleteError extends GameError { |
||||||
|
constructor(gameId: number, playerId: number, options?: ErrorOptions) { |
||||||
|
super( |
||||||
|
`Player: ${playerId} attempted to participate in game: ${gameId}, but ` + |
||||||
|
`this game has finished`, |
||||||
|
options |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
import { BaseError } from "../../common_errors/base_error.js"; |
||||||
|
export class GameError extends BaseError {} |
@ -0,0 +1,16 @@ |
|||||||
|
import { GameError } from "./game_error.js"; |
||||||
|
|
||||||
|
export class NotParticipatingError extends GameError { |
||||||
|
constructor( |
||||||
|
gameId: number, |
||||||
|
playerId: number, |
||||||
|
action: string, |
||||||
|
options?: ErrorOptions |
||||||
|
) { |
||||||
|
super( |
||||||
|
`Attempted to perform action: "${action}" with player: ${playerId} ` + |
||||||
|
`despite them not being a participent of game: ${gameId}`, |
||||||
|
options |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
import { GameError } from "./game_error.js"; |
||||||
|
|
||||||
|
export class OutOfTurnError extends GameError { |
||||||
|
constructor(gameId: number, playerId: number, options?: ErrorOptions) { |
||||||
|
super( |
||||||
|
`Player: ${playerId} attempted to roll dice out of turn in game: ` + |
||||||
|
`${gameId}`, |
||||||
|
options |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
export class GameEvent { |
||||||
|
constructor(public gameId: number) {} |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
import { GameEvent } from "./game_event.js"; |
||||||
|
|
||||||
|
export class GameStartedEvent extends GameEvent {} |
@ -0,0 +1,7 @@ |
|||||||
|
import { GameEvent } from "./game_event.js"; |
||||||
|
|
||||||
|
export class PlayerJoinedEvent extends GameEvent { |
||||||
|
constructor(gameId: number, public playerId: number) { |
||||||
|
super(gameId); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
import { GameEvent } from "./game_event.js"; |
||||||
|
|
||||||
|
export class PlayerRolledEvent extends GameEvent { |
||||||
|
constructor(gameId: number, public playerId: number, public roll: number) { |
||||||
|
super(gameId); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,110 @@ |
|||||||
|
import { Board } from "./board.js"; |
||||||
|
|
||||||
|
import { Player } from "./player.js"; |
||||||
|
|
||||||
|
import { DuplicatePlayerError } from "./errors/duplicate_player_error.js"; |
||||||
|
import { NotParticipatingError } from "./errors/not_participating_error.js"; |
||||||
|
import { OutOfTurnError } from "./errors/out_of_turn_error.js"; |
||||||
|
import { GameCompleteError } from "./errors/game_complete_error.js"; |
||||||
|
|
||||||
|
class PlayerData { |
||||||
|
pos: number = 0; |
||||||
|
snakeCount: number = 0; |
||||||
|
ladderCount: number = 0; |
||||||
|
} |
||||||
|
|
||||||
|
export class Game { |
||||||
|
public players: Map<Player, PlayerData> = new Map(); |
||||||
|
private winningPlayer: Player | undefined; |
||||||
|
private nextPlayer: Player | undefined; |
||||||
|
|
||||||
|
constructor(public gameId: number, public board: Board) {} |
||||||
|
|
||||||
|
get playerCount() { |
||||||
|
return this.players.size; |
||||||
|
} |
||||||
|
|
||||||
|
get winner() { |
||||||
|
return this.winningPlayer; |
||||||
|
} |
||||||
|
|
||||||
|
get isComplete() { |
||||||
|
return this.winningPlayer !== undefined; |
||||||
|
} |
||||||
|
|
||||||
|
addPlayer(player: Player) { |
||||||
|
if (this.hasPlayer(player)) { |
||||||
|
new DuplicatePlayerError(this.gameId, player.playerId); |
||||||
|
} |
||||||
|
this.players.set(player, new PlayerData()); |
||||||
|
} |
||||||
|
|
||||||
|
hasPlayer(player: Player) { |
||||||
|
return this.players.has(player); |
||||||
|
} |
||||||
|
|
||||||
|
rollDice(player: Player, roll: number) { |
||||||
|
const playerData = this.players.get(player); |
||||||
|
if (!playerData) { |
||||||
|
throw new NotParticipatingError( |
||||||
|
this.gameId, |
||||||
|
player.playerId, |
||||||
|
"roll dice" |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (this.nextPlayer && this.nextPlayer !== player) { |
||||||
|
throw new OutOfTurnError(this.gameId, player.playerId); |
||||||
|
} |
||||||
|
|
||||||
|
if (this.winningPlayer) { |
||||||
|
throw new GameCompleteError(this.gameId, player.playerId); |
||||||
|
} |
||||||
|
|
||||||
|
const rollPos = playerData.pos + roll; |
||||||
|
|
||||||
|
if (this.board.tileHasLadder(rollPos)) { |
||||||
|
playerData.ladderCount++; |
||||||
|
} else if (this.board.tileHasSnake(rollPos)) { |
||||||
|
playerData.snakeCount++; |
||||||
|
} |
||||||
|
|
||||||
|
playerData.pos = this.board.calculateLandingPosition(rollPos); |
||||||
|
if (playerData.pos >= this.board.size) { |
||||||
|
this.winningPlayer = player; |
||||||
|
} |
||||||
|
|
||||||
|
this.nextTurn(player); |
||||||
|
} |
||||||
|
|
||||||
|
ladderCountForPlayer(player: Player) { |
||||||
|
const playerData = this.players.get(player); |
||||||
|
if (!playerData) { |
||||||
|
throw new NotParticipatingError( |
||||||
|
this.gameId, |
||||||
|
player.playerId, |
||||||
|
"get ladder count" |
||||||
|
); |
||||||
|
} |
||||||
|
return playerData.ladderCount; |
||||||
|
} |
||||||
|
|
||||||
|
snakeCountForPlayer(player: Player) { |
||||||
|
const playerData = this.players.get(player); |
||||||
|
if (!playerData) { |
||||||
|
throw new NotParticipatingError( |
||||||
|
this.gameId, |
||||||
|
player.playerId, |
||||||
|
"get snake count" |
||||||
|
); |
||||||
|
} |
||||||
|
return playerData.snakeCount; |
||||||
|
} |
||||||
|
|
||||||
|
private nextTurn(currentPlayer: Player) { |
||||||
|
const players = Array.from(this.players.keys()); |
||||||
|
const playerIndex = players.indexOf(currentPlayer); |
||||||
|
const nextIndex = playerIndex === players.length - 1 ? 0 : playerIndex + 1; |
||||||
|
this.nextPlayer = players[nextIndex]; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
import { Game } from "./game.js"; |
||||||
|
import { GameError } from "./errors/game_error.js"; |
||||||
|
import { NotParticipatingError } from "./errors/not_participating_error.js"; |
||||||
|
|
||||||
|
export class Player { |
||||||
|
public games: Game[] = []; |
||||||
|
private rollCount = 0; |
||||||
|
|
||||||
|
constructor(public playerId: number) {} |
||||||
|
|
||||||
|
get rolls() { |
||||||
|
return this.rollCount; |
||||||
|
} |
||||||
|
|
||||||
|
join(game: Game) { |
||||||
|
if (this.games.includes(game)) { |
||||||
|
throw new GameError( |
||||||
|
`Player: ${this.playerId} attempted to join the same game multiple ` + |
||||||
|
`times` |
||||||
|
); |
||||||
|
} |
||||||
|
this.games.push(game); |
||||||
|
} |
||||||
|
|
||||||
|
rollDice(game: Game, roll: number) { |
||||||
|
if (!this.games.includes(game)) { |
||||||
|
throw new NotParticipatingError(game.gameId, this.playerId, "roll dice"); |
||||||
|
} |
||||||
|
|
||||||
|
this.rollCount++; |
||||||
|
game.rollDice(this, roll); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
import { BaseError } from "../../common_errors/base_error.js"; |
||||||
|
|
||||||
|
export class GamesLogError extends BaseError { |
||||||
|
constructor(message?: string, options?: ErrorOptions | undefined) { |
||||||
|
super(message || "Unable to parse game log", options); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,68 @@ |
|||||||
|
import { GamesLogError } from "./errors/games_log_error.js"; |
||||||
|
import { GamesLogParser, LogData } from "./games_log_parser.js"; |
||||||
|
|
||||||
|
import { GameEvent } from "../game/events/game_event.js"; |
||||||
|
import { GameStartedEvent } from "../game/events/game_started_event.js"; |
||||||
|
import { PlayerJoinedEvent } from "../game/events/player_joined_event.js"; |
||||||
|
import { PlayerRolledEvent } from "../game/events/player_rolled_event.js"; |
||||||
|
|
||||||
|
export class GamesLog { |
||||||
|
private logData: LogData = []; |
||||||
|
private gamesLogParser = new GamesLogParser(); |
||||||
|
|
||||||
|
async loadFile(filename: string, encoding?: BufferEncoding) { |
||||||
|
this.logData = await this.gamesLogParser.parseFile(filename, encoding); |
||||||
|
} |
||||||
|
|
||||||
|
async readEvents() { |
||||||
|
return this.logData.map((row, rowNumber) => { |
||||||
|
const payload = row.eventPayload; |
||||||
|
const requireProperties = this.requirePropertiesFor(payload, rowNumber); |
||||||
|
|
||||||
|
let gameEvent: GameEvent; |
||||||
|
switch (row.eventType) { |
||||||
|
case "player_rolls_dice": |
||||||
|
requireProperties("gameId", "playerId", "roll"); |
||||||
|
gameEvent = new PlayerRolledEvent( |
||||||
|
payload.gameId, |
||||||
|
payload.playerId, |
||||||
|
payload.roll |
||||||
|
); |
||||||
|
break; |
||||||
|
case "player_joins_game": |
||||||
|
requireProperties("gameId", "playerId"); |
||||||
|
gameEvent = new PlayerJoinedEvent(payload.gameId, payload.playerId); |
||||||
|
break; |
||||||
|
case "game_started": |
||||||
|
requireProperties("gameId"); |
||||||
|
gameEvent = new GameStartedEvent(payload.gameId); |
||||||
|
break; |
||||||
|
default: |
||||||
|
throw new Error( |
||||||
|
`Unknown event type: "${payload.eventType}" on row ${rowNumber}` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return gameEvent; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private requirePropertiesFor( |
||||||
|
payload: { [key: string]: any }, |
||||||
|
rowNumber: number |
||||||
|
) { |
||||||
|
return (...props: string[]) => |
||||||
|
props.forEach((name) => { |
||||||
|
if (!(name in payload)) { |
||||||
|
throw new GamesLogError( |
||||||
|
`Missing property: ${name} for event on row ${rowNumber}` |
||||||
|
); |
||||||
|
} |
||||||
|
if (!Number.isInteger(payload[name])) { |
||||||
|
throw new GamesLogError( |
||||||
|
`Invalid value for: ${name} on row ${rowNumber}` |
||||||
|
); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,58 @@ |
|||||||
|
import { readFile } from "fs/promises"; |
||||||
|
|
||||||
|
import { CSVParser } from "../utils/csv_parser/csv_parser.js"; |
||||||
|
import { GamesLogError } from "./errors/games_log_error.js"; |
||||||
|
|
||||||
|
export type LogData = { |
||||||
|
eventType: string; |
||||||
|
eventPayload: { [key: string]: any }; |
||||||
|
}[]; |
||||||
|
|
||||||
|
export class GamesLogParser { |
||||||
|
async parseFile(filename: string, encoding: BufferEncoding = "utf-8") { |
||||||
|
let data; |
||||||
|
try { |
||||||
|
data = await readFile(filename, { encoding }); |
||||||
|
} catch (err) { |
||||||
|
throw new GamesLogError(`Unable to read log file: ${filename}`, { |
||||||
|
cause: err instanceof Error ? err : undefined, |
||||||
|
}); |
||||||
|
} |
||||||
|
return this.parse(data); |
||||||
|
} |
||||||
|
|
||||||
|
parse(data: string): LogData { |
||||||
|
const parser = new CSVParser({ headers: true }); |
||||||
|
const csvData = parser.parse(data); |
||||||
|
|
||||||
|
return csvData.map((row, rowNumber) => { |
||||||
|
if (!("event_type" in row)) { |
||||||
|
throw new GamesLogError(`Missing event type on row: ${rowNumber}`); |
||||||
|
} |
||||||
|
if (!("event_payload" in row)) { |
||||||
|
throw new GamesLogError(`Missing event payload on row: ${rowNumber}`); |
||||||
|
} |
||||||
|
|
||||||
|
let eventPayload; |
||||||
|
try { |
||||||
|
eventPayload = JSON.parse(row.event_payload); |
||||||
|
} catch (err) { |
||||||
|
throw new GamesLogError( |
||||||
|
"Unable to parse event payload on row: ${rowNumber}", |
||||||
|
{ |
||||||
|
cause: err instanceof Error ? err : undefined, |
||||||
|
} |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (typeof eventPayload !== "object") { |
||||||
|
throw new GamesLogError("Invalid event payload on row: ${rowNumber}"); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
eventType: row.event_type, |
||||||
|
eventPayload, |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,58 @@ |
|||||||
|
import { GamesLog } from "./logging/games_log.js"; |
||||||
|
import { Tournament } from "./tournament/tournament.js"; |
||||||
|
import { Board } from "./game/board.js"; |
||||||
|
import { RankingReport } from "./reports/ranking_report.js"; |
||||||
|
import { BaseError } from "./common_errors/base_error.js"; |
||||||
|
import { ArgParse } from "./utils/arg_parse.js"; |
||||||
|
|
||||||
|
function printUsage() { |
||||||
|
console.log("Usage: npm start -- boardFile gameLogFile [options]"); |
||||||
|
console.log(" options:"); |
||||||
|
console.log(" -m --maxPlayersPerGame Maximum players per game"); |
||||||
|
console.log(" -d --debug Print stack trace on error"); |
||||||
|
} |
||||||
|
|
||||||
|
async function main() { |
||||||
|
// Validate command line arguments
|
||||||
|
const boardFilename = ArgParse.getStringArg(2); |
||||||
|
if (!boardFilename) return printUsage(); |
||||||
|
|
||||||
|
const gameLogFilename = ArgParse.getStringArg(3); |
||||||
|
if (!gameLogFilename) return printUsage(); |
||||||
|
|
||||||
|
const maxPlayersPerGame = |
||||||
|
ArgParse.getIntegerArg("m", "maxPlayersPerGame") ?? 2; |
||||||
|
|
||||||
|
// Set up tournament
|
||||||
|
const board = await Board.loadFromFile(boardFilename); |
||||||
|
const tournament = new Tournament(board, { maxPlayersPerGame }); |
||||||
|
|
||||||
|
// Retrieve game events
|
||||||
|
const gamesLog = new GamesLog(); |
||||||
|
await gamesLog.loadFile(gameLogFilename); |
||||||
|
const gameEvents = await gamesLog.readEvents(); |
||||||
|
|
||||||
|
// Process game events
|
||||||
|
tournament.processEvents(gameEvents); |
||||||
|
tournament.requireCompletion(); |
||||||
|
|
||||||
|
// Generate and print report
|
||||||
|
const rankingReport = new RankingReport(tournament); |
||||||
|
rankingReport.printPlayerSummary(); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
await main(); |
||||||
|
} catch (err) { |
||||||
|
if (err instanceof BaseError) { |
||||||
|
const printTrace = ArgParse.getBooleanArg("d", "debug"); |
||||||
|
if (printTrace) { |
||||||
|
console.error(err); |
||||||
|
} else { |
||||||
|
console.log(`Error: ${err.message}`); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Non-Application Error, re-throw
|
||||||
|
throw err; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,40 @@ |
|||||||
|
import { Tournament } from "../tournament/tournament"; |
||||||
|
|
||||||
|
export class RankingReport { |
||||||
|
constructor(public tournament: Tournament) {} |
||||||
|
|
||||||
|
printPlayerSummary() { |
||||||
|
const sortedPlayers = this.tournament.players.sort( |
||||||
|
(a, b) => a.playerId - b.playerId |
||||||
|
); |
||||||
|
for (const player of sortedPlayers) { |
||||||
|
const wins = player.games.reduce( |
||||||
|
(wins, game) => wins + Number(game.winner === player), |
||||||
|
0 |
||||||
|
); |
||||||
|
const losses = player.games.length - wins; |
||||||
|
const ratio = (wins / player.games.length).toFixed(3); |
||||||
|
const ladders = player.games.reduce( |
||||||
|
(ladders, game) => ladders + game.ladderCountForPlayer(player), |
||||||
|
0 |
||||||
|
); |
||||||
|
const snakes = player.games.reduce( |
||||||
|
(snakes, game) => snakes + game.snakeCountForPlayer(player), |
||||||
|
0 |
||||||
|
); |
||||||
|
|
||||||
|
console.log( |
||||||
|
`Player: ${player.playerId}: ` + |
||||||
|
[ |
||||||
|
`Win:${wins}`, |
||||||
|
`Lose:${losses}`, |
||||||
|
`Percent:${ratio}`, |
||||||
|
`Rolls:${player.rolls}`, |
||||||
|
`Ladders:${ladders}`, |
||||||
|
`Snakes:${snakes}`, |
||||||
|
].join(", ") + |
||||||
|
" ..." |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
import { TournamentError } from "./tournament_error.js"; |
||||||
|
|
||||||
|
export class DuplicateGameError extends TournamentError { |
||||||
|
constructor(gameId: number, options?: ErrorOptions) { |
||||||
|
super(`Duplicate game is: ${gameId}`, options); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
import { TournamentError } from "./tournament_error.js"; |
||||||
|
|
||||||
|
export class GameNotStartedError extends TournamentError { |
||||||
|
constructor(gameId: number, playerId: number, options?: ErrorOptions) { |
||||||
|
super( |
||||||
|
`Player: ${playerId} attempted to participate in game: ${gameId}, but ` + |
||||||
|
`that game hasn't started yet`, |
||||||
|
options |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
import { TournamentError } from "./tournament_error.js"; |
||||||
|
|
||||||
|
export class MaxPlayersExceededError extends TournamentError { |
||||||
|
constructor( |
||||||
|
gameId: number, |
||||||
|
playerId: number, |
||||||
|
maxPlayers: number, |
||||||
|
options?: ErrorOptions |
||||||
|
) { |
||||||
|
super( |
||||||
|
`Player: ${playerId} attempted to join game: ${gameId} which ` + |
||||||
|
`already has the maximum number (${maxPlayers}) of permissible ` + |
||||||
|
`players.`, |
||||||
|
options |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
import { BaseError } from "../../common_errors/base_error.js"; |
||||||
|
|
||||||
|
export class TournamentError extends BaseError {} |
@ -0,0 +1,11 @@ |
|||||||
|
import { TournamentError } from "./tournament_error.js"; |
||||||
|
|
||||||
|
export class TournamentIncompleteError extends TournamentError { |
||||||
|
constructor(incompleteGameIds: number[], options?: ErrorOptions) { |
||||||
|
const incompleteStr = incompleteGameIds.join(", "); |
||||||
|
super( |
||||||
|
"Tournament has the following incomplete game(s): " + incompleteStr, |
||||||
|
options |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,116 @@ |
|||||||
|
import { EventProcessor, IEventProcessor } from "../utils/event_processor.js"; |
||||||
|
|
||||||
|
import { GameStartedEvent } from "../game/events/game_started_event.js"; |
||||||
|
import { PlayerJoinedEvent } from "../game/events/player_joined_event.js"; |
||||||
|
import { PlayerRolledEvent } from "../game/events/player_rolled_event.js"; |
||||||
|
|
||||||
|
import { Game } from "../game/game.js"; |
||||||
|
import { Board } from "../game/board.js"; |
||||||
|
import { Player } from "../game/player.js"; |
||||||
|
|
||||||
|
import { MaxPlayersExceededError } from "./errors/max_players_exceeded_error.js"; |
||||||
|
import { GameNotStartedError } from "./errors/game_not_started_error.js"; |
||||||
|
import { DuplicateGameError } from "./errors/duplicate_game_error.js"; |
||||||
|
import { TournamentIncompleteError } from "./errors/tournament_incomplete_error.js"; |
||||||
|
|
||||||
|
// Provide Tournament class with additional EventProcessor methods
|
||||||
|
export interface Tournament extends IEventProcessor {} |
||||||
|
|
||||||
|
interface ITournamentOptions { |
||||||
|
maxPlayersPerGame: number; |
||||||
|
} |
||||||
|
|
||||||
|
@EventProcessor.watch |
||||||
|
export class Tournament implements ITournamentOptions { |
||||||
|
gamesMap = new Map<number, Game>(); |
||||||
|
playersMap = new Map<number, Player>(); |
||||||
|
|
||||||
|
maxPlayersPerGame = 2; |
||||||
|
|
||||||
|
constructor(public board: Board, options: Partial<ITournamentOptions> = {}) { |
||||||
|
Object.assign(this, options); |
||||||
|
} |
||||||
|
|
||||||
|
get players() { |
||||||
|
return Array.from(this.playersMap.values()); |
||||||
|
} |
||||||
|
|
||||||
|
get games() { |
||||||
|
return Array.from(this.gamesMap.values()); |
||||||
|
} |
||||||
|
|
||||||
|
@EventProcessor.handle(GameStartedEvent) |
||||||
|
onGameStarted(gameStartedEvent: GameStartedEvent) { |
||||||
|
this.newGame(gameStartedEvent.gameId); |
||||||
|
} |
||||||
|
|
||||||
|
@EventProcessor.handle(PlayerJoinedEvent) |
||||||
|
onPlayerJoined(playerJoinedEvent: PlayerJoinedEvent) { |
||||||
|
const game = this.getGame(playerJoinedEvent.gameId); |
||||||
|
if (!game) { |
||||||
|
throw new GameNotStartedError( |
||||||
|
playerJoinedEvent.gameId, |
||||||
|
playerJoinedEvent.playerId |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const player = this.getOrCreatePlayer(playerJoinedEvent.playerId); |
||||||
|
|
||||||
|
if (game.playerCount >= this.maxPlayersPerGame) { |
||||||
|
throw new MaxPlayersExceededError( |
||||||
|
game.gameId, |
||||||
|
player.playerId, |
||||||
|
this.maxPlayersPerGame |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
player.join(game); |
||||||
|
game.addPlayer(player); |
||||||
|
} |
||||||
|
|
||||||
|
@EventProcessor.handle(PlayerRolledEvent) |
||||||
|
onPlayerRolled(playerRolledEvent: PlayerRolledEvent) { |
||||||
|
const game = this.getGame(playerRolledEvent.gameId); |
||||||
|
if (!game) { |
||||||
|
throw new GameNotStartedError( |
||||||
|
playerRolledEvent.gameId, |
||||||
|
playerRolledEvent.playerId |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const player = this.getOrCreatePlayer(playerRolledEvent.playerId); |
||||||
|
player.rollDice(game, playerRolledEvent.roll); |
||||||
|
} |
||||||
|
|
||||||
|
getGame(gameId: number) { |
||||||
|
return this.gamesMap.get(gameId); |
||||||
|
} |
||||||
|
|
||||||
|
newGame(gameId: number) { |
||||||
|
if (this.gamesMap.has(gameId)) { |
||||||
|
throw new DuplicateGameError(gameId); |
||||||
|
} |
||||||
|
const game = new Game(gameId, this.board); |
||||||
|
this.gamesMap.set(gameId, game); |
||||||
|
return game; |
||||||
|
} |
||||||
|
|
||||||
|
getOrCreatePlayer(playerId: number) { |
||||||
|
let player = this.playersMap.get(playerId); |
||||||
|
if (!player) { |
||||||
|
player = new Player(playerId); |
||||||
|
this.playersMap.set(playerId, player); |
||||||
|
} |
||||||
|
return player; |
||||||
|
} |
||||||
|
|
||||||
|
requireCompletion() { |
||||||
|
const incompleteGameIds = Array.from(this.gamesMap.values()) |
||||||
|
.filter((game: Game) => !game.isComplete) |
||||||
|
.map((game: Game) => game.gameId); |
||||||
|
|
||||||
|
if (incompleteGameIds.length) { |
||||||
|
throw new TournamentIncompleteError(incompleteGameIds); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,109 @@ |
|||||||
|
import assert from "assert"; |
||||||
|
|
||||||
|
const argv = [...process.argv]; |
||||||
|
function next() { |
||||||
|
return argv.shift(); |
||||||
|
} |
||||||
|
|
||||||
|
function peek() { |
||||||
|
return argv[0]; |
||||||
|
} |
||||||
|
|
||||||
|
const positional: Array<string | boolean> = []; |
||||||
|
const named: { [key: string]: string | boolean } = {}; |
||||||
|
|
||||||
|
while (argv.length) { |
||||||
|
const arg = next(); |
||||||
|
if (arg === undefined) break; |
||||||
|
|
||||||
|
if (arg.startsWith("--")) { |
||||||
|
const match = arg.match(/^--([^=]*)(=?)(.*)$/); |
||||||
|
assert(match); |
||||||
|
const name = match[1]; |
||||||
|
if (!name.length) continue; |
||||||
|
let value: string | boolean = true; |
||||||
|
if (match[2] === "=") { |
||||||
|
if (match[3].length) { |
||||||
|
value = match[3]; |
||||||
|
} |
||||||
|
} else { |
||||||
|
const nextArg = peek(); |
||||||
|
if (nextArg && !nextArg.startsWith("-")) { |
||||||
|
next(); |
||||||
|
value = nextArg; |
||||||
|
} |
||||||
|
} |
||||||
|
named[name] = value; |
||||||
|
} else if (arg.startsWith("-")) { |
||||||
|
const match = arg.match(/^-(.*?)(=?)([^=a-zA-Z]*)$/); |
||||||
|
assert(match); |
||||||
|
const names = match[1].split(""); |
||||||
|
if (!names.length) continue; |
||||||
|
while (names.length > 1) { |
||||||
|
const name = names.shift(); |
||||||
|
if (name && name.length) { |
||||||
|
named[name] = true; |
||||||
|
} |
||||||
|
} |
||||||
|
let lastName = names.shift(); |
||||||
|
if (!lastName) continue; |
||||||
|
let lastValue: string | boolean = true; |
||||||
|
if (match[2] === "=") { |
||||||
|
if (match[3].length) { |
||||||
|
lastValue = match[3]; |
||||||
|
} |
||||||
|
} else { |
||||||
|
const nextArg = peek(); |
||||||
|
if (nextArg && !nextArg.startsWith("-")) { |
||||||
|
next(); |
||||||
|
lastValue = nextArg; |
||||||
|
} |
||||||
|
} |
||||||
|
named[lastName] = lastValue; |
||||||
|
} else { |
||||||
|
positional.push(arg); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getArg(...nameOrPositions: Array<string | number>) { |
||||||
|
let value: string | boolean | undefined; |
||||||
|
for (const nameOrPosition of nameOrPositions) { |
||||||
|
if (typeof nameOrPosition === "string") { |
||||||
|
const name = nameOrPosition; |
||||||
|
if (name in named) { |
||||||
|
value = named[name]; |
||||||
|
break; |
||||||
|
} |
||||||
|
} else if (Number.isInteger(nameOrPosition)) { |
||||||
|
const position = nameOrPosition; |
||||||
|
if (position < positional.length) { |
||||||
|
return positional[position]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return value; |
||||||
|
} |
||||||
|
|
||||||
|
export const ArgParse = { |
||||||
|
argv: { |
||||||
|
_: positional, |
||||||
|
...named, |
||||||
|
}, |
||||||
|
getStringArg(...nameOrPositions: Array<string | number>): string | undefined { |
||||||
|
const value = getArg(...nameOrPositions); |
||||||
|
if (typeof value === "string") { |
||||||
|
return value; |
||||||
|
} |
||||||
|
}, |
||||||
|
getBooleanArg(...nameOrPositions: Array<string | number>): boolean { |
||||||
|
return Boolean(getArg(...nameOrPositions)); |
||||||
|
}, |
||||||
|
getIntegerArg( |
||||||
|
...nameOrPositions: Array<string | number> |
||||||
|
): number | undefined { |
||||||
|
const value = Number(getArg(...nameOrPositions)); |
||||||
|
if (Number.isInteger(value)) { |
||||||
|
return value; |
||||||
|
} |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,136 @@ |
|||||||
|
import { Stream } from "./stream.js"; |
||||||
|
import { readFile } from "fs/promises"; |
||||||
|
|
||||||
|
type Options<T> = { |
||||||
|
quotes: string[]; |
||||||
|
delimeter: string; |
||||||
|
headers: T; |
||||||
|
}; |
||||||
|
|
||||||
|
export class CSVParser<T extends true | false = false> { |
||||||
|
quotes: string[] = ['"', "'"]; |
||||||
|
delimeter: string = ","; |
||||||
|
headers: boolean = false; |
||||||
|
|
||||||
|
constructor(options: Partial<Options<T>> = {}) { |
||||||
|
Object.assign(this, options); |
||||||
|
} |
||||||
|
|
||||||
|
async parseFile(filename: string, encoding: BufferEncoding = "utf-8") { |
||||||
|
const data = await readFile(filename, { |
||||||
|
encoding, |
||||||
|
}); |
||||||
|
return this.parse(data); |
||||||
|
} |
||||||
|
|
||||||
|
parse(str: string) { |
||||||
|
return this.parseStream(new Stream(str)); |
||||||
|
} |
||||||
|
|
||||||
|
private parseStream( |
||||||
|
stream: Stream |
||||||
|
): T extends true ? Array<{ [key: string]: string }> : string[] { |
||||||
|
const lines = []; |
||||||
|
let firstLine: string[] | undefined; |
||||||
|
|
||||||
|
while (!stream.eof) { |
||||||
|
const line = this.parseLine(stream); |
||||||
|
|
||||||
|
if (this.headers) { |
||||||
|
if (firstLine !== undefined) { |
||||||
|
const headers = firstLine; |
||||||
|
lines.push( |
||||||
|
line.reduce((obj: { [key: string]: string }, value, index) => { |
||||||
|
obj[ |
||||||
|
index < headers.length ? headers[index] : `column_${index + 1}` |
||||||
|
] = value; |
||||||
|
return obj; |
||||||
|
}, {}) |
||||||
|
); |
||||||
|
} else { |
||||||
|
firstLine = line; |
||||||
|
} |
||||||
|
} else { |
||||||
|
lines.push(line); |
||||||
|
} |
||||||
|
|
||||||
|
if (stream.eof) break; |
||||||
|
|
||||||
|
const next = stream.peek(); |
||||||
|
if (next === "\r") { |
||||||
|
stream.read(); |
||||||
|
if (stream.peek() === "\n") { |
||||||
|
stream.read(); |
||||||
|
} |
||||||
|
continue; |
||||||
|
} else if (next === "\n") { |
||||||
|
stream.read(); |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return lines as T extends true |
||||||
|
? Array<{ [key: string]: string }> |
||||||
|
: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
private parseLine(stream: Stream) { |
||||||
|
const values = []; |
||||||
|
|
||||||
|
while (!stream.eof) { |
||||||
|
values.push(this.parseValue(stream)); |
||||||
|
|
||||||
|
if (stream.eof) break; |
||||||
|
|
||||||
|
const next = stream.peek(); |
||||||
|
|
||||||
|
if (next === this.delimeter) { |
||||||
|
stream.read(); |
||||||
|
} else if (["\r", "\n"].includes(next)) { |
||||||
|
break; |
||||||
|
} else { |
||||||
|
stream.panic(`Unexpected character: "${next}"`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return values; |
||||||
|
} |
||||||
|
|
||||||
|
private parseValue(stream: Stream) { |
||||||
|
let str = ""; |
||||||
|
|
||||||
|
const spaces = stream.readSpaces(); |
||||||
|
|
||||||
|
const next = stream.peek(); |
||||||
|
|
||||||
|
if (this.quotes.includes(next)) { |
||||||
|
str += this.parseQuotedString(stream); |
||||||
|
// Read and discard trailing spaces
|
||||||
|
stream.readSpaces(); |
||||||
|
} else { |
||||||
|
// If a string is not quoted, spaces are assumed to be part of the value
|
||||||
|
str += spaces + stream.readUntil("\r", "\n", this.delimeter); |
||||||
|
} |
||||||
|
|
||||||
|
return str; |
||||||
|
} |
||||||
|
|
||||||
|
private parseQuotedString(stream: Stream) { |
||||||
|
const end = stream.read(); |
||||||
|
let str = ""; |
||||||
|
|
||||||
|
while (!stream.eof) { |
||||||
|
const chunk = stream.readUntil(end); |
||||||
|
str += chunk; |
||||||
|
stream.read(); |
||||||
|
if (stream.peek() === end) { |
||||||
|
stream.read(); |
||||||
|
str += end; |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return str; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
export class Stream { |
||||||
|
private pos: number; |
||||||
|
|
||||||
|
constructor(private data: string) { |
||||||
|
this.data = data; |
||||||
|
this.pos = 0; |
||||||
|
} |
||||||
|
|
||||||
|
get eof() { |
||||||
|
return this.pos === this.data.length; |
||||||
|
} |
||||||
|
|
||||||
|
peek() { |
||||||
|
return this.data.slice(this.pos, this.pos + 1); |
||||||
|
} |
||||||
|
read() { |
||||||
|
const value = this.data[this.pos]; |
||||||
|
this.pos++; |
||||||
|
return value; |
||||||
|
} |
||||||
|
panic(message: string) { |
||||||
|
throw new Error(`Stream Error: ${message}, at position: ${this.pos}`); |
||||||
|
} |
||||||
|
readSpaces() { |
||||||
|
return this.readWhile(" ", "\t"); |
||||||
|
} |
||||||
|
readWhile(...chars: string[]) { |
||||||
|
let value = ""; |
||||||
|
|
||||||
|
while (!this.eof && chars.includes(this.peek())) { |
||||||
|
value += this.read(); |
||||||
|
} |
||||||
|
|
||||||
|
return value; |
||||||
|
} |
||||||
|
readUntil(...chars: string[]) { |
||||||
|
const nextIndex = chars.reduce((index, chr) => { |
||||||
|
const i = this.data.indexOf(chr, this.pos); |
||||||
|
return i < index && i != -1 ? i : index; |
||||||
|
}, this.data.length - 1); |
||||||
|
|
||||||
|
const value = this.data.substring(this.pos, nextIndex); |
||||||
|
this.pos = nextIndex; |
||||||
|
|
||||||
|
return value; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,54 @@ |
|||||||
|
interface Type<T> extends Function { |
||||||
|
new (...args: any[]): T; |
||||||
|
} |
||||||
|
|
||||||
|
export interface IEventProcessor { |
||||||
|
processEvents: (events: any[]) => void; |
||||||
|
} |
||||||
|
|
||||||
|
export class EventProcessor { |
||||||
|
private handlers = new Map(); |
||||||
|
|
||||||
|
static watch<T extends Type<{}>>(Base: T) { |
||||||
|
const eventProcessor = new EventProcessor(); |
||||||
|
return class extends Base { |
||||||
|
constructor(...args: any[]) { |
||||||
|
super(...args); |
||||||
|
const methods = Base.prototype["eventProcessorMethods"] || new Map(); |
||||||
|
for (const [eventType, handler] of methods) { |
||||||
|
eventProcessor.setHandler(eventType, handler.bind(this)); |
||||||
|
} |
||||||
|
} |
||||||
|
processEvents(events: any[]) { |
||||||
|
for (const event of events) { |
||||||
|
eventProcessor.emit(event); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
static handle<T>(eventType: Type<T>) { |
||||||
|
return ( |
||||||
|
target: any, |
||||||
|
_propertyKey: string, |
||||||
|
descriptor: TypedPropertyDescriptor<(ev: T) => void> |
||||||
|
) => { |
||||||
|
if (!("eventProcessorMethods" in target)) { |
||||||
|
target["eventProcessorMethods"] = new Map(); |
||||||
|
} |
||||||
|
const methods = target["eventProcessorMethods"]; |
||||||
|
methods.set(eventType, descriptor.value); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
setHandler<T>(GameEvent: Type<T>, handler: (gameEvent: T) => void) { |
||||||
|
this.handlers.set(GameEvent, handler); |
||||||
|
} |
||||||
|
|
||||||
|
emit<T extends Object>(gameEvent: T) { |
||||||
|
const handler = this.handlers.get(gameEvent.constructor); |
||||||
|
if (handler !== undefined) { |
||||||
|
handler(gameEvent); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,104 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"experimentalDecorators": true, |
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */ |
||||||
|
|
||||||
|
/* Projects */ |
||||||
|
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ |
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ |
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ |
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ |
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ |
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ |
||||||
|
|
||||||
|
/* Language and Environment */ |
||||||
|
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, |
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ |
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */ |
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ |
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ |
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ |
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ |
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ |
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ |
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ |
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ |
||||||
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ |
||||||
|
|
||||||
|
/* Modules */ |
||||||
|
"module": "es2022" /* Specify what module code is generated. */, |
||||||
|
"rootDir": "./src" /* Specify the root folder within your source files. */, |
||||||
|
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, |
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ |
||||||
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ |
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ |
||||||
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ |
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */ |
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ |
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ |
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files. */ |
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ |
||||||
|
|
||||||
|
/* JavaScript Support */ |
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ |
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ |
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ |
||||||
|
|
||||||
|
/* Emit */ |
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ |
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */ |
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ |
||||||
|
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */ |
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ |
||||||
|
"outDir": "./dist" /* Specify an output folder for all emitted files. */, |
||||||
|
// "removeComments": true, /* Disable emitting comments. */ |
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */ |
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ |
||||||
|
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ |
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ |
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ |
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ |
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ |
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ |
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ |
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */ |
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ |
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ |
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ |
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ |
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */ |
||||||
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ |
||||||
|
|
||||||
|
/* Interop Constraints */ |
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ |
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ |
||||||
|
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, |
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ |
||||||
|
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, |
||||||
|
|
||||||
|
/* Type Checking */ |
||||||
|
"strict": true /* Enable all strict type-checking options. */, |
||||||
|
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ |
||||||
|
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ |
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ |
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ |
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ |
||||||
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ |
||||||
|
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ |
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ |
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ |
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ |
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ |
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ |
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ |
||||||
|
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ |
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ |
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ |
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ |
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ |
||||||
|
|
||||||
|
/* Completeness */ |
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ |
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */ |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue