implement ready up for players in a game lobby
This commit is contained in:
@ -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
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";
|
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;
|
||||||
|
@ -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() {
|
||||||
|
@ -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 }) {
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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", () => {
|
||||||
|
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 { 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";
|
@ -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>
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
Reference in New Issue
Block a user