setup some race condition protection on update, host client now starts game when everyone is ready

This commit is contained in:
2025-06-18 14:55:10 -07:00
parent 66ff3eaaea
commit f263e7f18f
10 changed files with 180 additions and 77 deletions

13
src/lib/ApiGameData.ts Normal file
View File

@ -0,0 +1,13 @@
import { hasOnlyKeys, hasProperty } from "$lib/validation";
export interface ApiGameData {
isStarted: boolean;
}
export function isApiGameData(target: unknown): target is ApiGameData {
if (!hasProperty(target, "isStarted", "boolean")) {
return false;
}
return hasOnlyKeys(target, ["isStarted"]);
}

View File

@ -7,6 +7,7 @@ export interface Listing<T = unknown> {
deleted: boolean; deleted: boolean;
modifiedAt: string | null; modifiedAt: string | null;
data: T; data: T;
version: number;
} }
export function isListing<T>( export function isListing<T>(
@ -22,8 +23,11 @@ export function isListing<T>(
if (!hasProperty(target, "deleted", "boolean")) return false; if (!hasProperty(target, "deleted", "boolean")) return false;
if (!hasProperty(target, "data", "object")) return false; if (!hasProperty(target, "data", "object")) return false;
if (!hasProperty(target, "version", "number")) return false;
if (!hasOnlyKeys(target, ["id", "createdAt", "modifiedAt", "deleted", "data"])) if (
!hasOnlyKeys(target, ["id", "createdAt", "modifiedAt", "deleted", "data", "version"])
)
return false; return false;
return dataGuard?.((target as any)["data"]) ?? true; return dataGuard?.((target as any)["data"]) ?? true;

View File

@ -2,7 +2,10 @@
import type { GamePlayer } from "$lib/GamePlayer"; import type { GamePlayer } from "$lib/GamePlayer";
import { getMeContext } from "$lib/meContext"; import { getMeContext } from "$lib/meContext";
const { players, withReadyStatus = false } = $props(); const {
players,
withReadyStatus,
}: { players: GamePlayer[]; withReadyStatus: boolean } = $props();
const me = getMeContext(); const me = getMeContext();
function getClasses(player: GamePlayer, withReadyStatus: boolean) { function getClasses(player: GamePlayer, withReadyStatus: boolean) {

View File

@ -43,11 +43,16 @@ export async function writeNewListing(col: ServerCollections, listing: Listing)
export async function writeUpdatedListing(col: ServerCollections, listing: Listing) { export async function writeUpdatedListing(col: ServerCollections, listing: Listing) {
const client = await getClient(); const client = await getClient();
const previousVersion = listing.version;
listing.version++;
await client return await client
.db(DATABASE) .db(DATABASE)
.collection(col) .collection(col)
.replaceOne({ _id: new ObjectId(listing.id) }, fixListingForMongo(listing)); .replaceOne(
{ _id: new ObjectId(listing.id), version: previousVersion },
fixListingForMongo(listing),
);
} }
export async function listCollection<T>( export async function listCollection<T>(

9
src/lib/server/retry.ts Normal file
View File

@ -0,0 +1,9 @@
export async function retry(fn: () => Promise<[boolean, Response]>, retries = 3) {
let [retryable, response] = await fn(); // 1 try
while (retries-- && retryable) {
[retryable, response] = await fn(); // and 3 retries
}
return response;
}

View File

@ -1,4 +1,5 @@
import { isGameData, type GameData } from "$lib/GameData"; import { isApiGameData, type ApiGameData } from "$lib/ApiGameData";
import { type GameData } from "$lib/GameData";
import { isListing } from "$lib/Listing"; import { isListing } from "$lib/Listing";
import { Game } from "$lib/server/Game"; import { Game } from "$lib/server/Game";
import { updateListing } from "$lib/server/modifyListing"; import { updateListing } from "$lib/server/modifyListing";
@ -7,13 +8,15 @@ import {
ServerCollections, ServerCollections,
writeUpdatedListing, writeUpdatedListing,
} from "$lib/server/mongo"; } from "$lib/server/mongo";
import { getBody, getParam, ResourceId } from "$lib/server/requestTools"; import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools";
import { import {
badRequestResponse, badRequestResponse,
conflictResponse, conflictResponse,
forbiddenResponse,
notFoundResponse, notFoundResponse,
singleResponse, singleResponse,
} from "$lib/server/responseBodies"; } from "$lib/server/responseBodies";
import { retry } from "$lib/server/retry";
import type { RequestHandler } from "@sveltejs/kit"; import type { RequestHandler } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ params }): Promise<Response> => { export const GET: RequestHandler = async ({ params }): Promise<Response> => {
@ -32,39 +35,69 @@ export const GET: RequestHandler = async ({ params }): Promise<Response> => {
return singleResponse(game); return singleResponse(game);
}; };
export const PUT: RequestHandler = async ({ request, params }): Promise<Response> => { export const PUT: RequestHandler = async ({
locals,
request,
params,
}): Promise<Response> => {
const id = getParam(params, ResourceId.Game); const id = getParam(params, ResourceId.Game);
if (!id) { if (!id) {
return badRequestResponse("missing gameid parameter"); return badRequestResponse("missing gameid parameter");
} }
const gameListing = await readListingById( const putItem = async (): Promise<[boolean, Response]> => {
ServerCollections.Games, const gameListing = await readListingById(
id, ServerCollections.Games,
isListing<GameData>, id,
); isListing<GameData>,
);
if (!gameListing) { if (!gameListing) {
return notFoundResponse(); return [false, notFoundResponse()];
}
const game = Game.from(gameListing.data);
const body = await getBody(request, isGameData);
if (!body) {
return badRequestResponse("missing game data in body");
}
if (!game.isStarted) {
if (body.isStarted) {
game.start();
} }
} else if (game.isStarted !== body.isStarted) {
return conflictResponse();
}
await writeUpdatedListing(ServerCollections.Games, updateListing(gameListing, game)); const game = Game.from(gameListing.data);
return singleResponse(gameListing); let body: ApiGameData | null;
try {
body = await getBody(request, isApiGameData);
} catch (err) {
return [false, badRequestResponse("malformed game data in body")];
}
if (!body) {
return [false, badRequestResponse("missing game data in body")];
}
const user = getUser(locals);
const userId = user.payload.sub;
if (game.isStarted !== body.isStarted) {
if (game.isStarted) {
return [false, conflictResponse()];
}
if (game.players[0].id !== userId) {
return [false, forbiddenResponse()];
}
game.start();
} else {
return [false, singleResponse(gameListing)];
}
const res = await writeUpdatedListing(
ServerCollections.Games,
updateListing(gameListing, game),
);
if (!res.acknowledged || res.modifiedCount === 0) {
return [true, conflictResponse()];
}
return [false, singleResponse(gameListing)];
};
return await retry(putItem, 200);
}; };

View File

@ -15,6 +15,7 @@ import {
notFoundResponse, notFoundResponse,
singleResponse, singleResponse,
} from "$lib/server/responseBodies"; } from "$lib/server/responseBodies";
import { retry } from "$lib/server/retry";
import type { RequestHandler } from "@sveltejs/kit"; import type { RequestHandler } from "@sveltejs/kit";
export const PUT: RequestHandler = async ({ params, request }): Promise<Response> => { export const PUT: RequestHandler = async ({ params, request }): Promise<Response> => {
@ -35,19 +36,35 @@ export const PUT: RequestHandler = async ({ params, request }): Promise<Response
return badRequestResponse("malformed request"); return badRequestResponse("malformed request");
} }
const listing = await readListingById(ServerCollections.Games, id, isListing<GameData>); const putItem = async (): Promise<[boolean, Response]> => {
if (!listing) { const listing = await readListingById(
return notFoundResponse(); ServerCollections.Games,
} id,
isListing<GameData>,
);
if (listing.data.isStarted === true) { if (!listing) {
return conflictResponse(); return [false, notFoundResponse()];
} }
const game = Game.from(listing.data); if (listing.data.isStarted === true) {
if (game.setPlayerReady(player) === null) return notFoundResponse(); return [false, conflictResponse()];
}
await writeUpdatedListing(ServerCollections.Games, updateListing(listing, game)); const game = Game.from(listing.data);
if (game.setPlayerReady(player) === null) return [false, notFoundResponse()];
return singleResponse(game); const res = await writeUpdatedListing(
ServerCollections.Games,
updateListing(listing, game),
);
if (!res.acknowledged || res.modifiedCount === 0) {
return [true, conflictResponse()];
}
return [false, singleResponse(game)];
};
return await retry(putItem);
}; };

View File

@ -10,8 +10,6 @@ export const load: PageServerLoad = async ({ fetch, params, cookies, depends })
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);
} }

View File

@ -1,46 +1,68 @@
<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();
function getHandler(gameId: string, me: Me) { const players = $state(page.game.data.players);
return async () => { const allReady = $derived(players.every(({ isReady }) => isReady));
const playerIndex = page.game.data.players.findIndex( let isStarted = $state(page.game.data.isStarted);
({ id }) => id === page.me?.id, const meId = page.me?.id ?? "";
); const gameId = page.game.id;
const target = page.game.data.players[playerIndex]; const token = page.token;
const update = { ...target, isReady: !target.isReady }; const isHost = players.findIndex(({ id }) => id === meId) === 0;
let response = await fetch(`/api/games/${gameId}/players/${me.id}`, { const playerIndex = players.findIndex(({ id }) => id === meId);
const me = players[playerIndex];
async function handleSetReady(isReady: boolean) {
const update = { ...me, isReady };
let response = await fetch(`/api/games/${gameId}/players/${meId}`, {
method: "PUT",
headers: [["Authorization", token]],
body: JSON.stringify(update),
});
if (response.status !== 200) {
throw new Error("unable to set ready");
}
players[playerIndex] = update;
if (isHost) {
await startGameIfReady();
}
}
async function startGameIfReady() {
if (allReady) {
const res = await fetch(`/api/games/${gameId}`, {
method: "PUT", method: "PUT",
headers: [["Authorization", page.token]], headers: [["Authorization", token]],
body: JSON.stringify(update), body: JSON.stringify({ isStarted: true }),
}); });
if (response.status !== 200) { if (res.status !== 200) {
throw new Error("unable to set ready"); await handleSetReady(false);
} else {
isStarted = true;
} }
}
invalidate(`/api/games/${gameId}`);
};
} }
</script> </script>
{#if page.game.data.isStarted} {#if isStarted}
<h1>This is game {page.game.id}</h1> <h1>This is game {page.game.id}</h1>
{:else} {:else}
<h1>This is some lobby</h1> <h1>This is some lobby</h1>
<div id="lobby"> <div id="lobby">
<div id="players"> <div id="players">
<div id="player-list"> <div id="player-list">
<PlayerList players={page.game.data.players} withReadyStatus /> <PlayerList {players} withReadyStatus />
</div> </div>
<input <input
id="ready" id="ready"
type="button" type="button"
value="Set Ready" value="Set Ready"
onclick={getHandler(page.game.id, page.me!)} onclick={() => handleSetReady(!me.isReady)}
/> />
</div> </div>
<div id="chat" style="background: palevioletred"> <div id="chat" style="background: palevioletred">

View File

@ -11,28 +11,27 @@ Authorization: Bearer {{token}}
### ###
PUT https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{token}}
{
"isStarted": true
}
###
POST https://localhost:5173/api/games POST https://localhost:5173/api/games
Accept: application/json Accept: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### ###
GET https://localhost:5173/api/games/6790386c4a41c3599d47d986 GET https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b
Accept: application/json Accept: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
###
PUT https://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3
Accept: application/json
Content-Type: application/json
{
"state": {},
"isStarted": true,
"players": ["2", "45", "10"]
}
### ###
POST https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b/turns POST https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b/turns