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