Browse Source

Initial commit

master
Ben Ashton 2 years ago
commit
f08b7da487
  1. 20
      .eslintrc.cjs
  2. 130
      .gitignore
  3. 1
      .prettierignore
  4. 1
      .prettierrc.json
  5. BIN
      documentation/SafetyVantage coding challenge 2021.pdf
  6. 2525
      package-lock.json
  7. 24
      package.json
  8. 21898
      sample_data/game_logs.csv
  9. 23
      sample_data/tournament_board.json
  10. 28
      snakes_and_ladders.sublime-project
  11. 1362
      snakes_and_ladders.sublime-workspace
  12. 1
      src/common_errors/base_error.ts
  13. 124
      src/game/board.ts
  14. 7
      src/game/errors/board_load_error.ts
  15. 7
      src/game/errors/duplicate_player_error.ts
  16. 11
      src/game/errors/game_complete_error.ts
  17. 2
      src/game/errors/game_error.ts
  18. 16
      src/game/errors/not_participating_error.ts
  19. 11
      src/game/errors/out_of_turn_error.ts
  20. 3
      src/game/events/game_event.ts
  21. 3
      src/game/events/game_started_event.ts
  22. 7
      src/game/events/player_joined_event.ts
  23. 7
      src/game/events/player_rolled_event.ts
  24. 110
      src/game/game.ts
  25. 33
      src/game/player.ts
  26. 7
      src/logging/errors/games_log_error.ts
  27. 68
      src/logging/games_log.ts
  28. 58
      src/logging/games_log_parser.ts
  29. 58
      src/main.ts
  30. 40
      src/reports/ranking_report.ts
  31. 7
      src/tournament/errors/duplicate_game_error.ts
  32. 11
      src/tournament/errors/game_not_started_error.ts
  33. 17
      src/tournament/errors/max_players_exceeded_error.ts
  34. 3
      src/tournament/errors/tournament_error.ts
  35. 11
      src/tournament/errors/tournament_incomplete_error.ts
  36. 116
      src/tournament/tournament.ts
  37. 109
      src/utils/arg_parse.ts
  38. 136
      src/utils/csv_parser/csv_parser.ts
  39. 47
      src/utils/csv_parser/stream.ts
  40. 54
      src/utils/event_processor.ts
  41. 104
      tsconfig.json

20
.eslintrc.cjs

@ -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

@ -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

@ -0,0 +1 @@
.eslintrc.js

1
.prettierrc.json

@ -0,0 +1 @@
{}

BIN
documentation/SafetyVantage coding challenge 2021.pdf

Binary file not shown.

2525
package-lock.json generated

File diff suppressed because it is too large Load Diff

24
package.json

@ -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

File diff suppressed because it is too large Load Diff

23
sample_data/tournament_board.json

@ -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 vendored

@ -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 vendored

File diff suppressed because it is too large Load Diff

1
src/common_errors/base_error.ts

@ -0,0 +1 @@
export class BaseError extends Error {}

124
src/game/board.ts

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -0,0 +1,3 @@
export class GameEvent {
constructor(public gameId: number) {}
}

3
src/game/events/game_started_event.ts

@ -0,0 +1,3 @@
import { GameEvent } from "./game_event.js";
export class GameStartedEvent extends GameEvent {}

7
src/game/events/player_joined_event.ts

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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…
Cancel
Save