setup some race condition protection on update, host client now starts game when everyone is ready
This commit is contained in:
13
src/lib/ApiGameData.ts
Normal file
13
src/lib/ApiGameData.ts
Normal 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"]);
|
||||
}
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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
9
src/lib/server/retry.ts
Normal 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;
|
||||
}
|
@ -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);
|
||||
};
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user