Add multiplayer rooms and Vercel analytics
This commit is contained in:
181
scripts/test-multiplayer-browser.mjs
Normal file
181
scripts/test-multiplayer-browser.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
if (fs.existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const rooms = [];
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
|
||||
});
|
||||
try {
|
||||
const { data: maps, error } = await db
|
||||
.from("datasets")
|
||||
.select("id")
|
||||
.eq("state", "published")
|
||||
.limit(1);
|
||||
if (error) throw error;
|
||||
assert.ok(maps.length);
|
||||
const desktop = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
locale: "en-US",
|
||||
});
|
||||
const mobile = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
locale: "en-US",
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
const host = await desktop.newPage(),
|
||||
friend = await mobile.newPage();
|
||||
const pageErrors = [];
|
||||
for (const page of [host, friend])
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await host.goto(`${origin}/servers/${maps[0].id}`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 60000,
|
||||
});
|
||||
await host.getByRole("button", { name: "Multiplayer", exact: true }).click();
|
||||
await host.getByLabel("Your player name").fill("Browser Host");
|
||||
await host.getByLabel("Player limit (including you)").selectOption("2");
|
||||
await host
|
||||
.getByRole("button", { name: "Create multiplayer room", exact: true })
|
||||
.click();
|
||||
await host.waitForURL("**/multiplayer/*", { timeout: 30000 });
|
||||
const id = host.url().split("/").pop();
|
||||
rooms.push(id);
|
||||
await expect(host.getByLabel("Invite link")).toHaveValue(
|
||||
`${origin}/multiplayer/${id}`,
|
||||
);
|
||||
await friend.goto(host.url(), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
await friend.getByLabel("Your player name").fill("Browser Friend");
|
||||
await friend.getByRole("button", { name: "Join room", exact: true }).click();
|
||||
await expect(
|
||||
friend.getByText("You’re in! Waiting for the room owner to start.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
host.getByRole("button", { name: "Start multiplayer match", exact: true }),
|
||||
).toBeEnabled({ timeout: 20000 });
|
||||
fs.mkdirSync("artifacts/multiplayer", { recursive: true });
|
||||
await host.screenshot({
|
||||
path: "artifacts/multiplayer/desktop-lobby.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await friend.screenshot({
|
||||
path: "artifacts/multiplayer/mobile-lobby.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.ok(
|
||||
await friend.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
"mobile lobby fits viewport",
|
||||
);
|
||||
await host
|
||||
.getByRole("button", { name: "Start multiplayer match", exact: true })
|
||||
.click();
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await Promise.all([
|
||||
expect(host.locator(".game-info strong").first()).toHaveText(
|
||||
`${round + 1} / 5`,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
expect(friend.locator(".game-info strong").first()).toHaveText(
|
||||
`${round + 1} / 5`,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
]);
|
||||
await Promise.all([
|
||||
expect(host.locator(".game-loading")).toHaveCount(0, { timeout: 45000 }),
|
||||
expect(friend.locator(".game-loading")).toHaveCount(0, {
|
||||
timeout: 45000,
|
||||
}),
|
||||
]);
|
||||
if (round === 0) {
|
||||
await host.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(host.locator(".game-loading")).toHaveCount(0, {
|
||||
timeout: 45000,
|
||||
});
|
||||
}
|
||||
await friend
|
||||
.getByRole("button", { name: "Open guess map", exact: true })
|
||||
.click();
|
||||
for (const [page, fraction] of [
|
||||
[host, 0.48],
|
||||
[friend, 0.7],
|
||||
]) {
|
||||
const map = page.getByRole("application", { name: /Minecraft map/ });
|
||||
const box = await map.boundingBox();
|
||||
assert.ok(box);
|
||||
await map.click({
|
||||
position: { x: box.width * fraction, y: box.height * 0.5 },
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Submit guess", exact: true }),
|
||||
).toBeEnabled();
|
||||
if (round === 0 && page === host)
|
||||
await page.screenshot({
|
||||
path: "artifacts/multiplayer/desktop-round.png",
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "Submit guess", exact: true })
|
||||
.click();
|
||||
}
|
||||
if (round < 4) {
|
||||
await expect(
|
||||
host.getByRole("heading", { name: "Round standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(
|
||||
friend.getByRole("heading", { name: "Round standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await host.getByRole("button", { name: /^Next round/ }).click();
|
||||
}
|
||||
console.log(`Browser round ${round + 1}/5 completed.`);
|
||||
}
|
||||
await expect(
|
||||
host.getByRole("heading", { name: "Final standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(
|
||||
friend.getByRole("heading", { name: "Final standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
assert.equal(await host.locator(".multiplayer-final tbody tr").count(), 2);
|
||||
await host.screenshot({
|
||||
path: "artifacts/multiplayer/desktop-results.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await friend.screenshot({
|
||||
path: "artifacts/multiplayer/mobile-results.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.ok(
|
||||
await friend.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
"mobile results fit viewport with table scrolling",
|
||||
);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(
|
||||
"Desktop/mobile browser multiplayer passed: create, invite, join, reconnect, five shared rounds, map guesses, and final standings. No page errors.",
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (rooms.length) {
|
||||
const { error } = await db
|
||||
.from("multiplayer_rooms")
|
||||
.delete()
|
||||
.in("id", rooms);
|
||||
if (error)
|
||||
throw new Error(`Browser test-room cleanup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
163
scripts/test-multiplayer-http.mjs
Normal file
163
scripts/test-multiplayer-http.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const rooms = [];
|
||||
function player() {
|
||||
let cookie;
|
||||
return async (path, body, expected = 200) => {
|
||||
const response = await fetch(`${origin}/api/v1/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { origin, "content-type": "application/json" }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (response.headers.get("set-cookie"))
|
||||
cookie = response.headers.get("set-cookie").split(";")[0];
|
||||
const data = await response.json();
|
||||
assert.equal(response.status, expected, JSON.stringify({ path, data }));
|
||||
assert.match(response.headers.get("cache-control"), /no-store/);
|
||||
return data;
|
||||
};
|
||||
}
|
||||
try {
|
||||
const host = player(),
|
||||
friend = player(),
|
||||
outsider = player();
|
||||
const catalog = await host("catalog");
|
||||
const datasetId = catalog.maps[0]?.id;
|
||||
assert.ok(datasetId, "A published map is required");
|
||||
const created = await host("rooms", {
|
||||
datasetId,
|
||||
difficulty: "hard",
|
||||
displayName: "HTTP Host",
|
||||
playerLimit: 2,
|
||||
});
|
||||
rooms.push(created.id);
|
||||
const path = `rooms/${created.id}`;
|
||||
const hostView = await host(path);
|
||||
const preview = await outsider(path);
|
||||
assert.equal(preview.you, null);
|
||||
assert.deepEqual(preview.rounds, []);
|
||||
await outsider(`${path}/media`, undefined, 403);
|
||||
const joined = await friend(`${path}/join`, { displayName: "HTTP Friend" });
|
||||
assert.equal(
|
||||
(await friend(`${path}/join`, { displayName: "HTTP Other" })).you,
|
||||
joined.you,
|
||||
);
|
||||
await outsider(`${path}/join`, { displayName: "Overflow" }, 400);
|
||||
await friend(`${path}/start`, {}, 403);
|
||||
await host(`${path}/start`, {});
|
||||
let results;
|
||||
for (let round = 0; round < 5; round++) {
|
||||
const [hostMedia, friendMedia] = await Promise.all([
|
||||
host(`${path}/media`),
|
||||
friend(`${path}/media`),
|
||||
]);
|
||||
assert.equal(hostMedia.round, round);
|
||||
assert.equal(friendMedia.round, round);
|
||||
assert.deepEqual(
|
||||
hostMedia.faces.map((url) => new URL(url).pathname),
|
||||
friendMedia.faces.map((url) => new URL(url).pathname),
|
||||
);
|
||||
assert.equal(hostMedia.faces.length, 6);
|
||||
if (round === 0)
|
||||
for (const url of [hostMedia.map, hostMedia.faces[0]])
|
||||
assert.equal((await fetch(url)).status, 200, "Signed media readable");
|
||||
await host(`${path}/ready`, { round });
|
||||
const playing = await friend(`${path}/ready`, { round });
|
||||
assert.equal(playing.status, "playing");
|
||||
const synchronized = await host(path);
|
||||
assert.equal(
|
||||
synchronized.rounds[round].deadline,
|
||||
playing.rounds[round].deadline,
|
||||
);
|
||||
const pointResult = await db
|
||||
.from("multiplayer_rounds")
|
||||
.select("viewpoint")
|
||||
.eq("room_id", created.id)
|
||||
.eq("round_number", round)
|
||||
.single();
|
||||
if (pointResult.error) throw pointResult.error;
|
||||
const point = pointResult.data.viewpoint;
|
||||
const first = await host(`${path}/guess`, {
|
||||
round,
|
||||
x: point.x,
|
||||
z: point.z,
|
||||
});
|
||||
assert.equal(first.status, "playing");
|
||||
assert.deepEqual(first.rounds[round].results, []);
|
||||
assert.equal(
|
||||
(await host(path)).players.find((p) => p.id === hostView.you).submitted,
|
||||
true,
|
||||
"Reconnect preserves submitted guess",
|
||||
);
|
||||
await host(`${path}/guess`, {
|
||||
round,
|
||||
x: hostMedia.bounds.minX,
|
||||
z: hostMedia.bounds.minZ,
|
||||
});
|
||||
const b = hostMedia.bounds;
|
||||
const x = point.x - (b.minX + b.maxX) / 2 >= 0 ? b.minX : b.maxX;
|
||||
const z = point.z - (b.minZ + b.maxZ) / 2 >= 0 ? b.minZ : b.maxZ;
|
||||
results = await friend(`${path}/guess`, { round, x, z });
|
||||
assert.equal(results.status, round === 4 ? "finished" : "results");
|
||||
assert.equal(
|
||||
results.rounds[round].results.find((r) => r.playerId === hostView.you)
|
||||
.score,
|
||||
5000,
|
||||
);
|
||||
if (round < 4) await host(`${path}/next`, { round });
|
||||
}
|
||||
assert.equal(results.players[0].id, hostView.you);
|
||||
assert.equal(results.players[0].total, 25000);
|
||||
assert.equal(results.rounds.flatMap((round) => round.results).length, 10);
|
||||
const capacityHost = player();
|
||||
const full = await capacityHost("rooms", {
|
||||
datasetId,
|
||||
difficulty: "normal",
|
||||
displayName: "Capacity Host",
|
||||
playerLimit: 16,
|
||||
});
|
||||
rooms.push(full.id);
|
||||
await Promise.all(
|
||||
Array.from({ length: 14 }, (_, i) =>
|
||||
player()(`rooms/${full.id}/join`, { displayName: `Capacity ${i + 2}` }),
|
||||
),
|
||||
);
|
||||
const attempts = await Promise.allSettled([
|
||||
player()(`rooms/${full.id}/join`, { displayName: "Last A" }),
|
||||
player()(`rooms/${full.id}/join`, { displayName: "Last B" }),
|
||||
]);
|
||||
assert.equal(
|
||||
attempts.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"Exactly one of the simultaneous last-slot joins succeeds",
|
||||
);
|
||||
const rejection = attempts.find((result) => result.status === "rejected");
|
||||
assert.match(rejection.reason.message, /This room is full/);
|
||||
assert.equal((await capacityHost(`rooms/${full.id}`)).playerCount, 16);
|
||||
console.log(
|
||||
"Multiplayer HTTP smoke passed: private invite joining, shared signed media and timers, five-round ranking, reconnects, retries, and simultaneous 16-player capacity checks.",
|
||||
);
|
||||
} finally {
|
||||
if (rooms.length) {
|
||||
const { error } = await db
|
||||
.from("multiplayer_rooms")
|
||||
.delete()
|
||||
.in("id", rooms);
|
||||
if (error)
|
||||
throw new Error(`Multiplayer test-room cleanup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
283
scripts/test-multiplayer-sql.mjs
Normal file
283
scripts/test-multiplayer-sql.mjs
Normal file
@@ -0,0 +1,283 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
|
||||
// Isolated PostgreSQL: fixtures never enter production rooms or leaderboards.
|
||||
const db = new PGlite();
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role;
|
||||
create schema auth; create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
|
||||
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||
for (const file of (await readdir("supabase/migrations"))
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
const account = (
|
||||
await db.query(
|
||||
"insert into auth.users values(gen_random_uuid()) returning id",
|
||||
)
|
||||
).rows[0].id;
|
||||
const dataset = (
|
||||
await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||
values($1,'Multiplayer fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
|
||||
[account],
|
||||
)
|
||||
).rows[0].id;
|
||||
const manifest = {
|
||||
name: "Multiplayer fixture",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `point${i}`,
|
||||
x: 10 + i,
|
||||
z: 10 + i,
|
||||
y: 64,
|
||||
})),
|
||||
};
|
||||
const revision = (
|
||||
await db.query(
|
||||
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
|
||||
[dataset, JSON.stringify(manifest)],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query("update public.datasets set revision_id=$1 where id=$2", [
|
||||
revision,
|
||||
dataset,
|
||||
]);
|
||||
const secret = (n) => String(n).padStart(64, "0");
|
||||
async function create(cap = 2) {
|
||||
return (
|
||||
await db.query(
|
||||
"select public.create_multiplayer_room($1,'hard','Host',$2,$3) as id",
|
||||
[dataset, cap, secret(1)],
|
||||
)
|
||||
).rows[0].id;
|
||||
}
|
||||
async function act(room, who, type = "view", payload = {}) {
|
||||
return (
|
||||
await db.query("select public.multiplayer_action($1,$2,$3,$4) as room", [
|
||||
room,
|
||||
secret(who),
|
||||
type,
|
||||
JSON.stringify(payload),
|
||||
])
|
||||
).rows[0].room;
|
||||
}
|
||||
for (const cap of [1, 17, null])
|
||||
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
|
||||
const room = await create();
|
||||
let view = await act(room, 1);
|
||||
const host = view.you;
|
||||
assert.equal(view.playerLimit, 2);
|
||||
assert.equal(view.players.length, 1);
|
||||
assert.equal(view.rounds.length, 5);
|
||||
assert.ok(view.rounds.every((turn) => turn.results.length === 0));
|
||||
const outsider = await act(room, 3);
|
||||
assert.equal(outsider.you, null);
|
||||
assert.deepEqual(outsider.players, []);
|
||||
assert.deepEqual(outsider.rounds, []);
|
||||
assert.ok(!JSON.stringify(view).includes('"viewpoint"'));
|
||||
assert.ok(!JSON.stringify(view).includes("access_hash"));
|
||||
await assert.rejects(act(room, 1, "start"), /Invite at least one friend/);
|
||||
await assert.rejects(act(room, 3, "start"), /membership permission/);
|
||||
const joined = await act(room, 2, "join", { name: "Friend" });
|
||||
const friend = joined.you;
|
||||
assert.equal(
|
||||
(await act(room, 2, "join", { name: "Changed name" })).you,
|
||||
friend,
|
||||
);
|
||||
await assert.rejects(act(room, 3, "join", { name: "Extra" }), /room is full/);
|
||||
await assert.rejects(act(room, 2, "start"), /owner permission/);
|
||||
view = await act(room, 1, "start");
|
||||
assert.equal(view.status, "loading");
|
||||
await assert.rejects(
|
||||
act(room, 3, "join", { name: "Late" }),
|
||||
/already started/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(room, 1, "guess", { round: 0, x: 10, z: 10 }),
|
||||
/Round is not active/,
|
||||
);
|
||||
assert.equal((await act(room, 1, "ready", { round: 0 })).status, "loading");
|
||||
view = await act(room, 2, "ready", { round: 0 });
|
||||
assert.equal(view.status, "playing");
|
||||
const deadline = view.rounds[0].deadline;
|
||||
assert.equal(
|
||||
(await act(room, 1, "ready", { round: 0 })).rounds[0].deadline,
|
||||
deadline,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(room, 1, "guess", { round: 0, x: 65, z: 0 }),
|
||||
/Guess outside map/,
|
||||
);
|
||||
const location = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
|
||||
[room],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
view = await act(room, 1, "guess", {
|
||||
round: 0,
|
||||
x: location.x,
|
||||
z: location.z,
|
||||
});
|
||||
assert.equal(view.status, "playing");
|
||||
assert.deepEqual(view.rounds[0].results, []);
|
||||
assert.ok(
|
||||
view.players.every((player) => player.total === 0),
|
||||
"scores concealed until everyone finishes",
|
||||
);
|
||||
assert.deepEqual(view.myGuess, { x: location.x, z: location.z });
|
||||
await act(room, 1, "guess", { round: 0, x: 0, z: 0 });
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select count(*)::integer as count from public.multiplayer_guesses where room_id=$1 and player_id=$2",
|
||||
[room, host],
|
||||
)
|
||||
).rows[0].count,
|
||||
1,
|
||||
);
|
||||
view = await act(room, 2, "guess", { round: 0, x: 0, z: 0 });
|
||||
assert.equal(view.status, "results");
|
||||
assert.equal(
|
||||
view.rounds[0].results.find((result) => result.playerId === host).score,
|
||||
5000,
|
||||
);
|
||||
assert.ok(
|
||||
view.rounds[0].results.find((result) => result.playerId === friend).score <
|
||||
5000,
|
||||
);
|
||||
await assert.rejects(act(room, 2, "next", { round: 0 }), /owner permission/);
|
||||
assert.equal(
|
||||
(await act(room, 1, "guess", { round: 0, x: 0, z: 0 })).status,
|
||||
"results",
|
||||
"retry cannot alter a submitted guess",
|
||||
);
|
||||
for (let round = 1; round < 5; round++) {
|
||||
await act(room, 1, "next", { round: round - 1 });
|
||||
await act(room, 1, "ready", { round });
|
||||
await act(room, 2, "ready", { round });
|
||||
const point = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=$2",
|
||||
[room, round],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
await act(room, 1, "guess", { round, x: point.x, z: point.z });
|
||||
if (round === 2) {
|
||||
await db.query(
|
||||
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=$2",
|
||||
[room, round],
|
||||
);
|
||||
view = await act(room, 2, "guess", { round, x: point.x, z: point.z });
|
||||
assert.equal(
|
||||
view.rounds[round].results.find((result) => result.playerId === friend)
|
||||
.score,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
view.rounds[round].results.find((result) => result.playerId === friend)
|
||||
.timedOut,
|
||||
true,
|
||||
);
|
||||
} else view = await act(room, 2, "guess", { round, x: 0, z: 0 });
|
||||
}
|
||||
assert.equal(view.status, "finished");
|
||||
assert.equal(view.players[0].id, host);
|
||||
assert.equal(view.players[0].total, 25000);
|
||||
assert.equal(view.players[0].rank, 1);
|
||||
assert.equal(view.players[1].rank, 2);
|
||||
assert.equal(view.rounds.flatMap((turn) => turn.results).length, 10);
|
||||
|
||||
const capacity = await create(16);
|
||||
for (let n = 2; n <= 16; n++)
|
||||
await act(capacity, n, "join", { name: `Player ${n}` });
|
||||
await assert.rejects(
|
||||
act(capacity, 17, "join", { name: "Overflow" }),
|
||||
/room is full/,
|
||||
);
|
||||
assert.equal((await act(capacity, 1)).playerCount, 16);
|
||||
const removed = (await act(capacity, 16)).you;
|
||||
await act(capacity, 1, "kick", { playerId: removed });
|
||||
assert.equal((await act(capacity, 16)).you, null);
|
||||
await act(capacity, 17, "join", { name: "Replacement" });
|
||||
await act(capacity, 1, "leave");
|
||||
assert.equal(
|
||||
(await act(capacity, 2)).hostId,
|
||||
(await act(capacity, 2)).you,
|
||||
"ownership transfers when host leaves",
|
||||
);
|
||||
await assert.rejects(
|
||||
act(capacity, 18, "join", { name: "player 2" }),
|
||||
/name is already taken/,
|
||||
);
|
||||
|
||||
const stalled = await create();
|
||||
await act(stalled, 2, "join", { name: "Offline" });
|
||||
await act(stalled, 1, "start");
|
||||
await act(stalled, 1, "ready", { round: 0 });
|
||||
await db.query(
|
||||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||||
[stalled],
|
||||
);
|
||||
assert.equal(
|
||||
(await act(stalled, 1)).status,
|
||||
"playing",
|
||||
"offline loader cannot block the match",
|
||||
);
|
||||
await db.query(
|
||||
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=0",
|
||||
[stalled],
|
||||
);
|
||||
view = await act(stalled, 1);
|
||||
assert.equal(view.status, "results");
|
||||
assert.ok(
|
||||
view.players.every((player) => player.rank === 1),
|
||||
"equal totals share a rank",
|
||||
);
|
||||
await db.query(
|
||||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||||
[stalled],
|
||||
);
|
||||
assert.equal(
|
||||
(await act(stalled, 1)).round,
|
||||
1,
|
||||
"rounds progress without host clicks",
|
||||
);
|
||||
await act(stalled, 2, "leave");
|
||||
await act(stalled, 1, "ready", { round: 1 });
|
||||
const point = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=1",
|
||||
[stalled],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
view = await act(stalled, 1, "guess", { round: 1, x: point.x, z: point.z });
|
||||
assert.equal(view.status, "results", "departed players cannot block scoring");
|
||||
await act(stalled, 1, "close");
|
||||
assert.equal((await act(stalled, 1)).status, "closed");
|
||||
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const table of [
|
||||
"multiplayer_rooms",
|
||||
"multiplayer_players",
|
||||
"multiplayer_rounds",
|
||||
"multiplayer_guesses",
|
||||
])
|
||||
await assert.rejects(
|
||||
db.query(`select * from public.${table}`),
|
||||
/permission denied/,
|
||||
);
|
||||
await assert.rejects(act(room, 1), /permission denied/);
|
||||
await assert.rejects(create(), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
console.log(
|
||||
"Multiplayer SQL checks passed: 16-player cap, owner permissions, private answers, synchronized rounds, deadlines, retries, ties, departure, and client-role isolation.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
Reference in New Issue
Block a user