implement ready up for players in a game lobby
This commit is contained in:
@ -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
24
src/lib/GamePlayer.ts
Normal 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"]);
|
||||
}
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
@ -2,6 +2,8 @@ import type { LocalCredentials } from "./auth";
|
||||
|
||||
export enum ResourceId {
|
||||
Game = "gameid",
|
||||
Turn = "turnid",
|
||||
Player = "playerid",
|
||||
}
|
||||
|
||||
export function getUser(locals: { user: LocalCredentials }) {
|
||||
|
@ -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 });
|
||||
}
|
||||
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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", () => {
|
||||
|
60
src/routes/api/games/[gameid]/players/[playerid]/+server.ts
Normal file
60
src/routes/api/games/[gameid]/players/[playerid]/+server.ts
Normal 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);
|
||||
};
|
@ -12,7 +12,6 @@ import {
|
||||
import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools";
|
||||
import {
|
||||
badRequestResponse,
|
||||
createdResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
} from "$lib/server/responseBodies";
|
@ -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>
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user