From 62f0636d2d3bc9b2e1aaf29782d6f8ad5b03ba23 Mon Sep 17 00:00:00 2001 From: Nolan Hellyer Date: Sat, 31 May 2025 16:51:58 -0700 Subject: [PATCH] implement ready up for players in a game lobby --- src/lib/GameData.ts | 11 +--- src/lib/GamePlayer.ts | 24 +++++++ src/lib/components/PlayerList.svelte | 38 ++++++++--- src/lib/server/Game.ts | 17 ++++- src/lib/server/requestTools.ts | 2 + src/lib/server/responseBodies.ts | 4 ++ src/lib/server/test/Game.spec.ts | 4 +- src/lib/test/GameData.spec.ts | 21 +++++-- src/lib/test/GameEvent.spec.ts | 2 + .../[gameid]/players/[playerid]/+server.ts | 60 ++++++++++++++++++ .../{[turns] => turns/[turnid]}/+server.ts | 1 - src/routes/games/+page.svelte | 16 +++-- src/routes/games/[gameid]/+page.server.ts | 10 ++- src/routes/games/[gameid]/+page.svelte | 63 ++++++++++++++++--- 14 files changed, 226 insertions(+), 47 deletions(-) create mode 100644 src/lib/GamePlayer.ts create mode 100644 src/routes/api/games/[gameid]/players/[playerid]/+server.ts rename src/routes/api/games/[gameid]/{[turns] => turns/[turnid]}/+server.ts (98%) diff --git a/src/lib/GameData.ts b/src/lib/GameData.ts index 10a30f7..df5f06e 100644 --- a/src/lib/GameData.ts +++ b/src/lib/GameData.ts @@ -1,10 +1,11 @@ import { hasOnlyKeys, hasProperty } from "./validation"; import { isId, type Id } from "./Id"; import type { State } from "./State"; +import { isGamePlayer, type GamePlayer } from "./GamePlayer"; export interface GameData { isStarted: boolean; - players: { id: Id; username: string }[]; + players: GamePlayer[]; state: State; } @@ -17,13 +18,7 @@ export function isGameData(target: unknown): target is GameData { const { players } = target as any; for (const player of players) { - if (!isId(player.id)) { - return false; - } - - if (!hasProperty(player, "username", "string")) { - return false; - } + if (!isGamePlayer(player)) return false; } } else { return false; diff --git a/src/lib/GamePlayer.ts b/src/lib/GamePlayer.ts new file mode 100644 index 0000000..c0afc7b --- /dev/null +++ b/src/lib/GamePlayer.ts @@ -0,0 +1,24 @@ +import { isId, type Id } from "./Id"; +import { hasOnlyKeys, hasProperty } from "./validation"; + +export interface GamePlayer { + id: Id; + username: string; + isReady: boolean; +} + +export function isGamePlayer(target: unknown): target is GamePlayer { + if (!isId((target as any).id)) { + return false; + } + + if (!hasProperty(target, "username", "string")) { + return false; + } + + if (!hasProperty(target, "isReady", "boolean")) { + return false; + } + + return hasOnlyKeys(target, ["username", "id", "isReady"]); +} diff --git a/src/lib/components/PlayerList.svelte b/src/lib/components/PlayerList.svelte index e10fbdd..cec7f59 100644 --- a/src/lib/components/PlayerList.svelte +++ b/src/lib/components/PlayerList.svelte @@ -1,19 +1,29 @@ -
    {#each players as player} - {#if me !== null && player.id === me.id} -
  1. you
  2. - {:else} -
  3. {player.username}
  4. - {/if} +
  5. {player.username}
  6. {/each}
@@ -23,6 +33,18 @@ font-weight: bold; } + .ready::after { + content: " [ready]"; + color: green; + font-weight: bold; + } + + .unready::after { + content: " [unready]"; + color: red; + font-weight: bold; + } + .list { border: 1pt gray solid; background: white; diff --git a/src/lib/server/Game.ts b/src/lib/server/Game.ts index 6dac908..ae96d71 100644 --- a/src/lib/server/Game.ts +++ b/src/lib/server/Game.ts @@ -1,9 +1,10 @@ import type { Id } from "../Id"; import type { GameData } from "../GameData"; import type { State } from "../State"; +import type { GamePlayer } from "$lib/GamePlayer"; export class Game implements GameData { - players: { id: Id; username: string }[]; + players: GamePlayer[]; isStarted: boolean; state: State; @@ -22,8 +23,18 @@ export class Game implements GameData { return game; } - addPlayer(id: Id, username: string) { - this.players.push({ id, username }); + addPlayer(player: GamePlayer) { + this.players.push(player); + } + + setPlayerReady({ id: playerId, isReady }: GamePlayer) { + const player = this.players.find(({ id }) => playerId === id); + + if (!player) return null; + + player.isReady = isReady; + + return player; } start() { diff --git a/src/lib/server/requestTools.ts b/src/lib/server/requestTools.ts index 7780cf7..58a2f06 100644 --- a/src/lib/server/requestTools.ts +++ b/src/lib/server/requestTools.ts @@ -2,6 +2,8 @@ import type { LocalCredentials } from "./auth"; export enum ResourceId { Game = "gameid", + Turn = "turnid", + Player = "playerid", } export function getUser(locals: { user: LocalCredentials }) { diff --git a/src/lib/server/responseBodies.ts b/src/lib/server/responseBodies.ts index 5378f8b..6bb885b 100644 --- a/src/lib/server/responseBodies.ts +++ b/src/lib/server/responseBodies.ts @@ -10,6 +10,10 @@ export function createdResponse(id: string) { return Response.json({ item: id }, { status: 201 }); } +export function conflictResponse() { + return Response.json({ error: "Conflict" }, { status: 409 }); +} + export function tokenResponse(token: string) { return Response.json({ access_token: token }); } diff --git a/src/lib/server/test/Game.spec.ts b/src/lib/server/test/Game.spec.ts index 77a0420..42eec30 100644 --- a/src/lib/server/test/Game.spec.ts +++ b/src/lib/server/test/Game.spec.ts @@ -13,9 +13,9 @@ describe("Game", () => { equal(game.players.length, 0); - game.addPlayer(idString, user); + game.addPlayer({ id: idString, username: user, isReady: false }); equal(game.players.length, 1); - deepEqual(game.players[0], { id: idString, username: user }); + deepEqual(game.players[0], { id: idString, username: user, isReady: false }); }); }); diff --git a/src/lib/test/GameData.spec.ts b/src/lib/test/GameData.spec.ts index a1b9f31..a9eea47 100644 --- a/src/lib/test/GameData.spec.ts +++ b/src/lib/test/GameData.spec.ts @@ -30,12 +30,25 @@ describe("GameData", () => { }); it("rejects an object with a malformed players array", () => { - const data: unknown = { - players: [{ id: idString }], + let data: unknown = { + players: [{ id: idString, username: "Mr. User" }], state: {}, isStarted: false, }; + equal(isGameData(data), false); + data = { + players: [{ id: idString, isReady: false }], + state: {}, + isStarted: false, + }; + equal(isGameData(data), false); + + data = { + players: [{ username: "Mr. User", isReady: false }], + state: {}, + isStarted: false, + }; equal(isGameData(data), false); }); @@ -50,7 +63,7 @@ describe("GameData", () => { it("rejects an object with extra properties", () => { const data: unknown = { - players: [{ username: "Mr. User", id: idString }], + players: [{ username: "Mr. User", id: idString, isReady: false }], isStarted: false, state: {}, extra: true, @@ -61,7 +74,7 @@ describe("GameData", () => { it("should accept a proper GameData object", () => { const data: unknown = { - players: [{ username: "Mr. User", id: idString }], + players: [{ username: "Mr. User", id: idString, isReady: false }], state: {}, isStarted: false, }; diff --git a/src/lib/test/GameEvent.spec.ts b/src/lib/test/GameEvent.spec.ts index d44a0af..3c8f80a 100644 --- a/src/lib/test/GameEvent.spec.ts +++ b/src/lib/test/GameEvent.spec.ts @@ -58,11 +58,13 @@ describe("Game Events", () => { const playerOne = { id: createId(), username: "Player One", + isReady: false, }; const playerTwo = { id: createId(), username: "Player Two", + isReady: false, }; it("should throw if the kind is unkown", () => { diff --git a/src/routes/api/games/[gameid]/players/[playerid]/+server.ts b/src/routes/api/games/[gameid]/players/[playerid]/+server.ts new file mode 100644 index 0000000..67a7b18 --- /dev/null +++ b/src/routes/api/games/[gameid]/players/[playerid]/+server.ts @@ -0,0 +1,60 @@ +import type { GameData } from "$lib/GameData"; +import { isGamePlayer, type GamePlayer } from "$lib/GamePlayer"; +import { isListing } from "$lib/Listing"; +import { Game } from "$lib/server/Game"; +import { updateListing } from "$lib/server/modifyListing"; +import { + readListingById, + ServerCollections, + writeUpdatedListing, +} from "$lib/server/mongo"; +import { getBody, getParam, ResourceId } from "$lib/server/requestTools"; +import { + badRequestResponse, + conflictResponse, + notFoundResponse, + singleResponse, +} from "$lib/server/responseBodies"; +import type { RequestHandler } from "@sveltejs/kit"; + +export const PUT: RequestHandler = async ({ params, request }): Promise => { + const id = getParam(params, ResourceId.Game); + + if (id === null) { + return badRequestResponse("missing playerid parameter"); + } + + let player: GamePlayer | null; + try { + player = await getBody(request, isGamePlayer); + } catch (err) { + return badRequestResponse("missing player body"); + } + + if (!player) { + return badRequestResponse("malformed request"); + } + + const listing = await readListingById(ServerCollections.Games, id, isListing); + if (!listing) { + return notFoundResponse(); + } + + if (listing.data.isStarted === true) { + return conflictResponse(); + } + + const game = Game.from(listing.data); + if (game.setPlayerReady(player) === null) return notFoundResponse(); + + // TODO: there's a potential race condition here where some player is unreadying as this + // function is running. This should do some kind of check in MongoDB to make sure + // all players are ready if it's starting the game. For now: good enough. + if (game.players.every(({ isReady }) => isReady)) { + game.start(); + } + + await writeUpdatedListing(ServerCollections.Games, updateListing(listing, game)); + + return singleResponse(game); +}; diff --git a/src/routes/api/games/[gameid]/[turns]/+server.ts b/src/routes/api/games/[gameid]/turns/[turnid]/+server.ts similarity index 98% rename from src/routes/api/games/[gameid]/[turns]/+server.ts rename to src/routes/api/games/[gameid]/turns/[turnid]/+server.ts index a51ba2c..feaff62 100644 --- a/src/routes/api/games/[gameid]/[turns]/+server.ts +++ b/src/routes/api/games/[gameid]/turns/[turnid]/+server.ts @@ -12,7 +12,6 @@ import { import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools"; import { badRequestResponse, - createdResponse, notFoundResponse, singleResponse, } from "$lib/server/responseBodies"; diff --git a/src/routes/games/+page.svelte b/src/routes/games/+page.svelte index 56fea7d..dc90688 100644 --- a/src/routes/games/+page.svelte +++ b/src/routes/games/+page.svelte @@ -23,9 +23,7 @@
{prettyDate(new Date(game.createdAt))}
- {#each games as game} - - {/each} +
@@ -40,20 +38,20 @@ .game-listing { display: flex; - gap: 3rem; + gap: 1rem; padding: 0.5rem; } - .game-listing > div { - flex: 1 1 auto; + .game-listing > * { + flex: auto; } - form { - display: flex; - flex: 1 1 auto; + .game-listing > div:first-child { + max-width: 12rem; } form input { width: 100%; + height: 100%; } diff --git a/src/routes/games/[gameid]/+page.server.ts b/src/routes/games/[gameid]/+page.server.ts index 80b0188..061ee73 100644 --- a/src/routes/games/[gameid]/+page.server.ts +++ b/src/routes/games/[gameid]/+page.server.ts @@ -4,13 +4,14 @@ import { isServerResponse } from "$lib/ServerResponse"; import { error } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; -export const load: PageServerLoad = async ({ fetch, params, cookies }) => { +export const load: PageServerLoad = async ({ fetch, params, cookies, depends }) => { const url = `/api/games/${params.gameid}`; let res: Response; let body: unknown; - const token = cookies.get("access_token"); + depends(url); + if (!token) { error(401); } @@ -31,7 +32,10 @@ export const load: PageServerLoad = async ({ fetch, params, cookies }) => { } if ("item" in body && isListing(body.item, isGameData)) { - return { game: body.item }; + return { + game: body.item, + token, + }; } else { error(res.status, "unable to fetch game data"); } diff --git a/src/routes/games/[gameid]/+page.svelte b/src/routes/games/[gameid]/+page.svelte index b806597..b068c19 100644 --- a/src/routes/games/[gameid]/+page.svelte +++ b/src/routes/games/[gameid]/+page.svelte @@ -1,8 +1,30 @@ {#if page.game.data.isStarted} @@ -11,12 +33,19 @@

This is some lobby

- +
+ +
+ +
+
+ Chat Feature Under Construction
-
-
-
-
{/if} @@ -30,14 +59,30 @@ #players { flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + } + + #player-list { + flex: auto; } #chat { flex: 2 2; } - #controls { - margin: 1rem 0; - text-align: right; + #ready { + height: 4rem; + } + + #chat span { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: white; + font-weight: bold; + text-transform: uppercase; }