implement ready up for players in a game lobby

This commit is contained in:
2025-05-31 16:51:58 -07:00
parent 6d6d6801db
commit 62f0636d2d
14 changed files with 226 additions and 47 deletions

View File

@ -1,10 +1,11 @@
import { hasOnlyKeys, hasProperty } from "./validation"; import { hasOnlyKeys, hasProperty } from "./validation";
import { isId, type Id } from "./Id"; import { isId, type Id } from "./Id";
import type { State } from "./State"; import type { State } from "./State";
import { isGamePlayer, type GamePlayer } from "./GamePlayer";
export interface GameData { export interface GameData {
isStarted: boolean; isStarted: boolean;
players: { id: Id; username: string }[]; players: GamePlayer[];
state: State; state: State;
} }
@ -17,13 +18,7 @@ export function isGameData(target: unknown): target is GameData {
const { players } = target as any; const { players } = target as any;
for (const player of players) { for (const player of players) {
if (!isId(player.id)) { if (!isGamePlayer(player)) return false;
return false;
}
if (!hasProperty(player, "username", "string")) {
return false;
}
} }
} else { } else {
return false; return false;

24
src/lib/GamePlayer.ts Normal file
View File

@ -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"]);
}

View File

@ -1,19 +1,29 @@
<script> <script lang="ts">
import type { GamePlayer } from "$lib/GamePlayer";
import { getMeContext } from "$lib/meContext"; import { getMeContext } from "$lib/meContext";
const { players } = $props(); const { players, withReadyStatus = false } = $props();
const me = getMeContext(); const me = getMeContext();
function getClasses(player: GamePlayer, withReadyStatus: boolean) {
let classes = "";
if (withReadyStatus) {
classes += player.isReady ? "ready" : "unready";
}
if (me !== null && player.id === me.id) {
classes += " you";
}
return classes;
}
</script> </script>
<div class="list"> <div class="list">
<ol> <ol>
{#each players as player} {#each players as player}
{#if me !== null && player.id === me.id} <li class={getClasses(player, withReadyStatus)}>{player.username}</li>
<li class="you">you</li>
{:else}
<li>{player.username}</li>
{/if}
{/each} {/each}
</ol> </ol>
</div> </div>
@ -23,6 +33,18 @@
font-weight: bold; font-weight: bold;
} }
.ready::after {
content: " [ready]";
color: green;
font-weight: bold;
}
.unready::after {
content: " [unready]";
color: red;
font-weight: bold;
}
.list { .list {
border: 1pt gray solid; border: 1pt gray solid;
background: white; background: white;

View File

@ -1,9 +1,10 @@
import type { Id } from "../Id"; import type { Id } from "../Id";
import type { GameData } from "../GameData"; import type { GameData } from "../GameData";
import type { State } from "../State"; import type { State } from "../State";
import type { GamePlayer } from "$lib/GamePlayer";
export class Game implements GameData { export class Game implements GameData {
players: { id: Id; username: string }[]; players: GamePlayer[];
isStarted: boolean; isStarted: boolean;
state: State; state: State;
@ -22,8 +23,18 @@ export class Game implements GameData {
return game; return game;
} }
addPlayer(id: Id, username: string) { addPlayer(player: GamePlayer) {
this.players.push({ id, username }); 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() { start() {

View File

@ -2,6 +2,8 @@ import type { LocalCredentials } from "./auth";
export enum ResourceId { export enum ResourceId {
Game = "gameid", Game = "gameid",
Turn = "turnid",
Player = "playerid",
} }
export function getUser(locals: { user: LocalCredentials }) { export function getUser(locals: { user: LocalCredentials }) {

View File

@ -10,6 +10,10 @@ export function createdResponse(id: string) {
return Response.json({ item: id }, { status: 201 }); return Response.json({ item: id }, { status: 201 });
} }
export function conflictResponse() {
return Response.json({ error: "Conflict" }, { status: 409 });
}
export function tokenResponse(token: string) { export function tokenResponse(token: string) {
return Response.json({ access_token: token }); return Response.json({ access_token: token });
} }

View File

@ -13,9 +13,9 @@ describe("Game", () => {
equal(game.players.length, 0); equal(game.players.length, 0);
game.addPlayer(idString, user); game.addPlayer({ id: idString, username: user, isReady: false });
equal(game.players.length, 1); equal(game.players.length, 1);
deepEqual(game.players[0], { id: idString, username: user }); deepEqual(game.players[0], { id: idString, username: user, isReady: false });
}); });
}); });

View File

@ -30,12 +30,25 @@ describe("GameData", () => {
}); });
it("rejects an object with a malformed players array", () => { it("rejects an object with a malformed players array", () => {
const data: unknown = { let data: unknown = {
players: [{ id: idString }], players: [{ id: idString, username: "Mr. User" }],
state: {}, state: {},
isStarted: false, 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); equal(isGameData(data), false);
}); });
@ -50,7 +63,7 @@ describe("GameData", () => {
it("rejects an object with extra properties", () => { it("rejects an object with extra properties", () => {
const data: unknown = { const data: unknown = {
players: [{ username: "Mr. User", id: idString }], players: [{ username: "Mr. User", id: idString, isReady: false }],
isStarted: false, isStarted: false,
state: {}, state: {},
extra: true, extra: true,
@ -61,7 +74,7 @@ describe("GameData", () => {
it("should accept a proper GameData object", () => { it("should accept a proper GameData object", () => {
const data: unknown = { const data: unknown = {
players: [{ username: "Mr. User", id: idString }], players: [{ username: "Mr. User", id: idString, isReady: false }],
state: {}, state: {},
isStarted: false, isStarted: false,
}; };

View File

@ -58,11 +58,13 @@ describe("Game Events", () => {
const playerOne = { const playerOne = {
id: createId(), id: createId(),
username: "Player One", username: "Player One",
isReady: false,
}; };
const playerTwo = { const playerTwo = {
id: createId(), id: createId(),
username: "Player Two", username: "Player Two",
isReady: false,
}; };
it("should throw if the kind is unkown", () => { it("should throw if the kind is unkown", () => {

View File

@ -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<Response> => {
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<GameData>);
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);
};

View File

@ -12,7 +12,6 @@ import {
import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools"; import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools";
import { import {
badRequestResponse, badRequestResponse,
createdResponse,
notFoundResponse, notFoundResponse,
singleResponse, singleResponse,
} from "$lib/server/responseBodies"; } from "$lib/server/responseBodies";

View File

@ -23,9 +23,7 @@
<div class="game-listing"> <div class="game-listing">
<div>{prettyDate(new Date(game.createdAt))}</div> <div>{prettyDate(new Date(game.createdAt))}</div>
<div> <div>
{#each games as game} <PlayerList players={game.data.players} />
<PlayerList players={game.data.players} />
{/each}
</div> </div>
<form method="GET" action={`/games/${game.id}`}> <form method="GET" action={`/games/${game.id}`}>
<input type="submit" value="JOIN" /> <input type="submit" value="JOIN" />
@ -40,20 +38,20 @@
.game-listing { .game-listing {
display: flex; display: flex;
gap: 3rem; gap: 1rem;
padding: 0.5rem; padding: 0.5rem;
} }
.game-listing > div { .game-listing > * {
flex: 1 1 auto; flex: auto;
} }
form { .game-listing > div:first-child {
display: flex; max-width: 12rem;
flex: 1 1 auto;
} }
form input { form input {
width: 100%; width: 100%;
height: 100%;
} }
</style> </style>

View File

@ -4,13 +4,14 @@ import { isServerResponse } from "$lib/ServerResponse";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types"; 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}`; const url = `/api/games/${params.gameid}`;
let res: Response; let res: Response;
let body: unknown; let body: unknown;
const token = cookies.get("access_token"); const token = cookies.get("access_token");
depends(url);
if (!token) { if (!token) {
error(401); error(401);
} }
@ -31,7 +32,10 @@ export const load: PageServerLoad = async ({ fetch, params, cookies }) => {
} }
if ("item" in body && isListing<GameData>(body.item, isGameData)) { if ("item" in body && isListing<GameData>(body.item, isGameData)) {
return { game: body.item }; return {
game: body.item,
token,
};
} else { } else {
error(res.status, "unable to fetch game data"); error(res.status, "unable to fetch game data");
} }

View File

@ -1,8 +1,30 @@
<script lang="ts"> <script lang="ts">
import { invalidate } from "$app/navigation";
import PlayerList from "$lib/components/PlayerList.svelte"; import PlayerList from "$lib/components/PlayerList.svelte";
import type { Me } from "$lib/me";
import type { PageData } from "./$types"; import type { PageData } from "./$types";
let { data: page }: { data: PageData } = $props(); let { data: page }: { data: PageData } = $props();
const handleSubmit = () => {}; function getHandler(gameId: string, me: Me) {
return async () => {
const playerIndex = page.game.data.players.findIndex(
({ id }) => id === page.me?.id,
);
const target = page.game.data.players[playerIndex];
const update = { ...target, isReady: !target.isReady };
let response = await fetch(`/api/games/${gameId}/players/${me.id}`, {
method: "PUT",
headers: [["Authorization", page.token]],
body: JSON.stringify(update),
});
if (response.status !== 200) {
throw new Error("unable to set ready");
}
invalidate(`/api/games/${gameId}`);
};
}
</script> </script>
{#if page.game.data.isStarted} {#if page.game.data.isStarted}
@ -11,12 +33,19 @@
<h1>This is some lobby</h1> <h1>This is some lobby</h1>
<div id="lobby"> <div id="lobby">
<div id="players"> <div id="players">
<PlayerList players={page.game.data.players} /> <div id="player-list">
<PlayerList players={page.game.data.players} withReadyStatus />
</div>
<input
id="ready"
type="button"
value="Set Ready"
onclick={getHandler(page.game.id, page.me!)}
/>
</div>
<div id="chat" style="background: palevioletred">
<span>Chat Feature Under Construction</span>
</div> </div>
<div id="chat" style="background: pink"></div>
</div>
<div id="controls">
<input type="button" value="Start Game" onsubmit={handleSubmit} />
</div> </div>
{/if} {/if}
@ -30,14 +59,30 @@
#players { #players {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
#player-list {
flex: auto;
} }
#chat { #chat {
flex: 2 2; flex: 2 2;
} }
#controls { #ready {
margin: 1rem 0; height: 4rem;
text-align: right; }
#chat span {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: white;
font-weight: bold;
text-transform: uppercase;
} }
</style> </style>