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;
|
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;
|
||||||
|
@ -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) {
|
||||||
|
@ -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
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 { 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);
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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
|
||||||
|
Reference in New Issue
Block a user