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;
modifiedAt: string | null;
data: T;
version: number;
}
export function isListing<T>(
@ -22,8 +23,11 @@ export function isListing<T>(
if (!hasProperty(target, "deleted", "boolean")) 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 dataGuard?.((target as any)["data"]) ?? true;

View File

@ -2,7 +2,10 @@
import type { GamePlayer } from "$lib/GamePlayer";
import { getMeContext } from "$lib/meContext";
const { players, withReadyStatus = false } = $props();
const {
players,
withReadyStatus,
}: { players: GamePlayer[]; withReadyStatus: boolean } = $props();
const me = getMeContext();
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) {
const client = await getClient();
const previousVersion = listing.version;
listing.version++;
await client
return await client
.db(DATABASE)
.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>(

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 { Game } from "$lib/server/Game";
import { updateListing } from "$lib/server/modifyListing";
@ -7,13 +8,15 @@ import {
ServerCollections,
writeUpdatedListing,
} from "$lib/server/mongo";
import { getBody, getParam, ResourceId } from "$lib/server/requestTools";
import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools";
import {
badRequestResponse,
conflictResponse,
forbiddenResponse,
notFoundResponse,
singleResponse,
} from "$lib/server/responseBodies";
import { retry } from "$lib/server/retry";
import type { RequestHandler } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ params }): Promise<Response> => {
@ -32,39 +35,69 @@ export const GET: RequestHandler = async ({ params }): Promise<Response> => {
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);
if (!id) {
return badRequestResponse("missing gameid parameter");
}
const gameListing = await readListingById(
ServerCollections.Games,
id,
isListing<GameData>,
);
const putItem = async (): Promise<[boolean, Response]> => {
const gameListing = await readListingById(
ServerCollections.Games,
id,
isListing<GameData>,
);
if (!gameListing) {
return 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();
if (!gameListing) {
return [false, notFoundResponse()];
}
} 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,
singleResponse,
} from "$lib/server/responseBodies";
import { retry } from "$lib/server/retry";
import type { RequestHandler } from "@sveltejs/kit";
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");
}
const listing = await readListingById(ServerCollections.Games, id, isListing<GameData>);
if (!listing) {
return notFoundResponse();
}
const putItem = async (): Promise<[boolean, Response]> => {
const listing = await readListingById(
ServerCollections.Games,
id,
isListing<GameData>,
);
if (listing.data.isStarted === true) {
return conflictResponse();
}
if (!listing) {
return [false, notFoundResponse()];
}
const game = Game.from(listing.data);
if (game.setPlayerReady(player) === null) return notFoundResponse();
if (listing.data.isStarted === true) {
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;
const token = cookies.get("access_token");
depends(url);
if (!token) {
error(401);
}

View File

@ -1,46 +1,68 @@
<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();
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}`, {
const players = $state(page.game.data.players);
const allReady = $derived(players.every(({ isReady }) => isReady));
let isStarted = $state(page.game.data.isStarted);
const meId = page.me?.id ?? "";
const gameId = page.game.id;
const token = page.token;
const isHost = players.findIndex(({ id }) => id === meId) === 0;
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",
headers: [["Authorization", page.token]],
body: JSON.stringify(update),
headers: [["Authorization", token]],
body: JSON.stringify({ isStarted: true }),
});
if (response.status !== 200) {
throw new Error("unable to set ready");
if (res.status !== 200) {
await handleSetReady(false);
} else {
isStarted = true;
}
invalidate(`/api/games/${gameId}`);
};
}
}
</script>
{#if page.game.data.isStarted}
{#if isStarted}
<h1>This is game {page.game.id}</h1>
{:else}
<h1>This is some lobby</h1>
<div id="lobby">
<div id="players">
<div id="player-list">
<PlayerList players={page.game.data.players} withReadyStatus />
<PlayerList {players} withReadyStatus />
</div>
<input
id="ready"
type="button"
value="Set Ready"
onclick={getHandler(page.game.id, page.me!)}
onclick={() => handleSetReady(!me.isReady)}
/>
</div>
<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
Accept: application/json
Authorization: Bearer {{token}}
###
GET https://localhost:5173/api/games/6790386c4a41c3599d47d986
GET https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b
Accept: application/json
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