Initial commit
This commit is contained in:
commit
f08b7da487
20
.eslintrc.cjs
Normal file
20
.eslintrc.cjs
Normal file
@ -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": {
|
||||||
|
}
|
||||||
|
}
|
130
.gitignore
vendored
Normal file
130
.gitignore
vendored
Normal file
@ -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.*
|
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
.eslintrc.js
|
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
BIN
documentation/SafetyVantage coding challenge 2021.pdf
Normal file
BIN
documentation/SafetyVantage coding challenge 2021.pdf
Normal file
Binary file not shown.
2525
package-lock.json
generated
Normal file
2525
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
21898
sample_data/game_logs.csv
Normal file
21898
sample_data/game_logs.csv
Normal file
File diff suppressed because it is too large
Load Diff
23
sample_data/tournament_board.json
Normal file
23
sample_data/tournament_board.json
Normal file
@ -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 }
|
||||||
|
]
|
||||||
|
}
|
28
snakes_and_ladders.sublime-project
Normal file
28
snakes_and_ladders.sublime-project
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
1362
snakes_and_ladders.sublime-workspace
Normal file
1362
snakes_and_ladders.sublime-workspace
Normal file
File diff suppressed because it is too large
Load Diff
1
src/common_errors/base_error.ts
Normal file
1
src/common_errors/base_error.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class BaseError extends Error {}
|
124
src/game/board.ts
Normal file
124
src/game/board.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
7
src/game/errors/board_load_error.ts
Normal file
7
src/game/errors/board_load_error.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
7
src/game/errors/duplicate_player_error.ts
Normal file
7
src/game/errors/duplicate_player_error.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
11
src/game/errors/game_complete_error.ts
Normal file
11
src/game/errors/game_complete_error.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
2
src/game/errors/game_error.ts
Normal file
2
src/game/errors/game_error.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { BaseError } from "../../common_errors/base_error.js";
|
||||||
|
export class GameError extends BaseError {}
|
16
src/game/errors/not_participating_error.ts
Normal file
16
src/game/errors/not_participating_error.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
11
src/game/errors/out_of_turn_error.ts
Normal file
11
src/game/errors/out_of_turn_error.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
3
src/game/events/game_event.ts
Normal file
3
src/game/events/game_event.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export class GameEvent {
|
||||||
|
constructor(public gameId: number) {}
|
||||||
|
}
|
3
src/game/events/game_started_event.ts
Normal file
3
src/game/events/game_started_event.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { GameEvent } from "./game_event.js";
|
||||||
|
|
||||||
|
export class GameStartedEvent extends GameEvent {}
|
7
src/game/events/player_joined_event.ts
Normal file
7
src/game/events/player_joined_event.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { GameEvent } from "./game_event.js";
|
||||||
|
|
||||||
|
export class PlayerJoinedEvent extends GameEvent {
|
||||||
|
constructor(gameId: number, public playerId: number) {
|
||||||
|
super(gameId);
|
||||||
|
}
|
||||||
|
}
|
7
src/game/events/player_rolled_event.ts
Normal file
7
src/game/events/player_rolled_event.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
110
src/game/game.ts
Normal file
110
src/game/game.ts
Normal file
@ -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];
|
||||||
|
}
|
||||||
|
}
|
33
src/game/player.ts
Normal file
33
src/game/player.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
7
src/logging/errors/games_log_error.ts
Normal file
7
src/logging/errors/games_log_error.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
68
src/logging/games_log.ts
Normal file
68
src/logging/games_log.ts
Normal file
@ -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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
58
src/logging/games_log_parser.ts
Normal file
58
src/logging/games_log_parser.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
58
src/main.ts
Normal file
58
src/main.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
40
src/reports/ranking_report.ts
Normal file
40
src/reports/ranking_report.ts
Normal file
@ -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(", ") +
|
||||||
|
" ..."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
src/tournament/errors/duplicate_game_error.ts
Normal file
7
src/tournament/errors/duplicate_game_error.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
11
src/tournament/errors/game_not_started_error.ts
Normal file
11
src/tournament/errors/game_not_started_error.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
17
src/tournament/errors/max_players_exceeded_error.ts
Normal file
17
src/tournament/errors/max_players_exceeded_error.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
3
src/tournament/errors/tournament_error.ts
Normal file
3
src/tournament/errors/tournament_error.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { BaseError } from "../../common_errors/base_error.js";
|
||||||
|
|
||||||
|
export class TournamentError extends BaseError {}
|
11
src/tournament/errors/tournament_incomplete_error.ts
Normal file
11
src/tournament/errors/tournament_incomplete_error.ts
Normal file
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
116
src/tournament/tournament.ts
Normal file
116
src/tournament/tournament.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
109
src/utils/arg_parse.ts
Normal file
109
src/utils/arg_parse.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
136
src/utils/csv_parser/csv_parser.ts
Normal file
136
src/utils/csv_parser/csv_parser.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
47
src/utils/csv_parser/stream.ts
Normal file
47
src/utils/csv_parser/stream.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
54
src/utils/event_processor.ts
Normal file
54
src/utils/event_processor.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
104
tsconfig.json
Normal file
104
tsconfig.json
Normal file
@ -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
Block a user