Add multiplayer rooms and Vercel analytics

This commit is contained in:
2026-10-07 12:28:45 +05:00
parent ce26e6fdbc
commit 48b34db6f1
20 changed files with 2345 additions and 9 deletions

View 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}`);
}
}

View 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}`);
}
}

View 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();
}