diff --git a/src/lib/ApiGameData.ts b/src/lib/ApiGameData.ts new file mode 100644 index 0000000..c5ef820 --- /dev/null +++ b/src/lib/ApiGameData.ts @@ -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"]); +} diff --git a/src/lib/Listing.ts b/src/lib/Listing.ts index 454f819..66e29a5 100644 --- a/src/lib/Listing.ts +++ b/src/lib/Listing.ts @@ -7,6 +7,7 @@ export interface Listing { deleted: boolean; modifiedAt: string | null; data: T; + version: number; } export function isListing( @@ -22,8 +23,11 @@ export function isListing( 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; diff --git a/src/lib/components/PlayerList.svelte b/src/lib/components/PlayerList.svelte index cec7f59..d3f9982 100644 --- a/src/lib/components/PlayerList.svelte +++ b/src/lib/components/PlayerList.svelte @@ -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) { diff --git a/src/lib/server/mongo.ts b/src/lib/server/mongo.ts index 460c5d9..f389846 100644 --- a/src/lib/server/mongo.ts +++ b/src/lib/server/mongo.ts @@ -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( diff --git a/src/lib/server/retry.ts b/src/lib/server/retry.ts new file mode 100644 index 0000000..a14afb9 --- /dev/null +++ b/src/lib/server/retry.ts @@ -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; +} diff --git a/src/routes/api/games/[gameid]/+server.ts b/src/routes/api/games/[gameid]/+server.ts index 9ec955c..820011b 100644 --- a/src/routes/api/games/[gameid]/+server.ts +++ b/src/routes/api/games/[gameid]/+server.ts @@ -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 => { @@ -32,39 +35,69 @@ export const GET: RequestHandler = async ({ params }): Promise => { return singleResponse(game); }; -export const PUT: RequestHandler = async ({ request, params }): Promise => { +export const PUT: RequestHandler = async ({ + locals, + request, + params, +}): Promise => { const id = getParam(params, ResourceId.Game); if (!id) { return badRequestResponse("missing gameid parameter"); } - const gameListing = await readListingById( - ServerCollections.Games, - id, - isListing, - ); + const putItem = async (): Promise<[boolean, Response]> => { + const gameListing = await readListingById( + ServerCollections.Games, + id, + isListing, + ); - 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); }; diff --git a/src/routes/api/games/[gameid]/players/[playerid]/+server.ts b/src/routes/api/games/[gameid]/players/[playerid]/+server.ts index 6866576..9deca29 100644 --- a/src/routes/api/games/[gameid]/players/[playerid]/+server.ts +++ b/src/routes/api/games/[gameid]/players/[playerid]/+server.ts @@ -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 => { @@ -35,19 +36,35 @@ export const PUT: RequestHandler = async ({ params, request }): Promise); - if (!listing) { - return notFoundResponse(); - } + const putItem = async (): Promise<[boolean, Response]> => { + const listing = await readListingById( + ServerCollections.Games, + id, + isListing, + ); - 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); }; diff --git a/src/routes/games/[gameid]/+page.server.ts b/src/routes/games/[gameid]/+page.server.ts index 061ee73..f0430c3 100644 --- a/src/routes/games/[gameid]/+page.server.ts +++ b/src/routes/games/[gameid]/+page.server.ts @@ -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); } diff --git a/src/routes/games/[gameid]/+page.svelte b/src/routes/games/[gameid]/+page.svelte index b068c19..74d7b04 100644 --- a/src/routes/games/[gameid]/+page.svelte +++ b/src/routes/games/[gameid]/+page.svelte @@ -1,46 +1,68 @@ -{#if page.game.data.isStarted} +{#if isStarted}

This is game {page.game.id}

{:else}

This is some lobby

- +
handleSetReady(!me.isReady)} />
diff --git a/src/tests/requests.http b/src/tests/requests.http index 0c27ebb..e84f63a 100644 --- a/src/tests/requests.http +++ b/src/tests/requests.http @@ -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