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 { 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;

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";
const { players } = $props();
const { players, withReadyStatus = false } = $props();
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>
<div class="list">
<ol>
{#each players as player}
{#if me !== null && player.id === me.id}
<li class="you">you</li>
{:else}
<li>{player.username}</li>
{/if}
<li class={getClasses(player, withReadyStatus)}>{player.username}</li>
{/each}
</ol>
</div>
@ -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;

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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,
};

View File

@ -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", () => {

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 {
badRequestResponse,
createdResponse,
notFoundResponse,
singleResponse,
} from "$lib/server/responseBodies";

View File

@ -23,9 +23,7 @@
<div class="game-listing">
<div>{prettyDate(new Date(game.createdAt))}</div>
<div>
{#each games as game}
<PlayerList players={game.data.players} />
{/each}
<PlayerList players={game.data.players} />
</div>
<form method="GET" action={`/games/${game.id}`}>
<input type="submit" value="JOIN" />
@ -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%;
}
</style>

View File

@ -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<GameData>(body.item, isGameData)) {
return { game: body.item };
return {
game: body.item,
token,
};
} else {
error(res.status, "unable to fetch game data");
}

View File

@ -1,8 +1,30 @@
<script lang="ts">
import { invalidate } from "$app/navigation";
import PlayerList from "$lib/components/PlayerList.svelte";
import type { Me } from "$lib/me";
import type { PageData } from "./$types";
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>
{#if page.game.data.isStarted}
@ -11,12 +33,19 @@
<h1>This is some lobby</h1>
<div id="lobby">
<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 id="chat" style="background: pink"></div>
</div>
<div id="controls">
<input type="button" value="Start Game" onsubmit={handleSubmit} />
</div>
{/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;
}
</style>