From 8700c431cf9e9c3ab040bfbeea0130e38c48cc53 Mon Sep 17 00:00:00 2001 From: Nolan Hellyer Date: Tue, 21 Jan 2025 11:50:01 -0800 Subject: [PATCH] bring server files into project. --- .gitignore | 3 + .prettierrc | 1 - package-lock.json | 41 +- package.json | 1 + src/hooks.server.ts | 6 + src/lib/GameData.ts | 28 + src/lib/GameEvent.ts | 455 +++++++++++ src/lib/Listing.ts | 26 + src/lib/ServerResponse.ts | 14 + src/lib/State.ts | 9 + src/lib/index.ts | 1 - src/lib/server/Game.ts | 25 + src/lib/server/Id.ts | 1 + src/lib/server/cache.ts | 4 + src/lib/server/getDiceRoll.ts | 9 + src/lib/server/modifyListing.ts | 20 + src/lib/server/responseBodies.ts | 19 + src/lib/server/test/Game.test.ts | 32 + src/lib/server/test/GameData.test.ts | 50 ++ src/lib/server/test/GameEvent.test.ts | 991 +++++++++++++++++++++++ src/lib/server/test/Listing.test.ts | 33 + src/lib/server/test/getDiceRoll.test.ts | 15 + src/lib/server/test/validation.test.ts | 140 ++++ src/lib/validation.ts | 94 +++ src/routes/api/+server.ts | 6 + src/routes/api/games/+server.ts | 16 + src/routes/api/games/[gameid]/+server.ts | 14 + src/routes/games/+page.svelte | 52 ++ src/routes/games/[gameid]/+page.svelte | 15 + src/routes/games/[gameid]/+page.ts | 32 + tests/requests.http | 41 + vite.config.ts | 15 +- 32 files changed, 2200 insertions(+), 9 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/GameData.ts create mode 100644 src/lib/GameEvent.ts create mode 100644 src/lib/Listing.ts create mode 100644 src/lib/ServerResponse.ts create mode 100644 src/lib/State.ts delete mode 100644 src/lib/index.ts create mode 100644 src/lib/server/Game.ts create mode 100644 src/lib/server/Id.ts create mode 100644 src/lib/server/cache.ts create mode 100644 src/lib/server/getDiceRoll.ts create mode 100644 src/lib/server/modifyListing.ts create mode 100644 src/lib/server/responseBodies.ts create mode 100644 src/lib/server/test/Game.test.ts create mode 100644 src/lib/server/test/GameData.test.ts create mode 100644 src/lib/server/test/GameEvent.test.ts create mode 100644 src/lib/server/test/Listing.test.ts create mode 100644 src/lib/server/test/getDiceRoll.test.ts create mode 100644 src/lib/server/test/validation.test.ts create mode 100644 src/lib/validation.ts create mode 100644 src/routes/api/+server.ts create mode 100644 src/routes/api/games/+server.ts create mode 100644 src/routes/api/games/[gameid]/+server.ts create mode 100644 src/routes/games/+page.svelte create mode 100644 src/routes/games/[gameid]/+page.svelte create mode 100644 src/routes/games/[gameid]/+page.ts create mode 100644 tests/requests.http diff --git a/.gitignore b/.gitignore index 3b462cb..dbbb26c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Development +cert \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 3f7802c..f7de837 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,5 @@ { "useTabs": true, - "singleQuote": true, "trailingComma": "none", "printWidth": 100, "plugins": ["prettier-plugin-svelte"], diff --git a/package-lock.json b/package-lock.json index a17b71c..bfab32a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/node": "^22.10.7", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", @@ -1128,6 +1129,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.10.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", + "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", @@ -2568,6 +2579,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2742,13 +2766,15 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -3432,6 +3458,13 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 088edd9..a187ffd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@types/node": "^22.10.7", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..abc649f --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,6 @@ +import type { Handle } from "@sveltejs/kit"; + +export const handle: Handle = async ({ event, resolve }) => { + console.log("this got called", event.isSubRequest); + return await resolve(event); +}; diff --git a/src/lib/GameData.ts b/src/lib/GameData.ts new file mode 100644 index 0000000..dba091d --- /dev/null +++ b/src/lib/GameData.ts @@ -0,0 +1,28 @@ +import { hasOnlyKeys, hasProperty } from "./validation"; +import type { Id } from "./server/Id"; +import type { State } from "./State"; + +export interface GameData { + isStarted: boolean; + players: Id[]; + state: State; +} + +export function isGameData(target: unknown): target is GameData { + if (!hasProperty(target, "isStarted", "boolean")) { + return false; + } + + if (!hasProperty(target, "players", "string[]")) { + return false; + } + + // the user cannot update this property, they have to send it as it is, so if we + // receive something that isn't a correctly formed state, that will get rejected + // anyway (since it won't match what we already have). + if (!hasProperty(target, "state", "object")) { + return false; + } + + return hasOnlyKeys(target, ["players", "isStarted", "state"]); +} diff --git a/src/lib/GameEvent.ts b/src/lib/GameEvent.ts new file mode 100644 index 0000000..6f7d63d --- /dev/null +++ b/src/lib/GameEvent.ts @@ -0,0 +1,455 @@ +import type { GameData } from "$lib/GameData"; +import { getDiceRoll } from "$lib/server/getDiceRoll"; +import type { State } from "$lib/State"; +import { hasOnlyKeys, hasProperty } from "$lib/validation"; + +export const FIRST_ROLL_LOST = -1; +export const FIRST_ROLL_PENDING = 0; +export const GAME_SCORE_THRESHOLD = 10_000; + +export enum GameEventKind { + SeatPlayers = "SeatPlayers", + RollForFirst = "RollForFirst", + Roll = "Roll", + Hold = "Hold", + Score = "Score", +} + +export interface GameEventData { + kind: string; + player?: number; + value?: number | number[]; +} + +export interface GameEvent extends GameEventData { + run: (state: State) => void; +} + +export function isGameEventData(target: unknown): target is GameEvent { + if (typeof target !== "object") { + return false; + } + + if (!hasProperty(target, "kind", "string")) { + return false; + } + + // TODO: add checks to make sure that, if it has optional properties, they + // are the correct type. + return hasOnlyKeys(target, ["kind", "player", "value"]); +} + +export function getGameEvent(data: GameData, event: GameEventData): GameEvent { + switch (event.kind) { + case GameEventKind.SeatPlayers: + if (event.value !== data.players.length) { + throw new Error("must seat all of the players in the game"); + } + + return new SeatPlayers(event); + case GameEventKind.RollForFirst: + return new RollForFirst(event); + case GameEventKind.Roll: + // Obviously a client can't send the roll they want to make. Instead, the + // client sends a Roll event with a value representing the number of dice + // they intend to roll, and then the server creates an array of that length + // with random dice values. + if (typeof event.value !== "number") { + throw new Error("event must include the number of dice to roll"); + } + + event.value = getDiceRoll(event.value, Math.random); + + return new Roll(event); + case GameEventKind.Hold: + return new Hold(event); + case GameEventKind.Score: + return new Score(event); + default: + throw new Error(`${event.kind} is not a valid kind of event`); + } +} + +/** + * SeatPlayers takes a value which represents the number of players who will be playing. + * It initializes the scores for that number of players. + */ +export class SeatPlayers implements GameEvent { + kind: GameEventKind.SeatPlayers; + value: number; + + constructor(event: GameEventData) { + const value = throwIfNoValueNumber(event); + + this.kind = GameEventKind.SeatPlayers; + this.value = value; + } + + run(state: State) { + throwIfGameOver(state); + + if (state.scores !== undefined) { + throw new Error("players already seated!"); + } + + state.scores = new Array(this.value).fill(FIRST_ROLL_PENDING); + } +} + +/** + * RollForFirst takes a player index and a value which represents the pips on the die + * that the player rolled. It represents and attempt to roll the highest die and go + * first. + * + * Players can roll in any order. Each time a player rolls, the event checks to see if + * everyone has rolled and if there is a winner, it sets that person as the first active + * player and ends the rollling-for-first stage of the game. If there was a tie, those + * players are expected to re-roll as a tie breaker. Re-rolling continues until someone + * wins. + */ +export class RollForFirst implements GameEvent { + kind: GameEventKind.RollForFirst; + player: number; + value: number; + + constructor(event: GameEventData) { + this.kind = GameEventKind.RollForFirst; + + const player = throwIfNoPlayer(event); + const value = throwIfNoValueNumber(event); + + this.player = player; + this.value = value; + } + + run(state: State) { + const scores = state.scores ?? []; + + throwIfGameOver(state); + + if (state.playing !== undefined) { + throw new Error("first player has already been selected"); + } + + if (scores.length <= this.player) { + throw new Error("this player index is out of bounds"); + } + + if (scores[this.player] !== FIRST_ROLL_PENDING) { + throw new Error("this player has already rolled"); + } + + scores[this.player] = this.value; + + let best = 0; + let winningIndex = -1; + const ties = new Set(); + + // check for a winner + for (let i = 0; i < scores.length; i++) { + const score = scores[i]; + + // if someone hasn't rolled, no winner yet + if (score === 0) { + best = 0; + winningIndex = -1; + ties.clear(); + break; + } + + if (score > best) { + // This is the new high score... + best = score; + winningIndex = i; + ties.clear(); + } else if (score === best) { + // ...or it is tied for the best score. + ties.add(winningIndex); + ties.add(i); + } + } + + if (winningIndex > -1) { + // If a winner was found... + if (ties.size < 1) { + // ...and there are no ties... + state.playing = winningIndex; + state.scores = scores.fill(0); + state.dieCount = 6; + } else { + // ...otherwise, setup for tie breaking rolls. + state.scores = scores.map((_, i) => + ties.has(i) ? FIRST_ROLL_PENDING : FIRST_ROLL_LOST, + ); + } + } + } +} + +/** + * Roll takes a player index and a value which is an array of numbers representing the + * number of pips on each rolled die. The Roll event represents a player rolling however + * many dice they have available. + */ +export class Roll implements GameEvent { + kind: GameEventKind.Roll; + player: number; + value: number[]; + + constructor(event: GameEventData) { + this.kind = GameEventKind.Roll; + + const player = throwIfNoPlayer(event); + const value = throwIfNoValueArray(event); + + this.player = player; + this.value = value; + } + + run(state: State) { + const scores = state.scores ?? []; + + throwIfGameOver(state); + throwIfWrongTurn(state, this.player); + + if (state.dieCount !== this.value.length) { + throw new Error("player is rolling the wrong number if dice"); + } + + if (state.dice !== undefined) { + throw new Error("player has already rolled"); + } + + state.dice = this.value; + delete state.dieCount; + } +} + +/** + * Hold takes a player index and a value which is an array of numbers representing + * indexes of rolled dice that a player wants to hold. Hold represents the part of a + * player's turn where they select scoring dice to hold onto. + * + * If the player holds all the dice, the number of dice they can roll will reset to 6, + * and they will be required to roll again (a player must always roll when they have 6) + * dice available. + */ +export class Hold implements GameEvent { + kind: GameEventKind.Hold; + player: number; + value: number[]; + + constructor(event: GameEventData) { + this.kind = GameEventKind.Hold; + + const player = throwIfNoPlayer(event); + const value = throwIfNoValueArray(event); + + this.player = player; + this.value = value; + } + + run(state: State) { + const { dice } = state; + + throwIfGameOver(state); + throwIfWrongTurn(state, this.player); + + if (!dice) { + throw new Error("player hasn't rolled yet"); + } + + if (this.value.length < 1) { + throw new Error("player cannot hold 0 dice"); + } + + // heldValues represents the actual values of the held dice based on the indexes + // of the held dice. + const heldValues = this.value.map((val) => { + if (dice[val] === undefined) { + throw new Error("player is trying to hold non-existent dice"); + } + + return dice[val]; + }); + + const counts = new Map(); + let total = 0; + + const valueSet = new Set(heldValues); + if (valueSet.size === 6) { + // Detect a run of six: if the held values create a set of values with a size + // of six, then, since there are only six dice, it MUST be a run of six. + total = 2_000; + } else if (valueSet.size === 2 && this.value.length === 6) { + // Detect two threes of a kind: if the number of held values was six, and the + // set of unique values is two, then there MUST have been two threes of a + // kind. + total = 1_500; + } else { + // A player can use a "push" if they are using every one of their rolled + // dice. + let canPush = this.value.length === dice.length; + + for (const val of heldValues) { + const count = counts.get(val) ?? 0; + counts.set(val, count + 1); + } + + for (let pips = 1; pips <= 6; pips++) { + if (!counts.has(pips)) continue; + + // This for sure exists because of the check at t he top of the loop. + const count = counts.get(pips)!; + const pipsScore = scorePips(count, pips); + + if (!pipsScore) { + // The player can only hold scoring dice... + if (!canPush || count !== 2) { + throw new Error("player is holding non-scoring dice"); + } else { + // ...unless they have a push. + canPush = false; + } + } + + total += pipsScore; + } + } + + state.dieCount = dice.length - this.value.length; + state.heldScore = total; + delete state.dice; + + if (state.dieCount === 0) { + state.dieCount = 6; + } + } +} + +/** + * Score takes a player index and a value which is a number representing the number of + * points that a player has accrued by rolling and holding dice. Once a player has + * scored, the active player is passed on to the next player and the players total score + * is updated. + * + * If a player's score matches or exceeds 10,000, then a turn countdown begins, allowing + * all the remaining players to take one more turn before the game ends. + */ +export class Score implements GameEvent { + kind: GameEventKind.Score; + player: number; + value: number; + + constructor(event: GameEventData) { + const player = throwIfNoPlayer(event); + const value = throwIfNoValueNumber(event); + + this.kind = GameEventKind.Score; + this.player = player; + this.value = value; + } + + run(state: State) { + const { dieCount, heldScore, scores } = state; + const playerCount = scores?.length ?? 0; + + throwIfGameOver(state); + throwIfWrongTurn(state, this.player); + + if (dieCount === 6) { + throw new Error("player must roll when they have six free dice"); + } + + if (this.value !== state.heldScore) { + throw new Error("player score must match the held score"); + } + + // It's safe to tell the compiler that the player is not undefined because of the + // check above. + state.scores![this.player] += heldScore ?? 0; + + // Increment the index of the active player, circling back to 1 if the player + // who just scored was the last player in the array. + state.playing = + playerCount - 1 === this.player + ? (state.playing = 0) + : (state.playing = this.player + 1); + + state.dieCount = 6; + delete state.heldScore; + + // If there is an active turn countdown, then now is the time to decrement it, + // otherwise, if the player has crossed 10,000, then it's time to start the + // turn countdown. + if (state.turnCountdown !== undefined) { + state.turnCountdown--; + } else if (state.scores![this.player] >= GAME_SCORE_THRESHOLD) { + state.turnCountdown = playerCount - 1; + } + } +} + +function scorePips(count: number, pips: number) { + if (count < 3) { + // If not a three of a kind, return the raw dice value... + return pipScore(pips) * count; + } + + // ...otherwise, this is a three or more of a kind. + if (pips === 1) { + // Ones are treated as 10s. + pips = 10; + } + + // Three of a kind is the pips times 100, times two for each additional rolled dice. + return pips * 100 * (count - 2); +} + +/** + * TODO: There are variations to the rules of ten thousand. It would be interesting if + * code like this could actually come from a setting somewhere. + */ +function pipScore(pips: number) { + switch (pips) { + case 1: + return 100; + case 5: + return 50; + default: + return 0; + } +} + +function throwIfNoPlayer(event: GameEventData): number { + if (event.player === undefined) { + throw new Error("missing player index"); + } + + return event.player; +} + +function throwIfNoValueNumber({ value }: GameEventData): number { + if (typeof value !== "number") { + throw new Error("missing value"); + } + + return value; +} + +function throwIfNoValueArray({ value }: GameEventData): number[] { + if (!Array.isArray(value)) { + throw new Error("missing value array"); + } + + return value; +} + +function throwIfGameOver(state: State) { + if (state.turnCountdown === 0) throw new Error("the game is over"); +} + +function throwIfWrongTurn(state: State, player: number) { + if (state.playing === undefined || state.playing !== player) + throw new Error("it's not this player's turn"); +} diff --git a/src/lib/Listing.ts b/src/lib/Listing.ts new file mode 100644 index 0000000..6edbc88 --- /dev/null +++ b/src/lib/Listing.ts @@ -0,0 +1,26 @@ +import { hasProperty } from "$lib/validation"; + +export interface Listing { + id: string; + createdAt: string; + modifiedAt: string | null; + deleted: boolean; + data: T; +} + +export function isListing( + target: unknown, + dataGuard?: (target: unknown) => target is T +): target is Listing { + if (!hasProperty(target, "id", "string")) return false; + if (!hasProperty(target, "createdAt", "string")) return false; + + if (!hasProperty(target, "modifiedAt", "null")) { + if (!hasProperty(target, "modifiedAt", "string")) return false; + } + + if (!hasProperty(target, "deleted", "boolean")) return false; + if (!hasProperty(target, "data", "object")) return false; + + return dataGuard?.((target as any)["data"]) ?? true; +} diff --git a/src/lib/ServerResponse.ts b/src/lib/ServerResponse.ts new file mode 100644 index 0000000..8798627 --- /dev/null +++ b/src/lib/ServerResponse.ts @@ -0,0 +1,14 @@ +import type { Listing } from "$lib/Listing"; +import { hasProperty } from "$lib/validation"; + +export type ServerResponse = + | { item: Listing } + | { items: Listing[] } + | { error: string }; + +export function isServerResponse(target: unknown): target is ServerResponse { + if (hasProperty(target, "item", "object")) return true; + if (hasProperty(target, "items", "object[]")) return true; + if (hasProperty(target, "error", "string")) return true; + return false; +} diff --git a/src/lib/State.ts b/src/lib/State.ts new file mode 100644 index 0000000..48a56d0 --- /dev/null +++ b/src/lib/State.ts @@ -0,0 +1,9 @@ +export interface State { + dice?: number[]; + dieCount?: number; + gameOver?: boolean; + heldScore?: number; + playing?: number; + scores?: number[]; + turnCountdown?: number; +} diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/server/Game.ts b/src/lib/server/Game.ts new file mode 100644 index 0000000..535a61d --- /dev/null +++ b/src/lib/server/Game.ts @@ -0,0 +1,25 @@ +import type { Id } from "./Id"; +import type { GameData } from "../GameData"; +import type { State } from "../State"; + +export class Game implements GameData { + players: Id[]; + isStarted: boolean; + state: State; + + constructor() { + this.players = []; + this.isStarted = false; + this.state = {}; + } + + addPlayer(id: Id) { + this.players.push(id); + } + + start() { + if (this.isStarted) throw new Error("game is already started"); + + this.isStarted = true; + } +} diff --git a/src/lib/server/Id.ts b/src/lib/server/Id.ts new file mode 100644 index 0000000..5dfbae8 --- /dev/null +++ b/src/lib/server/Id.ts @@ -0,0 +1 @@ +export type Id = string; diff --git a/src/lib/server/cache.ts b/src/lib/server/cache.ts new file mode 100644 index 0000000..80c72fb --- /dev/null +++ b/src/lib/server/cache.ts @@ -0,0 +1,4 @@ +import type { GameData } from "../GameData"; +import type { Listing } from "./modifyListing"; + +export const games: Listing[] = []; diff --git a/src/lib/server/getDiceRoll.ts b/src/lib/server/getDiceRoll.ts new file mode 100644 index 0000000..c202d9f --- /dev/null +++ b/src/lib/server/getDiceRoll.ts @@ -0,0 +1,9 @@ +export function getDiceRoll(dieCount: number, rand: () => number = Math.random) { + const dice: number[] = []; + for (let i = 0; i < dieCount; i++) { + const value = Math.floor(rand() * 7); + dice.push(value); + } + + return dice; +} diff --git a/src/lib/server/modifyListing.ts b/src/lib/server/modifyListing.ts new file mode 100644 index 0000000..079bdf1 --- /dev/null +++ b/src/lib/server/modifyListing.ts @@ -0,0 +1,20 @@ +import { randomUUID } from "crypto"; +import type { Listing } from "$lib/Listing"; + +export function createNewListing(data: T): Listing { + return { + id: randomUUID(), + createdAt: new Date().toISOString(), + modifiedAt: null, + deleted: false, + data + }; +} + +export function updateListing(listing: Listing, data: T): Listing { + return { + ...listing, + modifiedAt: new Date().toISOString(), + data + }; +} diff --git a/src/lib/server/responseBodies.ts b/src/lib/server/responseBodies.ts new file mode 100644 index 0000000..dff2ce3 --- /dev/null +++ b/src/lib/server/responseBodies.ts @@ -0,0 +1,19 @@ +export function listResponse(items: unknown[]) { + return Response.json({ items }); +} + +export function singleResponse(item: unknown) { + return Response.json({ item }); +} + +export function badRequestResponse(error: string = "Bad Request") { + return Response.json({ error }, { status: 400 }); +} + +export function notFoundResponse() { + return Response.json({ error: "Not Found" }, { status: 404 }); +} + +export function serverErrorResponse() { + return Response.json({ error: "Unexpected Server Error" }, { status: 500 }); +} diff --git a/src/lib/server/test/Game.test.ts b/src/lib/server/test/Game.test.ts new file mode 100644 index 0000000..5a23424 --- /dev/null +++ b/src/lib/server/test/Game.test.ts @@ -0,0 +1,32 @@ +import { describe, it } from "node:test"; +import { Game } from "../../Game"; +import { deepEqual, ok, throws } from "node:assert/strict"; + +describe("Game", () => { + describe("addPlayer", () => { + it("should push a player id into the player array", () => { + const game = new Game(); + deepEqual(game.players, []); + + game.addPlayer("some-id"); + deepEqual(game.players, ["some-id"]); + }); + }); + + describe("start", () => { + it("start shoud start the game", () => { + const game = new Game(); + game.isStarted = false; + + game.start(); + ok(game.isStarted); + }); + + it("start should throw if the game is already started", () => { + const game = new Game(); + + game.start(); + throws(() => game.start()); + }); + }); +}); diff --git a/src/lib/server/test/GameData.test.ts b/src/lib/server/test/GameData.test.ts new file mode 100644 index 0000000..69d66eb --- /dev/null +++ b/src/lib/server/test/GameData.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from "node:test"; +import { GameData, isGameData } from "../../GameData"; +import { equal, ok } from "node:assert/strict"; + +describe("GameData", () => { + describe("isGameData", () => { + it("rejects a malformed object", () => { + let data: unknown = { + players: ["id", 3], + isStarted: false, + state: {}, + }; + equal(isGameData(data), false); + + data = { + players: ["id"], + isStarted: null, + state: {}, + }; + equal(isGameData(data), false); + + data = { + players: ["id"], + isStarted: false, + }; + equal(isGameData(data), false); + }); + + it("rejects an object with extra properties", () => { + const data: GameData & { extra: boolean } = { + players: ["id"], + isStarted: false, + state: {}, + extra: true, + }; + + equal(isGameData(data), false); + }); + + it("should accept a proper GameData object", () => { + const data: GameData = { + players: ["id"], + state: {}, + isStarted: false, + }; + + ok(isGameData(data)); + }); + }); +}); diff --git a/src/lib/server/test/GameEvent.test.ts b/src/lib/server/test/GameEvent.test.ts new file mode 100644 index 0000000..f783c25 --- /dev/null +++ b/src/lib/server/test/GameEvent.test.ts @@ -0,0 +1,991 @@ +import { + FIRST_ROLL_LOST, + FIRST_ROLL_PENDING, + GameEventKind, + getGameEvent, + Hold, + isGameEventData, + Roll, + RollForFirst, + Score, + SeatPlayers, +} from "../../GameEvent"; +import type { GameEventData } from "../../GameEvent"; +import type { GameData } from "../../GameData"; +import { describe, it } from "node:test"; +import type { State } from "../../State"; +import { doesNotThrow, deepStrictEqual, equal, ok, throws } from "assert"; + +describe("Game Events", () => { + describe("isGameEventData", () => { + it("should return false if the target is not an object", () => { + // const target = { + // kind: GameEventKind.Hold, + // player: 0, + // value: [1, 4], + // }; + + equal(isGameEventData("target"), false); + }); + + it("should return false if the target has no kind", () => { + const target = { + player: 0, + value: [1, 4], + }; + + equal(isGameEventData(target), false); + }); + + it("should return false if the target has uknown keys", () => { + const target = { + kind: GameEventKind.Hold, + player: 0, + value: [1, 4], + isWeird: true, + }; + + equal(isGameEventData(target), false); + }); + + it("should return true of the target is a GameEventData", () => { + const target = { + kind: GameEventKind.Hold, + player: 0, + value: [1, 4], + }; + + ok(isGameEventData(target)); + }); + }); + + describe("getGameEvent", () => { + it("should throw if the kind is unkown", () => { + const data: GameData = { + isStarted: false, + players: ["42", "1,"], + state: {}, + }; + + const event: GameEventData = { + kind: "GameEventKind", + player: 0, + value: [1, 2], + }; + + throws(() => getGameEvent(data, event)); + }); + + it("should throw when SeatPlayers has the wrong number of players", () => { + const data: GameData = { + isStarted: true, + players: ["42", "1,"], + state: {}, + }; + + const event: GameEventData = { + kind: GameEventKind.SeatPlayers, + value: 3, + }; + + throws(() => getGameEvent(data, event)); + }); + + it("should return a SeatPlayers object when the number of players is correct", () => { + const data: GameData = { + isStarted: true, + players: ["42", "1,"], + state: {}, + }; + + const event: GameEventData = { + kind: GameEventKind.SeatPlayers, + value: 2, + }; + + ok(getGameEvent(data, event) instanceof SeatPlayers); + }); + + it("should throw an error if the player passes a full roll with Roll", () => { + const data: GameData = { + isStarted: true, + players: ["42", "1,"], + state: {}, + }; + + const event: GameEventData = { + kind: GameEventKind.Roll, + player: 0, + value: [1, 2, 3, 4, 5, 6], + }; + + throws(() => getGameEvent(data, event)); + }); + + it("should return a Roll object with dice values when the player passes a die count as a value", () => { + const data: GameData = { + isStarted: true, + players: ["42", "1,"], + state: {}, + }; + + const event: GameEventData = { + kind: GameEventKind.Roll, + player: 0, + value: 4, + }; + + const roll = getGameEvent(data, event); + + ok(roll instanceof Roll); + ok(Array.isArray(roll.value)); + equal(roll.value.length, 4); + }); + + it("should return the class that corresponds with a given kind", () => { + const data: GameData = { + isStarted: true, + players: ["42", "1,"], + state: {}, + }; + + const event: GameEventData = { + kind: "", + player: 0, + value: 4, + }; + + const rollForFirst = getGameEvent(data, { + ...event, + kind: GameEventKind.RollForFirst, + }); + + const hold = getGameEvent(data, { + ...event, + value: [0, 1, 3], + kind: GameEventKind.Hold, + }); + + const score = getGameEvent(data, { + ...event, + value: 2_000, + kind: GameEventKind.Score, + }); + + ok(rollForFirst instanceof RollForFirst); + ok(hold instanceof Hold); + ok(score instanceof Score); + }); + }); + + describe("SeatPlayers", () => { + describe("constructor", () => { + it("should throw when value is not a number", () => { + throws(() => new SeatPlayers({ kind: GameEventKind.SeatPlayers, value: [1] })); + }); + }); + + describe("run", () => { + it("should throw if the game is over", () => { + const ev = new SeatPlayers({ kind: GameEventKind.SeatPlayers, value: 3 }); + const state: State = { turnCountdown: 0 }; + + throws(() => ev.run(state)); + }); + + it("should throw if players are already seated", () => { + const ev = new SeatPlayers({ kind: GameEventKind.SeatPlayers, value: 3 }); + const state: State = {}; + + doesNotThrow(() => ev.run(state), "should not throw before players are seated"); + throws(() => ev.run(state)); + }); + + it("should seat the number of players provided", () => { + const ev = new SeatPlayers({ kind: GameEventKind.SeatPlayers, value: 4 }); + const state: State = {}; + + ev.run(state); + deepStrictEqual(state.scores, [0, 0, 0, 0]); + }); + }); + }); + + // TODO: Some of these tests rely on implementation details in an undesirable way. + // It might be better to add some features to State so that we can examine it + // and understand its meaning without having to understand all the gritty + // details. + describe("RollForFirst", () => { + describe("constructor", () => { + it("should throw if player is missing", () => { + throws(() => new RollForFirst({ kind: GameEventKind.RollForFirst, value: 5 })); + }); + + it("should throw if the value is not a number", () => { + throws( + () => + new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: [4] }), + ); + }); + }); + + describe("run", () => { + it("should throw if the game is over", () => { + const ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 0, + value: 3, + }); + const state: State = { + scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING], + turnCountdown: 0, + }; + + throws(() => ev.run(state)); + }); + + it("should throw if the game has already started", () => { + const ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 0, + value: 3, + }); + const state: State = { + scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING], + playing: 0, + }; + + throws(() => ev.run(state)); + }); + + it("should throw if the player index is out of bounds", () => { + const ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 2, + value: 3, + }); + const state: State = { scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING] }; + + throws(() => ev.run(state)); + console.log("done"); + }); + + it("should throw if the player has already rolled", () => { + const ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 1, + value: 3, + }); + const state: State = { scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING] }; + + doesNotThrow(() => ev.run(state)); + throws(() => ev.run(state)); + }); + + it("should set the players score to match the dice roll", () => { + const ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 1, + value: 3, + }); + const state: State = { scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING] }; + + ev.run(state); + deepStrictEqual(state, { scores: [FIRST_ROLL_PENDING, 3] }); + }); + + it("should reset the scores and set the winning player when everyone has rolled", () => { + const state: State = { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + ], + }; + + let ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 1, + value: 4, + }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: 3 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 2, value: 6 }); + ev.run(state); + + deepStrictEqual(state, { scores: [3, 4, 6, 0] }); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 3, value: 4 }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + ], + playing: 2, + }); + }); + + it("should reset tied players for tie breaker", () => { + const state: State = { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + ], + }; + + let ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 3, + value: 5, + }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: 5 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 1, value: 3 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 2, value: 1 }); + ev.run(state); + + deepStrictEqual(state, { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_LOST, + FIRST_ROLL_LOST, + FIRST_ROLL_PENDING, + ], + }); + }); + + it("should throw if a player whose lost tries to roll again", () => { + const state: State = { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_LOST, + FIRST_ROLL_LOST, + FIRST_ROLL_PENDING, + ], + }; + + const ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 1, + value: 3, + }); + throws(() => ev.run(state)); + }); + + it("should allow tied players to keep rolling until somoene wins", () => { + const state: State = { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_LOST, + FIRST_ROLL_PENDING, + ], + }; + + // simulate another 3-way tie + let ev = new RollForFirst({ + kind: GameEventKind.RollForFirst, + player: 0, + value: 5, + }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 1, value: 5 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 3, value: 5 }); + ev.run(state); + + deepStrictEqual( + state, + { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_PENDING, + FIRST_ROLL_LOST, + FIRST_ROLL_PENDING, + ], + }, + "shouldn't change in a 3-way tie", + ); + + // simulate a 2-way tie + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: 2 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 1, value: 1 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 3, value: 2 }); + ev.run(state); + + deepStrictEqual( + state, + { + scores: [ + FIRST_ROLL_PENDING, + FIRST_ROLL_LOST, + FIRST_ROLL_LOST, + FIRST_ROLL_PENDING, + ], + }, + "should update for a smaller tie", + ); + + // finally find a winner + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: 3 }); + ev.run(state); + + ev = new RollForFirst({ kind: GameEventKind.RollForFirst, player: 3, value: 1 }); + ev.run(state); + + deepStrictEqual( + state, + { + dieCount: 6, + scores: [0, 0, 0, 0], + playing: 0, + }, + "should finally select a winner", + ); + }); + }); + }); + + // TODO: It's very hard to tell if these tests are throwing for the right reason. + // Some system should be devised for making sure that the correct errors are + // being thrown. + describe("Roll", () => { + describe("constructor", () => { + it("should throw if player is missing", () => { + throws(() => new Roll({ kind: GameEventKind.Roll, value: 5 })); + }); + + it("should throw if the value is not a number array", () => { + throws(() => new Roll({ kind: GameEventKind.Roll, player: 0, value: 4 })); + }); + }); + + describe("run", () => { + it("should throw if the game is over", () => { + const state: State = { + scores: [0, 0], + dieCount: 6, + playing: 0, + turnCountdown: 0, + }; + + const ev = new Roll({ + kind: GameEventKind.Roll, + player: 0, + value: [1, 2, 3, 4, 5, 6], + }); + + throws(() => ev.run(state)); + }); + + it("should throw if it's not the player's turn", () => { + const state: State = { scores: [0, 0], dieCount: 6, playing: 1 }; + const ev = new Roll({ + kind: GameEventKind.Roll, + player: 0, + value: [1, 2, 3, 4, 5, 6], + }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player index is out of bounds", () => { + const state: State = { scores: [0, 0], dieCount: 6, playing: 0 }; + const ev = new Roll({ + kind: GameEventKind.Roll, + player: 3, + value: [1, 2, 3, 4, 5, 6], + }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player is rolling more dice than they have", () => { + const state: State = { scores: [0, 0], dieCount: 4, playing: 0 }; + const ev = new Roll({ + kind: GameEventKind.Roll, + player: 0, + value: [1, 2, 3, 4, 5, 6], + }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player has already rolled", () => { + const state: State = { + scores: [0, 0], + dieCount: 6, + playing: 0, + dice: [1, 2, 3, 4, 5, 6], + }; + + const ev = new Roll({ + kind: GameEventKind.Roll, + player: 0, + value: [1, 2, 3, 4, 5, 6], + }); + + throws(() => ev.run(state)); + }); + + it("should set the dice when the player rolls", () => { + const state: State = { + scores: [0, 0], + dieCount: 4, + playing: 0, + }; + + const ev = new Roll({ kind: GameEventKind.Roll, player: 0, value: [1, 2, 3, 4] }); + ev.run(state); + + deepStrictEqual(state, { + scores: [0, 0], + playing: 0, + dice: [1, 2, 3, 4], + }); + }); + }); + }); + + describe("Hold", () => { + describe("constructor", () => { + it("should throw if player missing", () => { + throws(() => new Hold({ kind: GameEventKind.Hold, value: [0] })); + }); + + it("should throw if the value is not a number array", () => { + throws(() => new Hold({ kind: GameEventKind.Hold, player: 0, value: 3 })); + }); + }); + + describe("run", () => { + it("should throw if the game is over", () => { + const state: State = { + scores: [0, 0, 0], + playing: 0, + dice: [1, 1, 5, 3], + turnCountdown: 0, + }; + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 1, 2] }); + + throws(() => ev.run(state)); + }); + + it("should throw if it is not the player's turn", () => { + const state: State = { + scores: [0, 0, 0], + playing: 1, + dice: [1, 1, 5, 3], + }; + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 1, 2] }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player has not rolled yet", () => { + const state: State = { + scores: [0, 0, 0], + playing: 0, + }; + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 1, 2] }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player is trying to hold no dice", () => { + const state: State = { + scores: [0, 0, 0], + playing: 0, + dice: [1, 1, 5, 3], + }; + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [] }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player is trying to hold non-existent dice", () => { + const state: State = { + scores: [0, 0, 0], + playing: 0, + dice: [1, 1, 5, 3], + }; + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 2, 9] }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player is trying to hold non-scoring, non-push dice", () => { + const state: State = { + scores: [0, 0, 0], + playing: 0, + dice: [1, 1, 5, 3], + }; + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 2, 3] }); + + throws(() => ev.run(state)); + }); + + it("should allow the player to hold ones for 100", () => { + let state: State = { + dice: [1, 1, 5, 3], + playing: 0, + scores: [0, 0, 0], + }; + + let ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0] }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 3, + heldScore: 100, + playing: 0, + scores: [0, 0, 0], + }); + + state = { + dice: [1, 1, 5, 3], + playing: 0, + scores: [0, 0, 0], + }; + + ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 1] }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 2, + heldScore: 200, + playing: 0, + scores: [0, 0, 0], + }); + }); + + it("should allow the player to hold fives for 50", () => { + let state: State = { + dice: [5, 5, 5, 3], + playing: 0, + scores: [0, 0, 0], + }; + + let ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0] }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 3, + heldScore: 50, + playing: 0, + scores: [0, 0, 0], + }); + + state = { + dice: [5, 5, 5, 3], + playing: 0, + scores: [0, 0, 0], + }; + + ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 1] }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 2, + heldScore: 100, + playing: 0, + scores: [0, 0, 0], + }); + }); + + it("should allow the player to hold three of a kind", () => { + testNOfAKind(3); + }); + + it("should allow the player to hold four of a kind", () => { + testNOfAKind(4); + }); + + it("should allow the player to hold five of a kind", () => { + testNOfAKind(5); + }); + + it("should allow the player to hold six of a kind", () => { + testNOfAKind(6); + }); + + it("should allow the player to hold a run of six for 2,000 points", () => { + const state: State = { + dice: [1, 2, 3, 4, 5, 6], + playing: 0, + scores: [0, 0, 0], + }; + + const ev = new Hold({ + kind: GameEventKind.Hold, + player: 0, + value: [1, 0, 2, 5, 4, 3], + }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + heldScore: 2_000, + playing: 0, + scores: [0, 0, 0], + }); + }); + + it("should allow the player to hold 2 threes of a kind for 1,500 points", () => { + const state: State = { + dice: [2, 2, 2, 3, 3, 3], + playing: 0, + scores: [0, 0, 0], + }; + + const ev = new Hold({ + kind: GameEventKind.Hold, + player: 0, + value: [0, 1, 2, 3, 4, 5], + }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + heldScore: 1_500, + playing: 0, + scores: [0, 0, 0], + }); + }); + + it("shoud allow the player to re-roll on a push", () => { + const state: State = { + dice: [2, 2], + playing: 0, + scores: [0, 0, 0], + }; + + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: [0, 1] }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + heldScore: 0, + playing: 0, + scores: [0, 0, 0], + }); + }); + }); + }); + + describe("Score", () => { + describe("constructor", () => { + it("should throw if player missing", () => { + throws(() => new Score({ kind: GameEventKind.Score, value: 200 })); + }); + + it("should throw if the value is not a number", () => { + throws(() => new Score({ kind: GameEventKind.Hold, player: 0, value: [200] })); + }); + }); + + describe("run", () => { + it("should throw if the game is over", () => { + const state: State = { + dieCount: 4, + heldScore: 200, + playing: 0, + turnCountdown: 0, + scores: [0, 0, 0], + }; + + const ev = new Score({ kind: GameEventKind.Score, player: 0, value: 200 }); + + throws(() => ev.run(state)); + }); + + it("should throw if it's not the players turn", () => { + const state: State = { + dieCount: 4, + heldScore: 200, + playing: 1, + scores: [0, 0, 0], + }; + + const ev = new Score({ kind: GameEventKind.Score, player: 0, value: 200 }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player is trying to score with 6 dice available to roll", () => { + const state: State = { + dieCount: 6, + heldScore: 200, + playing: 0, + scores: [0, 0, 0], + }; + + const ev = new Score({ kind: GameEventKind.Score, player: 0, value: 200 }); + + throws(() => ev.run(state)); + }); + + it("should throw if the player is trying to score a different value than the held score", () => { + const state: State = { + dieCount: 4, + heldScore: 200, + playing: 0, + scores: [0, 0, 0], + }; + + const ev = new Score({ kind: GameEventKind.Score, player: 0, value: 250 }); + throws(() => ev.run(state)); + }); + + it("should add the score to the players score and activate the next player", () => { + let state: State = { + dieCount: 4, + heldScore: 200, + playing: 0, + scores: [0, 0, 0], + }; + + let ev = new Score({ kind: GameEventKind.Score, player: 0, value: 200 }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + playing: 1, + scores: [200, 0, 0], + }); + + state = { ...state, heldScore: 300, dieCount: 3 }; + ev = new Score({ kind: GameEventKind.Score, player: 1, value: 300 }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + playing: 2, + scores: [200, 300, 0], + }); + + state = { ...state, heldScore: 400, dieCount: 3 }; + ev = new Score({ kind: GameEventKind.Score, player: 2, value: 400 }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + playing: 0, + scores: [200, 300, 400], + }); + }); + + it("should begin the turn countdown when a player crosses 10,000 points", () => { + const state: State = { + dieCount: 4, + heldScore: 2_000, + playing: 0, + scores: [9_000, 0, 0], + }; + + const ev = new Score({ kind: GameEventKind.Score, player: 0, value: 2_000 }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + playing: 1, + turnCountdown: 2, + scores: [11_000, 0, 0], + }); + }); + + it("should decrement the turn countdown when it is active", () => { + const state: State = { + dieCount: 4, + heldScore: 2_000, + turnCountdown: 2, + playing: 1, + scores: [10_000, 0, 0], + }; + + const ev = new Score({ kind: GameEventKind.Score, player: 1, value: 2_000 }); + ev.run(state); + + deepStrictEqual(state, { + dieCount: 6, + playing: 2, + turnCountdown: 1, + scores: [10_000, 2_000, 0], + }); + }); + }); + }); +}); + +function testNOfAKind(n: number) { + // Each of these arrays starts with some three of a kind. + const nOfAKind: number[][] = []; + + for (let i = 1; i <= 6; i++) { + nOfAKind.push(new Array(n).fill(i)); + } + + // Check each of these arrays. + for (const roll of nOfAKind) { + // The first value in each array is part of the three of a kind. + const value = roll[0]; + let expectedValue: number; + + const state: State = { + dice: roll, + playing: 0, + scores: [0, 0, 0], + }; + + if (value === 1) { + // Ones are treated like tens, so three ones is 1,000. + expectedValue = 1_000 * (n - 2); + } else { + // All other values are treated as themselves, and three of a kind + // is the number of pips multiplied by 100. + expectedValue = value * 100 * (n - 2); + } + + // Generate an array of the first n indexes. + const holdValue: number[] = []; + for (let i = 0; i < n; i++) { + holdValue.push(i); + } + + const ev = new Hold({ kind: GameEventKind.Hold, player: 0, value: holdValue }); + ev.run(state); + + deepStrictEqual( + state, + { + dieCount: 6, + heldScore: expectedValue, + playing: 0, + scores: [0, 0, 0], + }, + `expected ${n} ${value}s to be worth ${expectedValue}`, + ); + } +} diff --git a/src/lib/server/test/Listing.test.ts b/src/lib/server/test/Listing.test.ts new file mode 100644 index 0000000..6079d50 --- /dev/null +++ b/src/lib/server/test/Listing.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "node:test"; +import { createNewListing, updateListing } from "../modifyListing"; +import { Game } from "../../Game"; +import { deepEqual, equal, ok } from "node:assert/strict"; + +describe("Listing", () => { + describe("createNewListing", () => { + it("should create a new Listing with the provided data, and a new UUID", () => { + const game = new Game(); + const listing = createNewListing(game); + + ok(listing.data instanceof Game); + ok(listing.createdAt instanceof Date); + ok(listing.modifiedAt === null); + ok(!listing.deleted); + equal(typeof listing.id, "string"); + }); + }); + + describe("updateListing", () => { + it("should return a new listing with updated data and a modified date", () => { + const game = new Game(); + const update = new Game(); + update.isStarted = !game.isStarted; + + const listing = createNewListing(game); + const updatedListing = updateListing(listing, update); + + deepEqual(updatedListing.data, update); + ok(updatedListing.modifiedAt instanceof Date); + }); + }); +}); diff --git a/src/lib/server/test/getDiceRoll.test.ts b/src/lib/server/test/getDiceRoll.test.ts new file mode 100644 index 0000000..f63bbf4 --- /dev/null +++ b/src/lib/server/test/getDiceRoll.test.ts @@ -0,0 +1,15 @@ +import { describe, it } from "node:test"; +import { getDiceRoll } from "../../getDiceRoll"; +import { deepEqual } from "node:assert/strict"; + +function testRandom() { + const val = [0, 0.2, 0.5, 0.5, 0.7, 0.9]; + return () => val.shift()!; +} + +describe("getDiceRoll", () => { + it("should return an array of numbers from 1 to 6 with a given length", () => { + let rand = getDiceRoll(6, testRandom()); + deepEqual(rand, [0, 1, 3, 3, 4, 6]); + }); +}); diff --git a/src/lib/server/test/validation.test.ts b/src/lib/server/test/validation.test.ts new file mode 100644 index 0000000..d54e7ae --- /dev/null +++ b/src/lib/server/test/validation.test.ts @@ -0,0 +1,140 @@ +import { describe, it } from "node:test"; +import { equal, ok } from "node:assert/strict"; +import { hasProperty, hasOnlyKeys } from "../../validation"; + +describe("validation", () => { + describe("hasProperty", () => { + it("should return false if the property is undefined", () => { + const target = { some: "property" }; + const result = hasProperty(target, "important", "string"); + + equal(result, false); + }); + + it("should return false if passed a non-object", () => { + const target = 45; + const result = hasProperty(target, "important", "string"); + + equal(result, false); + }); + + it("should return false if passed null", () => { + const target = null; + const result = hasProperty(target, "important", "string"); + + equal(result, false); + }); + + it("should return false if passed undefined", () => { + const target = undefined; + const result = hasProperty(target, "important", "string"); + + equal(result, false); + }); + + it("should return false if the property is of the wrong type", () => { + const target = { important: 45 }; + const result = hasProperty(target, "important", "string"); + + equal(result, false); + }); + + it("should return true if the property is the correct type", () => { + const target = { + first: "string", + second: 2, + third: false, + fourth: null, + fifth: { something: "important" }, + sixth: ["one", "two"], + }; + + ok(hasProperty(target, "first", "string")); + ok(hasProperty(target, "second", "number")); + ok(hasProperty(target, "third", "boolean")); + ok(hasProperty(target, "fourth", "null")); + ok(hasProperty(target, "fifth", "object")); + ok(hasProperty(target, "sixth", "array")); + }); + + it("should return false if passed an array type and the property isn't an array", () => { + const target = { + arr: "not array", + }; + + equal(hasProperty(target, "arr", "string[]"), false); + }); + + it("should return false if the defined array contains a non-matching element", () => { + const target = { + arr: ["I", "was", "born", "in", 1989], + }; + + equal(hasProperty(target, "arr", "string[]"), false); + }); + + it("should return true if all the elements in a defined array match", () => { + const target = { + arr: ["I", "was", "born", "in", "1989"], + }; + + ok(hasProperty(target, "arr", "string[]")); + }); + + it("should return true if all the elements in a defined array match one of multiple types", () => { + const target = { + arr: ["I", "was", "born", "in", 1989], + }; + + ok(hasProperty(target, "arr", "(string|number)[]")); + }); + + it("should return true if type is null but property is nullable", () => { + const target = { + nullable: null, + }; + + ok(hasProperty(target, "nullable", "string", true)); + }); + }); + + describe("hasOnlyKeys", () => { + it("returns false if the target is not an object", () => { + equal(hasOnlyKeys(45, []), false); + }); + + it("returns false has extra properties", () => { + const target = { + one: "one", + two: "two", + three: "three", + }; + + const keys = ["one", "two"]; + + equal(hasOnlyKeys(target, keys), false); + }); + + it("should return true if the target has only the provided keys", () => { + const target = { + one: "one", + two: "two", + three: "three", + }; + + const keys = ["one", "two", "three"]; + + ok(hasOnlyKeys(target, keys)); + }); + + it("should return true if the target has only a subset of the provided keys", () => { + const target = { + one: "one", + }; + + const keys = ["one", "two", "three"]; + + ok(hasOnlyKeys(target, keys)); + }); + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..30f96e3 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,94 @@ +// hasProperty takes a target, which is unknown, propertyName and propertyType, which +// are string and isNullable, which is a boolean. If the property exists on the target, +// and the type of the property value matches the string passes (as determined using the +// typeof keyword) then hasProperty returns true. An important exception: if the user +// passes null or an array and a propertyType of "object", hasProperty will return false. +// To check if something is an array, pass "array" and to check if something is null, pass +// "null" as the property type. If hasProperty receives a null value, it will still return +// true as long as "isNullable" has been set to true (it defaults to false). +// +// hasProperty can also receive a specific array type which resembles defining arrays of +// items in TypeScript: e.g. string[] is an array of string, (string|number)[] is an array of +// strings or numbers. It does not recognize the Array syntax. +export function hasProperty( + target: unknown, + propertyName: string, + propertyType: string | T, + isNullable: boolean = false, +): boolean { + if (target === null || target === undefined) return false; + + const p = (target as any)[propertyName]; + + if (p === undefined) { + return false; + } + + if (p === null) { + return propertyType === "null" || isNullable; + } + + if (typeof propertyType !== "string") { + return p instanceof propertyType + } + + if (propertyType === "array") { + return Array.isArray(p); + } + + // TODO: this logic won't work on arrays of array, for instance, string[][] + if (propertyType.substring(propertyType.length - 2) === "[]") { + const types = parseArrayType(propertyType); + + if (!Array.isArray(p)) { + return false; + } + + for (const item of p) { + let match = false; + + for (const type of types) { + if (typeof item === type) { + match = true; + break; + } + } + + if (!match) { + return false; + } + } + + return true; + } + + return typeof p === propertyType; +} + +// hasOnlyKeys takes a target, which is unknown, and keys, which is a string array. +// if the target has ONLY own properties that are listed on the string array, then +// hasOnlyKeys returns true. If there is any extra properties, it returns false. +// +// hasOnlyKeys does not make sure that the keys provided exist on the target, only +// that keys not provided do not exist. +export function hasOnlyKeys(target: unknown, keys: string[]) { + if (typeof target !== "object") return false; + + const keySet = new Set(keys); + for (const key in target) { + if (target.hasOwnProperty(key)) { + if (!keySet.has(key)) return false; + } + } + + return true; +} + +function parseArrayType(propertyType: string): string[] { + if (propertyType[0] === "(") { + const typesString = propertyType.substring(1, propertyType.length - 3); + return typesString.split("|"); + } else { + return [propertyType.substring(0, propertyType.length - 2)]; + } +} diff --git a/src/routes/api/+server.ts b/src/routes/api/+server.ts new file mode 100644 index 0000000..b6bf817 --- /dev/null +++ b/src/routes/api/+server.ts @@ -0,0 +1,6 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { listResponse } from "../../lib/server/responseBodies" + +export const GET: RequestHandler = (): Response => { + return listResponse(["games", "players"]); +} diff --git a/src/routes/api/games/+server.ts b/src/routes/api/games/+server.ts new file mode 100644 index 0000000..df3a48c --- /dev/null +++ b/src/routes/api/games/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { listResponse, singleResponse } from "$lib/server/responseBodies"; +import { createNewListing } from "$lib/server/modifyListing"; +import { Game } from "$lib/server/Game"; +import { games } from "$lib/server/cache"; + +export const GET: RequestHandler = (): Response => { + return listResponse(games); +}; + +export const POST: RequestHandler = (): Response => { + const newListing = createNewListing(new Game()); + games.push(newListing); + + return singleResponse(newListing.id); +}; diff --git a/src/routes/api/games/[gameid]/+server.ts b/src/routes/api/games/[gameid]/+server.ts new file mode 100644 index 0000000..d3c86a1 --- /dev/null +++ b/src/routes/api/games/[gameid]/+server.ts @@ -0,0 +1,14 @@ +import { games } from "$lib/server/cache"; +import { notFoundResponse, singleResponse } from "$lib/server/responseBodies"; +import type { RequestHandler } from "@sveltejs/kit"; + +export const GET: RequestHandler = ({ params }): Response => { + const id = params["gameid"]; + const game = games.find(({ id: gid }) => id === gid); + + if (!game) { + return notFoundResponse(); + } + + return singleResponse(game); +}; diff --git a/src/routes/games/+page.svelte b/src/routes/games/+page.svelte new file mode 100644 index 0000000..769533c --- /dev/null +++ b/src/routes/games/+page.svelte @@ -0,0 +1,52 @@ + + +
+

Let’s Play Ten Thousand

+ + + + + + + + + + + {#each games as game} + {@render GameRow(game)} + {/each} + +
No.NamePlayers
+
+ +{#snippet GameRow (game: GameData)} + + {game.id} + {game.name} + {game.players.length} + +{/snippet} + + diff --git a/src/routes/games/[gameid]/+page.svelte b/src/routes/games/[gameid]/+page.svelte new file mode 100644 index 0000000..79e642a --- /dev/null +++ b/src/routes/games/[gameid]/+page.svelte @@ -0,0 +1,15 @@ + + +
+

This is game {data.id}

+
+ + diff --git a/src/routes/games/[gameid]/+page.ts b/src/routes/games/[gameid]/+page.ts new file mode 100644 index 0000000..4a82b58 --- /dev/null +++ b/src/routes/games/[gameid]/+page.ts @@ -0,0 +1,32 @@ +import { isGameData, type GameData } from "$lib/GameData"; +import { isListing } from "$lib/Listing"; +import { isServerResponse } from "$lib/ServerResponse"; +import { error } from "@sveltejs/kit"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params }) => { + const url = `/api/games/${params.gameid}`; + let res: Response; + let body: unknown; + + try { + res = await fetch(url); + body = await res.json(); + } catch (err) { + error(500, "unable to call API"); + } + + if (res.status === 404) { + error(404, `Not Found: ${params.gameid}`); + } + + if (!isServerResponse(body)) { + error(500, "expected to receive a properly formatted server response body"); + } + + if ("item" in body && isListing(body.item, isGameData)) { + return body.item; + } else { + error(500, "expected response body to contain game data"); + } +}; diff --git a/tests/requests.http b/tests/requests.http new file mode 100644 index 0000000..6f8222e --- /dev/null +++ b/tests/requests.http @@ -0,0 +1,41 @@ +GET http://localhost:5173/api +Accept: application/json + +### + +GET http://localhost:5173/api/games +Accept: application/json + +### + +POST http://localhost:5173/api/games +Accept: application/json + +### + +GET http://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3 +Accept: application/json + +### + +PUT http://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3 +Accept: application/json +Content-Type: application/json + +{ + "state": {}, + "isStarted": true, + "players": ["2", "45", "10"] +} + +### + +POST http://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3/turns +Accept: application/json +Content-Type: application/json + +{ + "kind": "Roll", + "player": 2, + "value": 4 +} diff --git a/vite.config.ts b/vite.config.ts index d76fc8a..284484a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,19 @@ -import { defineConfig } from 'vitest/config'; -import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from "vitest/config"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { readFileSync } from "fs"; export default defineConfig({ plugins: [sveltekit()], + server: { + https: { + key: readFileSync(`${__dirname}/cert/key.pem`), + cert: readFileSync(`${__dirname}/cert/cert.pem`) + }, + proxy: {} + }, + test: { - include: ['src/**/*.{test,spec}.{js,ts}'] + include: ["src/**/*.{test,spec}.{js,ts}"] } });