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

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
import "./globals.css";
import "./block-theme.css";
import "./account.css";
@@ -13,6 +14,7 @@ import { I18nProvider } from "@/components/I18nProvider";
import { getI18n } from "@/lib/i18n";
import "./polish.css";
import "./mobile-game.css";
import "./multiplayer.css";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase";
@@ -50,6 +52,7 @@ export default async function RootLayout({
<LegacyPasswordNotice enabled={!!account?.email} />
</SiteSettingsProvider>
</I18nProvider>
<Analytics />
</body>
</html>
);

217
app/multiplayer.css Normal file
View File

@@ -0,0 +1,217 @@
.multiplayer-settings,
.multiplayer-join {
display: grid;
gap: 12px;
margin: 18px 0;
}
.multiplayer-settings label,
.multiplayer-join label,
.multiplayer-lobby > label {
font-weight: 650;
}
.multiplayer-settings input,
.multiplayer-settings select,
.multiplayer-join input,
.multiplayer-invite input {
width: 100%;
min-height: 44px;
border: 1px solid var(--line, #35404c);
border-radius: 10px;
padding: 10px 12px;
color: inherit;
background: var(--panel, #1a222c);
font: inherit;
}
.multiplayer-settings select {
max-width: 160px;
}
.multiplayer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 5%;
gap: 20px;
border-bottom: 1px solid var(--line, #35404c);
}
.multiplayer-lobby {
max-width: 1000px;
min-height: 70vh;
}
.multiplayer-lobby .eyebrow,
.multiplayer-lobby .lead {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.multiplayer-join {
max-width: 480px;
}
.multiplayer-invite {
display: flex;
gap: 10px;
margin: 12px 0 28px;
}
.multiplayer-invite input {
min-width: 0;
}
.multiplayer-invite .button {
white-space: nowrap;
flex-shrink: 0;
}
.multiplayer-roster {
list-style: none;
padding: 0;
margin: 16px 0 24px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.multiplayer-roster li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border: 1px solid var(--line, #35404c);
border-radius: 12px;
gap: 12px;
overflow-wrap: anywhere;
}
.multiplayer-roster li > span {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.multiplayer-table-wrap {
overflow-x: auto;
margin: 28px 0;
}
.multiplayer-final {
min-width: 650px;
width: 100%;
}
.multiplayer-final caption {
text-align: left;
font-weight: 650;
margin-bottom: 16px;
}
.multiplayer-final .multiplayer-you {
background: #8dce6614;
}
.multiplayer-scoreboard {
position: absolute;
z-index: 20;
left: 80px;
top: 100px;
width: 245px;
padding: 12px 16px;
border-radius: 12px;
background: rgba(26, 34, 44, 0.96);
color: var(--text);
box-shadow: 0 6px 24px #0002;
max-height: calc(100dvh - 190px);
overflow: auto;
}
.multiplayer-scoreboard summary {
cursor: pointer;
font-weight: 650;
}
.multiplayer-scoreboard summary svg {
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.multiplayer-scoreboard ul {
list-style: none;
margin: 12px 0;
padding: 0;
}
.multiplayer-scoreboard li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
.multiplayer-scoreboard li > span {
flex: 1;
min-width: 0;
}
.multiplayer-scoreboard li small {
display: block;
font-size: 11px;
opacity: 0.7;
}
.multiplayer-network,
.multiplayer-waiting {
position: fixed;
z-index: 50;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: calc(100% - 32px);
border-radius: 10px;
background: var(--panel);
color: var(--text);
box-shadow: 0 6px 24px #0003;
padding: 12px 18px;
text-align: center;
}
.multiplayer-network {
z-index: 100;
top: 80px;
bottom: auto;
border: 1px solid #aa5040;
}
.multiplayer-round-result {
max-height: calc(100dvh - 100px);
overflow-y: auto;
}
.multiplayer-round-scores {
padding: 0;
margin: 12px 0 20px;
list-style: none;
text-align: left;
}
.multiplayer-round-scores li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 7px 0;
border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.multiplayer-header {
padding: 18px 5%;
}
.multiplayer-roster {
grid-template-columns: 1fr;
}
.multiplayer-invite {
flex-wrap: wrap;
}
.multiplayer-scoreboard {
top: 130px;
left: 64px;
width: min(220px, calc(100% - 80px));
}
.multiplayer-waiting {
bottom: 96px;
}
.multiplayer-game .game-header > .report-content {
display: none;
}
.multiplayer-round-result {
max-height: calc(100dvh - 48px);
}
}
.start-panel .tabs button {
display: inline-flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,13 @@
import { MultiplayerRoom } from "@/components/MultiplayerRoom";
import { notFound } from "next/navigation";
import { z } from "zod";
export default async function MultiplayerPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!z.string().uuid().safeParse(id).success) notFound();
return <MultiplayerRoom id={id} />;
}

View File

@@ -0,0 +1,314 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Maximize2,
Minimize2,
MapPin,
Map as MapIcon,
Users,
} from "lucide-react";
import { Brand } from "./Brand";
import { Panorama } from "./Panorama";
import { GuessMap, type Pin } from "./GuessMap";
import { ReportContent } from "./ReportContent";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { difficulties } from "@/shared/protocol";
import type { RoomView, RoomMedia } from "@/shared/multiplayer";
export function MultiplayerGame({
id,
room,
busy,
action,
remaining,
onLeave,
}: {
id: string;
room: RoomView;
busy: boolean;
action: (type: string, payload?: object) => Promise<boolean>;
remaining: (deadline: string) => number;
onLeave: () => void;
}) {
const { t, formatNumber } = useI18n();
const [media, setMedia] = useState<RoomMedia | null>(null);
const [error, setError] = useState("");
const [pin, setPin] = useState<Pin | null>(null);
const [expanded, setExpanded] = useState(false);
const [loaded, setLoaded] = useState({ panorama: false, map: false });
const [seconds, setSeconds] = useState(0);
const [nextIn, setNextIn] = useState(0);
const sendingReady = useRef(false);
const me = room.players.find((player) => player.id === room.you);
const turn = room.rounds[room.round];
const result = turn.results.find((result) => result.playerId === room.you);
const owner = room.hostId === room.you;
const submitted = me?.submitted ?? false;
const deadline = turn.deadline;
useEffect(() => {
let disposed = false;
api<RoomMedia>(`rooms/${id}/media`)
.then((next) => {
if (!disposed && next.round === room.round) setMedia(next);
})
.catch((failure) => {
if (!disposed) setError(failure.message);
});
return () => {
disposed = true;
};
}, [id, room.round]);
useEffect(() => {
if (
room.status !== "loading" ||
!loaded.panorama ||
!loaded.map ||
me?.ready ||
sendingReady.current ||
busy ||
error
)
return;
sendingReady.current = true;
void action("ready", { round: room.round }).finally(() => {
sendingReady.current = false;
});
}, [room.status, room.round, me?.ready, loaded, busy, error, action]);
useEffect(() => {
const tick = () => {
setSeconds(deadline ? remaining(deadline) : 0);
setNextIn(room.transitionAt ? remaining(room.transitionAt) : 0);
};
tick();
const timer = setInterval(tick, 250);
return () => clearInterval(timer);
}, [deadline, room.transitionAt, remaining]);
const panoramaReady = useCallback(
() => setLoaded((previous) => ({ ...previous, panorama: true })),
[],
);
const mapReady = useCallback(
() => setLoaded((previous) => ({ ...previous, map: true })),
[],
);
const mediaError = useCallback((message: string) => setError(message), []);
const roundScores = [...turn.results].sort((a, b) => b.score - a.score);
return (
<main className="game-page multiplayer-game">
{media && (
<Panorama
urls={media.faces}
zoom={difficulties[room.difficulty].zoom}
onReady={panoramaReady}
onError={mediaError}
/>
)}
<div className="game-header">
<Brand />
<ReportContent datasetId={room.datasetId} />
<div className="game-info">
<div>
<small>{t("ROUND")}</small>
<strong>{room.round + 1} / 5</strong>
</div>
<div>
<small>{t("TIME LEFT")}</small>
<strong
className={
room.status === "playing" && seconds <= 10 ? "urgent" : ""
}
>
{room.status === "loading"
? t("Loading…")
: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`}
</strong>
</div>
<div>
<small>{t("POINTS")}</small>
<strong>{formatNumber(me?.total ?? 0)}</strong>
</div>
</div>
</div>
<details className="multiplayer-scoreboard">
<summary>
<Users size={16} /> {t("Players")} · {room.playerCount}
</summary>
<ul>
{room.players.map((player) => (
<li key={player.id}>
<span>
{player.name}
{player.id === room.you && <small> {t("(you)")}</small>}
<small>
{player.left
? t("Left room")
: room.status === "loading"
? player.ready
? t("Ready")
: t("Loading…")
: player.submitted
? t("Guess submitted")
: t("Guessing")}
</small>
</span>
<strong>{formatNumber(player.total)}</strong>
</li>
))}
</ul>
<button className="text-link" onClick={onLeave} disabled={busy}>
{t("Leave room")}
</button>
</details>
{media && (
<div className={"game-map " + (expanded ? "expanded" : "")}>
<button
className="mobile-map-toggle"
aria-expanded={expanded}
aria-controls="multiplayer-guess-map"
onClick={() => setExpanded(true)}
>
<MapIcon size={20} aria-hidden="true" />
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
</button>
<div className="game-map-content" id="multiplayer-guess-map">
<div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</div>
<GuessMap
bounds={media.bounds}
image={media.map}
pin={result ? result.guess : (room.myGuess ?? pin)}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
onError={mediaError}
disabled={
room.status !== "playing" || submitted || busy || seconds === 0
}
/>
<div className="map-actions">
<small>
{submitted
? t("Guess locked. Waiting for the other players.")
: pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Click the map to drop a pin")}
</small>
<button
className="button primary full"
disabled={
!pin ||
busy ||
submitted ||
room.status !== "playing" ||
seconds === 0
}
onClick={() =>
pin && void action("guess", { round: room.round, ...pin })
}
>
<MapPin size={15} />{" "}
{submitted ? t("Guess submitted") : t("Submit guess")}
</button>
</div>
</div>
</div>
)}
{room.status === "playing" && submitted && (
<div className="multiplayer-waiting" role="status">
{t("Guess locked. Waiting for the other players.")}
</div>
)}
{room.status === "playing" && seconds === 0 && !submitted && (
<div className="multiplayer-waiting" role="status">
{t("Time is up. Calculating round scores…")}
</div>
)}
{(!media ||
!loaded.panorama ||
!loaded.map ||
room.status === "loading") &&
!error &&
room.status !== "results" && (
<div className="game-loading">
<div className="loading-cube" />
<h2>{t("Preparing your expedition.")}</h2>
<p>
{loaded.panorama && loaded.map
? t("Waiting for players to load the shared round…")
: t("Loading panorama and surface map…")}
</p>
{room.status === "loading" && (
<small>
{t("Starting within {seconds}s", { seconds: nextIn })}
</small>
)}
</div>
)}
{error && (
<div className="round-result" role="alert">
<h2>{t("Connection interrupted.")}</h2>
<p>{t(error)}</p>
<button
className="button primary"
onClick={() => window.location.reload()}
>
{t("Reconnect")}
</button>
</div>
)}
{room.status === "results" && !error && (
<div className="round-result multiplayer-round-result">
<span className="eyebrow">
{t("ROUND")} {room.round + 1} {t("COMPLETE")}
</span>
<h2>{t("Round standings")}</h2>
<div className="score">
{formatNumber(result?.score ?? 0)}
<small> / 5,000</small>
</div>
<p>
{result?.timedOut
? t("The clock ran out. This round scores zero.")
: t("{count} blocks from the actual location.", {
count: formatNumber(Math.round(result?.distance ?? 0)),
})}
</p>
<ol className="multiplayer-round-scores">
{roundScores.map((score) => (
<li key={score.playerId}>
<span>
{
room.players.find((player) => player.id === score.playerId)
?.name
}
</span>
<strong>{formatNumber(score.score)}</strong>
</li>
))}
</ol>
{owner && (
<button
className="button primary full"
disabled={busy}
onClick={() => void action("next", { round: room.round })}
>
{t("Next round")} →
</button>
)}
<p className="muted">
{t("Next round in {seconds}s", { seconds: nextIn })}
</p>
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,367 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Copy, Users, Crown, Trophy } from "lucide-react";
import { Brand } from "./Brand";
import { MultiplayerGame } from "./MultiplayerGame";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { roundClock } from "@/shared/round-clock";
import type { RoomView } from "@/shared/multiplayer";
export function MultiplayerRoom({ id }: { id: string }) {
const { t, formatNumber } = useI18n();
const router = useRouter();
const [room, setRoom] = useState<RoomView | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [invite, setInvite] = useState("");
const [copied, setCopied] = useState(false);
const clock = useRef<ReturnType<typeof roundClock> | null>(null);
const version = useRef(0);
const acting = useRef(false);
const latest = useRef<RoomView | null>(null);
const accept = useCallback((next: RoomView, sentAt: number) => {
clock.current = roundClock(next.serverNow, sentAt, performance.now());
latest.current = next;
setRoom(next);
setError("");
}, []);
const load = useCallback(async () => {
if (acting.current) return;
const current = ++version.current;
const sentAt = performance.now();
try {
const next = await api<RoomView>(`rooms/${id}`);
if (current === version.current) accept(next, sentAt);
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
}
}, [id, accept]);
useEffect(() => {
setInvite(`${window.location.origin}/multiplayer/${id}`);
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
await load();
if (
!stopped &&
!["finished", "closed"].includes(latest.current?.status ?? "")
)
timer = setTimeout(poll, document.hidden ? 5000 : 2000);
};
void poll();
const resume = () => {
if (!document.hidden) void load();
};
document.addEventListener("visibilitychange", resume);
return () => {
stopped = true;
version.current++;
clearTimeout(timer);
document.removeEventListener("visibilitychange", resume);
};
}, [id, load]);
const action = useCallback(
async (type: string, payload: object = {}) => {
if (acting.current) return false;
acting.current = true;
setBusy(true);
setError("");
const current = ++version.current;
const sentAt = performance.now();
try {
const next = await api<RoomView>(`rooms/${id}/${type}`, payload);
if (current === version.current) accept(next, sentAt);
return true;
} catch (failure) {
if (current === version.current) setError((failure as Error).message);
return false;
} finally {
acting.current = false;
setBusy(false);
}
},
[id, accept],
);
const remaining = useCallback(
(deadline: string) =>
clock.current?.remaining(deadline, performance.now()) ?? 0,
[],
);
async function leave() {
if (await action("leave")) router.push("/servers");
}
async function copyInvite() {
try {
await navigator.clipboard.writeText(invite);
setCopied(true);
} catch {
setError("Copy the invite link from the field below");
}
}
const activeGame =
!!room?.you && ["loading", "playing", "results"].includes(room.status);
if (activeGame && room)
return (
<>
<MultiplayerGame
key={room.round}
id={id}
room={room}
busy={busy}
action={action}
remaining={remaining}
onLeave={leave}
/>
{error && (
<div className="multiplayer-network" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</div>
)}
</>
);
const owner = !!room?.you && room.you === room.hostId;
const finished = room?.status === "finished";
const winners = room?.players.filter((player) => player.rank === 1) ?? [];
return (
<>
<header className="multiplayer-header">
<Brand />
<Link href="/servers" className="text-link">
{t("Play & maps")}
</Link>
</header>
<main className="section page-content multiplayer-lobby">
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
{!room ? (
<p className="muted">{t("Loading multiplayer room…")}</p>
) : (
<>
<span className="eyebrow">
<Users size={16} /> {t("Multiplayer")} · {room.name}
</span>
<h1>
{finished
? t("Final standings")
: room.status === "closed"
? t("This room is closed")
: t("An expedition with friends.")}
</h1>
{!room.you && room.status === "lobby" && (
<form
className="multiplayer-join"
onSubmit={(event) => {
event.preventDefault();
void action("join", { displayName: name });
}}
>
<p className="lead">
{t("Join your friends for five rounds on the same map.")}
</p>
<p>
{t("{count} / {limit} players", {
count: room.playerCount,
limit: room.playerLimit,
})}
</p>
<label htmlFor="join-player-name">
{t("Your player name")}
</label>
<input
id="join-player-name"
value={name}
onChange={(event) => setName(event.target.value)}
minLength={2}
maxLength={32}
required
autoComplete="nickname"
placeholder={t("Enter a player name")}
disabled={busy}
/>
<button
className="button primary"
disabled={busy || room.playerCount >= room.playerLimit}
>
{room.playerCount >= room.playerLimit
? t("This room is full")
: t("Join room")}
</button>
</form>
)}
{!room.you && !["lobby", "closed"].includes(room.status) && (
<p className="lead">{t("This match has already started")}</p>
)}
{room.you && room.status === "lobby" && (
<>
<p className="lead">
{owner
? t(
"Share the invite link. Start when your friends arrive.",
)
: t("You’re in! Waiting for the room owner to start.")}
</p>
<label htmlFor="room-invite">{t("Invite link")}</label>
<div className="multiplayer-invite">
<input
id="room-invite"
value={invite}
readOnly
onFocus={(event) => event.target.select()}
/>
<button className="button" onClick={copyInvite}>
<Copy size={16} /> {copied ? t("Copied!") : t("Copy link")}
</button>
</div>
<h2>
{t("{count} / {limit} players", {
count: room.playerCount,
limit: room.playerLimit,
})}
</h2>
<ul className="multiplayer-roster">
{room.players.map((player) => (
<li key={player.id}>
<span>
{player.id === room.hostId && (
<Crown size={17} aria-label={t("Room owner")} />
)}{" "}
{player.name}{" "}
{player.id === room.you && <small>{t("(you)")}</small>}
</span>
{owner && player.id !== room.you && (
<button
className="text-link"
disabled={busy}
onClick={() =>
void action("kick", { playerId: player.id })
}
aria-label={t("Remove {name}", { name: player.name })}
>
{t("Remove")}
</button>
)}
</li>
))}
</ul>
<p className="muted">
{t(
"Everyone plays the same five locations. The most points wins; equal totals share a place.",
)}
</p>
<div className="inline-actions">
{owner && (
<button
className="button primary"
disabled={busy || room.playerCount < 2}
onClick={() => void action("start")}
>
{t("Start multiplayer match")}
</button>
)}
<button className="button" disabled={busy} onClick={leave}>
{t("Leave room")}
</button>
{owner && (
<button
className="text-link"
disabled={busy}
onClick={() => void action("close")}
>
{t("Close room")}
</button>
)}
</div>
{owner && room.playerCount < 2 && (
<p className="muted">
{t("Invite at least one friend before starting")}
</p>
)}
</>
)}
{finished && room.you && (
<>
<p className="lead">
<Trophy size={24} aria-hidden="true" />{" "}
{winners.map((player) => player.name).join(" & ")} ·{" "}
{t("{count} points", {
count: formatNumber(winners[0]?.total ?? 0),
})}
</p>
<div className="multiplayer-table-wrap">
<table className="history-table multiplayer-final">
<caption>{t("Five-round totals")}</caption>
<thead>
<tr>
<th>{t("Place")}</th>
<th>{t("Player")}</th>
{room.rounds.map((turn) => (
<th key={turn.number}>
{t("Round")} {turn.number + 1}
</th>
))}
<th>{t("Total points")}</th>
</tr>
</thead>
<tbody>
{room.players.map((player) => (
<tr
key={player.id}
className={
player.id === room.you ? "multiplayer-you" : ""
}
>
<td>{player.rank}</td>
<td>
{player.name}
{player.left && <small> · {t("Left room")}</small>}
</td>
{room.rounds.map((turn) => (
<td key={turn.number}>
{formatNumber(
turn.results.find(
(result) => result.playerId === player.id,
)?.score ?? 0,
)}
</td>
))}
<td>
<strong>{formatNumber(player.total)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Link
className="button primary"
href={`/servers/${room.datasetId}`}
>
{t("Play this map again")}
</Link>
</>
)}
{room.status === "closed" && (
<Link className="button primary" href="/servers">
{t("Another adventure →")}
</Link>
)}
</>
)}
</main>
</>
);
}

View File

@@ -3,13 +3,16 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) {
const { t } = useI18n();
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[displayName, setDisplayName] = useState(""),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false),
[error, setError] = useState("");
const router = useRouter();
@@ -17,11 +20,15 @@ export function StartGame({ datasetId }: { datasetId: string }) {
setLoading(true);
setError("");
try {
const session = await api<{ id: string }>("sessions", {
datasetId,
difficulty,
});
router.push("/play/" + session.id);
const session = await api<{ id: string }>(
mode === "solo" ? "sessions" : "rooms",
{
datasetId,
difficulty,
...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
},
);
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
} catch (e) {
setError((e as Error).message);
setLoading(false);
@@ -31,6 +38,24 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<div className="start-panel">
<span className="eyebrow">{t("YOUR EXPEDITION")}</span>
<h2>{t("Choose your challenge.")}</h2>
<div className="tabs" aria-label={t("Game mode")}>
<button
aria-pressed={mode === "solo"}
className={mode === "solo" ? "active" : ""}
disabled={loading}
onClick={() => setMode("solo")}
>
{t("Solo")}
</button>
<button
aria-pressed={mode === "multiplayer"}
className={mode === "multiplayer" ? "active" : ""}
disabled={loading}
onClick={() => setMode("multiplayer")}
>
<Users size={15} /> {t("Multiplayer")}
</button>
</div>
<div className="difficulty-options">
{Object.entries(difficulties).map(([key, value]) => (
<button
@@ -55,12 +80,53 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<p className="muted">
{t("5 rounds · 25,000 possible points · No movement")}
</p>
{mode === "multiplayer" && (
<div className="multiplayer-settings">
<label htmlFor="room-display-name">{t("Your player name")}</label>
<input
id="room-display-name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
minLength={2}
maxLength={32}
placeholder={t("Enter a player name")}
autoComplete="nickname"
disabled={loading}
/>
<label htmlFor="room-player-limit">
{t("Player limit (including you)")}
</label>
<select
id="room-player-limit"
value={playerLimit}
onChange={(event) => setPlayerLimit(Number(event.target.value))}
disabled={loading}
>
{Array.from({ length: 15 }, (_, i) => i + 2).map((count) => (
<option key={count} value={count}>
{count}
</option>
))}
</select>
<p className="muted">
{t(
"Create a room, share the invite link, and start when your friends arrive.",
)}
</p>
</div>
)}
<button
className="button primary full"
onClick={start}
disabled={loading}
disabled={
loading || (mode === "multiplayer" && displayName.trim().length < 2)
}
>
{loading ? t("Preparing your world…") : t("Start exploring")}
{loading
? t("Preparing your world…")
: mode === "multiplayer"
? t("Create multiplayer room")
: t("Start exploring")}
<ArrowRight size={17} />
</button>
{error && (

45
docs/multiplayer.md Normal file
View File

@@ -0,0 +1,45 @@
# Multiplayer rooms
Open a published map, select **Multiplayer**, enter your player name, and choose
a limit from 2 to 16 players, including yourself. Create the room and copy its
invite link. Friends open that link, enter a name, and join. The owner starts
the match once at least two players are present and can remove players from
the lobby.
Everyone plays the same five randomly selected locations with the selected
difficulty. A round starts when active players have loaded its images, with a
30-second loading grace period. The timer and scoring run in PostgreSQL;
refreshing the page restores the same player, guess, and deadline. Guesses and
scores are revealed when everyone submits or the timer expires. Missing guesses
score zero. The owner can advance from the round standings, or the next round
starts automatically after 20 seconds. The final table ranks total points;
equal totals share a place.
Players join without an account. A separate HttpOnly cookie identifies each
browser, and the room link acts as the invitation. Only the server can access
the room tables and functions. Clients receive the current round's signed
images and completed results, never future locations or other players' guesses
before a round closes. Leaving the lobby frees a place; leaving during a match
retains the player's results and scores their remaining rounds as zero. If the
owner leaves, ownership transfers to another remaining player.
Room state refreshes every two seconds, or every five seconds in a hidden tab.
Unfinished rooms expire after 24 hours. Multiplayer results are separate from
the solo leaderboard.
## Database and verification
Apply `supabase/migrations/20261007071259_multiplayer_rooms.sql` to a new database
before deploying the web update. It has already been applied to MineGeo's
configured Supabase project. Deployments continue through `npm run deploy`
without a Git-provider connection.
- `npm test`: validation and existing regression checks.
- `npm run test:multiplayer`: isolated PostgreSQL state, permission, and scoring
checks, using the existing `.data/sql-verification` PGlite installation.
- `node scripts/test-multiplayer-http.mjs <site-origin>`: real API and Storage
verification, including five-round rankings and concurrent last-slot joins.
Requires local Supabase configuration; creates and removes its own test rooms.
- `node scripts/test-multiplayer-browser.mjs <site-origin>`: desktop and mobile
invite, reconnect, map interaction, and five-round standings checks. Requires
Playwright Chromium; removes its own test room and saves screenshots locally.

View File

@@ -21,5 +21,17 @@ Useful options:
- `npm run deploy -- --preview`: create a preview deployment.
- `npm run deploy -- --help`: show usage.
Vercel Web Analytics is included in the root layout through
`@vercel/analytics/next`, so page views and client-side navigation are tracked
across the site. Enable Web Analytics in the MineGeo project's Analytics tab
before deploying. After deployment, visit the site and navigate between pages
to verify collection. Content blockers can prevent analytics requests.
Analytics uses the existing Vercel project and requires no Git connection or
additional Supabase credentials.
Multiplayer uses the Supabase migration described in [multiplayer.md](multiplayer.md).
Apply that migration to a new database before deploying the multiplayer web files.
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.

View File

@@ -15,6 +15,7 @@ import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api";
import { multiplayerApi } from "./multiplayer-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings";
@@ -109,6 +110,8 @@ export async function handle(request: NextRequest, path: string[]) {
verifyOrigin(request);
if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit });
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
const client = admin(),
method = request.method;
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {

137
lib/multiplayer-api.ts Normal file
View File

@@ -0,0 +1,137 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { admin, required } from "./supabase";
import { mediaUrls } from "./catalog";
import { requestOrigin } from "./request-origin";
import { ServiceError } from "./service-error";
import {
createRoomSchema,
joinRoomSchema,
roomRoundSchema,
roomGuessSchema,
type RoomView,
} from "../shared/multiplayer";
import {
datasetSchema,
faces,
panoramaPath,
viewpointSchema,
} from "../shared/protocol";
type Dependencies = {
jsonBody: (request: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
export async function multiplayerApi(
request: NextRequest,
path: string[],
{ jsonBody, limit }: Dependencies,
) {
const client = admin();
const savedSecret = request.cookies.get("mg_multiplayer")?.value;
const secret = savedSecret || randomBytes(32).toString("hex");
const accessHash = hash(secret);
const respond = (body: unknown) => {
const response = NextResponse.json(body);
if (!savedSecret && request.method === "POST")
response.cookies.set("mg_multiplayer", secret, {
httpOnly: true,
sameSite: "lax",
secure: requestOrigin(request, process.env.MINEGEO_URL).startsWith(
"https:",
),
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return response;
};
if (request.method === "POST" && path.length === 1) {
const body = createRoomSchema.parse(await jsonBody(request));
await limit("multiplayer:create:" + accessHash, 20, 3600);
const id = required(
await client.rpc("create_multiplayer_room", {
p_dataset: body.datasetId,
p_difficulty: body.difficulty,
p_name: body.displayName,
p_limit: body.playerLimit,
p_access_hash: accessHash,
}),
);
return respond({ id });
}
const id = z.string().uuid().parse(path[1]);
const action = path[2] ?? "view";
let payload: Record<string, string | number> = {};
if (request.method === "POST" && path.length === 3) {
const input = await jsonBody(request);
if (action === "join") {
payload = { name: joinRoomSchema.parse(input).displayName };
await limit("multiplayer:join:" + accessHash, 40, 3600);
} else if (action === "guess") payload = roomGuessSchema.parse(input);
else if (action === "ready" || action === "next")
payload = roomRoundSchema.parse(input);
else if (action === "kick")
payload = z.object({ playerId: z.string().uuid() }).parse(input);
else if (!["start", "leave", "close"].includes(action))
return NextResponse.json({ error: "Not found" }, { status: 404 });
} else if (
!(
request.method === "GET" &&
(path.length === 2 || (path.length === 3 && action === "media"))
)
)
return NextResponse.json({ error: "Not found" }, { status: 404 });
const room = required(
await client.rpc("multiplayer_action", {
p_room: id,
p_access_hash: accessHash,
p_action: request.method === "GET" ? "view" : action,
p_payload: payload,
}),
) as unknown as RoomView;
if (action !== "media") return respond(room);
if (!room.you) throw new ServiceError("Join this room to play", 403);
if (["lobby", "closed"].includes(room.status))
throw new ServiceError("This round is not available yet", 409);
const [record, round] = await Promise.all([
client
.from("multiplayer_rooms")
.select("revision_id")
.eq("id", id)
.single(),
client
.from("multiplayer_rounds")
.select("viewpoint")
.eq("room_id", id)
.eq("round_number", room.round)
.single(),
]);
const revision = required(
await client
.from("revisions")
.select("asset_root,manifest")
.eq("id", required(record).revision_id)
.single(),
);
const manifest = datasetSchema.parse(revision.manifest);
const viewpoint = viewpointSchema.parse(required(round).viewpoint);
const signed = await mediaUrls([
manifest.mapPath ?? `${revision.asset_root}/map.webp`,
...faces.map((face) =>
panoramaPath(revision.asset_root, viewpoint, `${face}.webp`),
),
]);
return respond({
round: room.round,
bounds: manifest.bounds,
map: signed[0],
faces: signed.slice(1),
});
}

43
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.117.2",
"@vercel/analytics": "2.0.1",
"fflate": "0.8.3",
"leaflet": "1.9.4",
"lucide-react": "0.468.0",
@@ -1793,6 +1794,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/@vercel/analytics": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
"license": "MIT",
"peerDependencies": {
"@remix-run/react": "^2",
"@sveltejs/kit": "^1 || ^2",
"next": ">= 13",
"nuxt": ">= 3",
"react": "^18 || ^19 || ^19.0.0-rc",
"svelte": ">= 4",
"vue": "^3",
"vue-router": "^4"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"next": {
"optional": true
},
"nuxt": {
"optional": true
},
"react": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
},
"vue-router": {
"optional": true
}
}
},
"node_modules/@webgpu/types": {
"version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",

View File

@@ -16,6 +16,7 @@
"worker": "tsx worker/main.ts",
"test": "tsx --test tests/*.test.ts",
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
"test:renderer": "node scripts/test-renderer.mjs",
"render:map": "node scripts/render-map.mjs",
"test:production": "node scripts/test-production.mjs",
@@ -27,6 +28,7 @@
"dependencies": {
"@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.117.2",
"@vercel/analytics": "2.0.1",
"fflate": "0.8.3",
"leaflet": "1.9.4",
"lucide-react": "0.468.0",

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

View File

@@ -43,6 +43,93 @@ export type Database = {
};
public: {
Tables: {
multiplayer_rooms: {
Row: {
id: string;
revision_id: string;
host_player_id: string;
difficulty: string;
player_limit: number;
status: string;
current_round: number;
transition_at: string | null;
created_at: string;
};
Insert: {
id?: string;
revision_id: string;
host_player_id: string;
difficulty: string;
player_limit: number;
status?: string;
current_round?: number;
transition_at?: string | null;
created_at?: string;
};
Update: {
host_player_id?: string;
status?: string;
current_round?: number;
transition_at?: string | null;
};
Relationships: [];
};
multiplayer_rounds: {
Row: {
room_id: string;
round_number: number;
viewpoint: Json;
deadline: string | null;
closed_at: string | null;
};
Insert: {
room_id: string;
round_number: number;
viewpoint: Json;
deadline?: string | null;
closed_at?: string | null;
};
Update: { deadline?: string | null; closed_at?: string | null };
Relationships: [];
};
multiplayer_players: {
Row: {
id: string;
room_id: string;
access_hash: string;
display_name: string;
ready_round: number;
has_left: boolean;
joined_at: string;
};
Insert: {
id?: string;
room_id: string;
access_hash: string;
display_name: string;
ready_round?: number;
has_left?: boolean;
joined_at?: string;
};
Update: { ready_round?: number; has_left?: boolean };
Relationships: [];
};
multiplayer_guesses: {
Row: {
room_id: string;
player_id: string;
round_number: number;
result: Json;
};
Insert: {
room_id: string;
player_id: string;
round_number: number;
result: Json;
};
Update: { result?: Json };
Relationships: [];
};
site_settings: {
Row: {
id: boolean;
@@ -540,6 +627,25 @@ export type Database = {
[_ in never]: never;
};
Functions: {
create_multiplayer_room: {
Args: {
p_dataset: string;
p_difficulty: string;
p_name: string;
p_limit: number;
p_access_hash: string;
};
Returns: string;
};
multiplayer_action: {
Args: {
p_room: string;
p_access_hash: string;
p_action: string;
p_payload: Json;
};
Returns: Json;
};
legacy_password_needed: { Args: { p_user: string }; Returns: boolean };
claim_legacy_password: { Args: { p_user: string }; Returns: boolean };
set_beta_badge: {

View File

@@ -605,5 +605,64 @@
"Not now": "Не сейчас",
"Select the password and copy it manually.": "Выдели пароль и скопируй его вручную.",
"A password is already set or was already issued. Use password recovery if needed.": "Пароль уже установлен или был выдан ранее. Если нужно, воспользуйся восстановлением пароля.",
"Password setup could not be confirmed. Use password recovery before signing out.": "Не удалось подтвердить установку пароля. Восстанови пароль перед выходом из аккаунта."
"Password setup could not be confirmed. Use password recovery before signing out.": "Не удалось подтвердить установку пароля. Восстанови пароль перед выходом из аккаунта.",
"Game mode": "Режим игры",
"Solo": "Одиночная игра",
"Multiplayer": "Мультиплеер",
"Your player name": "Ваше игровое имя",
"Enter a player name": "Введите игровое имя",
"Player limit (including you)": "Лимит игроков (включая вас)",
"Create a room, share the invite link, and start when your friends arrive.": "Создайте комнату, поделитесь ссылкой и начните игру, когда друзья присоединятся.",
"Create multiplayer room": "Создать комнату",
"Retry": "Повторить",
"Loading multiplayer room…": "Загрузка комнаты…",
"Final standings": "Итоговая таблица",
"This room is closed": "Эта комната закрыта",
"An expedition with friends.": "Экспедиция с друзьями.",
"Join your friends for five rounds on the same map.": "Присоединитесь к друзьям: пять раундов на одной карте.",
"{count} / {limit} players": "Игроков: {count} / {limit}",
"This room is full": "Комната заполнена",
"Join room": "Войти в комнату",
"This match has already started": "Игра уже началась",
"Share the invite link. Start when your friends arrive.": "Поделитесь ссылкой. Начните игру, когда друзья присоединятся.",
"You’re in! Waiting for the room owner to start.": "Вы в комнате! Ждём, когда владелец начнёт игру.",
"Invite link": "Ссылка-приглашение",
"Copied!": "Скопировано!",
"Copy link": "Копировать ссылку",
"Room owner": "Владелец комнаты",
"(you)": "(вы)",
"Remove {name}": "Удалить игрока {name}",
"Remove": "Удалить",
"Everyone plays the same five locations. The most points wins; equal totals share a place.": "Все играют на одинаковых пяти локациях. Побеждает игрок с наибольшим числом очков; при равном счёте место общее.",
"Start multiplayer match": "Начать игру",
"Leave room": "Покинуть комнату",
"Close room": "Закрыть комнату",
"Invite at least one friend before starting": "Перед началом пригласите хотя бы одного друга",
"{count} points": "{count} очков",
"Five-round totals": "Результаты пяти раундов",
"Place": "Место",
"Player": "Игрок",
"Total points": "Всего очков",
"Left room": "Покинул комнату",
"Play this map again": "Сыграть на этой карте ещё раз",
"Loading…": "Загрузка…",
"Players": "Игроки",
"Ready": "Готов",
"Guess submitted": "Ответ отправлен",
"Guessing": "Выбирает место",
"Guess locked. Waiting for the other players.": "Ответ принят. Ждём остальных игроков.",
"Time is up. Calculating round scores…": "Время вышло. Подсчитываем очки…",
"Waiting for players to load the shared round…": "Ждём загрузки раунда у остальных игроков…",
"Starting within {seconds}s": "Начало не позднее чем через {seconds} с",
"Round standings": "Результаты раунда",
"Next round in {seconds}s": "Следующий раунд через {seconds} с",
"Copy the invite link from the field below": "Скопируйте ссылку из поля ниже",
"Choose between 2 and 16 players": "Выберите от 2 до 16 игроков",
"This name is already taken in this room": "Это имя уже занято в комнате",
"Room owner permission required": "Это действие доступно владельцу комнаты",
"Room membership permission required": "Сначала присоединитесь к комнате",
"Room not found": "Комната не найдена",
"Join this room to play": "Присоединитесь к комнате, чтобы играть",
"Wait for the round to finish": "Дождитесь завершения раунда",
"Players can only be removed before the match starts": "Удалять игроков можно только до начала игры"
}

62
shared/multiplayer.ts Normal file
View File

@@ -0,0 +1,62 @@
import { z } from "zod";
import { displayNameSchema } from "./account";
import type { Bounds, Difficulty } from "./protocol";
export const createRoomSchema = z.object({
datasetId: z.string().uuid(),
difficulty: z.enum(["easy", "normal", "hard"]),
displayName: displayNameSchema,
playerLimit: z.number().int().min(2).max(16),
});
export const joinRoomSchema = z.object({ displayName: displayNameSchema });
export const roomRoundSchema = z.object({
round: z.number().int().min(0).max(4),
});
export const roomGuessSchema = roomRoundSchema.extend({
x: z.number().finite(),
z: z.number().finite(),
});
export type RoomResult = {
playerId: string;
score: number;
distance: number | null;
timedOut: boolean;
actual: { x: number; z: number };
guess: { x: number; z: number } | null;
};
export type RoomView = {
id: string;
datasetId: string;
name: string;
difficulty: Difficulty;
playerLimit: number;
playerCount: number;
status: "lobby" | "loading" | "playing" | "results" | "finished" | "closed";
round: number;
hostId: string;
you: string | null;
serverNow: number;
transitionAt: string | null;
players: {
id: string;
name: string;
left: boolean;
ready: boolean;
submitted: boolean;
total: number;
rank: number;
}[];
rounds: {
number: number;
deadline: string | null;
closedAt: string | null;
results: RoomResult[];
}[];
myGuess: { x: number; z: number } | null;
};
export type RoomMedia = {
round: number;
map: string;
bounds: Bounds;
faces: string[];
};

View File

@@ -0,0 +1,214 @@
-- Guest identities are verified by the server's HttpOnly cookie. No client role
-- can read room secrets, future locations, guesses, or invoke these functions.
create table public.multiplayer_rooms (
id uuid primary key default gen_random_uuid(),
revision_id uuid not null references public.revisions(id),
host_player_id uuid not null,
difficulty text not null check (difficulty in ('easy','normal','hard')),
player_limit integer not null check (player_limit between 2 and 16),
status text not null default 'lobby' check (status in ('lobby','loading','playing','results','finished','closed')),
current_round integer not null default 0 check (current_round between 0 and 4),
transition_at timestamptz,
created_at timestamptz not null default now()
);
create index multiplayer_rooms_revision on public.multiplayer_rooms(revision_id);
create table public.multiplayer_players (
id uuid primary key default gen_random_uuid(),
room_id uuid not null references public.multiplayer_rooms(id) on delete cascade,
access_hash text not null check (access_hash ~ '^[a-f0-9]{64}$'),
display_name text not null check (length(display_name) between 2 and 32 and display_name !~ '[[:cntrl:]]' and display_name !~ U&'[\202A-\202E\2066-\2069]'),
ready_round integer not null default -1,
has_left boolean not null default false,
joined_at timestamptz not null default clock_timestamp(),
unique(room_id,access_hash),
unique(room_id,id)
);
create unique index multiplayer_player_names on public.multiplayer_players(room_id,lower(display_name));
create table public.multiplayer_rounds (
room_id uuid not null references public.multiplayer_rooms(id) on delete cascade,
round_number integer not null check (round_number between 0 and 4),
viewpoint jsonb not null,
deadline timestamptz,
closed_at timestamptz,
primary key(room_id,round_number)
);
create table public.multiplayer_guesses (
room_id uuid not null,
player_id uuid not null,
round_number integer not null,
result jsonb not null,
primary key(room_id,player_id,round_number),
foreign key(room_id,player_id) references public.multiplayer_players(room_id,id) on delete cascade,
foreign key(room_id,round_number) references public.multiplayer_rounds(room_id,round_number) on delete cascade
);
create index multiplayer_guesses_round on public.multiplayer_guesses(room_id,round_number);
alter table public.multiplayer_rooms enable row level security;
alter table public.multiplayer_players enable row level security;
alter table public.multiplayer_rounds enable row level security;
alter table public.multiplayer_guesses enable row level security;
revoke all on public.multiplayer_rooms, public.multiplayer_players, public.multiplayer_rounds, public.multiplayer_guesses from public,anon,authenticated;
grant all on public.multiplayer_rooms, public.multiplayer_players, public.multiplayer_rounds, public.multiplayer_guesses to service_role;
create function public.create_multiplayer_room(p_dataset uuid,p_difficulty text,p_name text,p_limit integer,p_access_hash text)
returns uuid language plpgsql security invoker set search_path='' as $$
declare rid uuid; pid uuid := gen_random_uuid(); rev public.revisions; vp jsonb; n integer := 0;
begin
if p_limit is null or p_limit not between 2 and 16 then raise exception 'Choose between 2 and 16 players'; end if;
if p_difficulty is null or p_difficulty not in ('easy','normal','hard') then raise exception 'Invalid difficulty'; end if;
select r.* into rev from public.datasets d join public.revisions r on r.id=d.revision_id where d.id=p_dataset and d.state='published';
if not found then raise exception 'Map unavailable'; end if;
if coalesce(jsonb_array_length(rev.manifest->'viewpoints'),0)<5 then raise exception 'Map has fewer than five viewpoints'; end if;
insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,player_limit) values(rev.id,pid,p_difficulty,p_limit) returning id into rid;
insert into public.multiplayer_players(id,room_id,access_hash,display_name) values(pid,rid,p_access_hash,btrim(p_name));
for vp in select value from jsonb_array_elements(rev.manifest->'viewpoints') order by random() limit 5 loop
insert into public.multiplayer_rounds(room_id,round_number,viewpoint) values(rid,n,vp); n := n+1;
end loop;
return rid;
end; $$;
-- One room lock serializes capacity checks, guesses, and deadline transitions.
-- GET polling also settles disconnected players; it never relies on their timers.
create function public.multiplayer_tick(p_room uuid)
returns void language plpgsql security invoker set search_path='' as $$
declare room public.multiplayer_rooms; turn public.multiplayer_rounds; seconds integer;
begin
select * into room from public.multiplayer_rooms where id=p_room for update;
if room.status not in ('finished','closed') and room.created_at < clock_timestamp()-interval '24 hours' then
update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room; return;
end if;
if room.status='results' and room.transition_at<=clock_timestamp() then
update public.multiplayer_rooms set current_round=current_round+1,status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room returning * into room;
end if;
if room.status='loading' and (
room.transition_at<=clock_timestamp() or not exists(select 1 from public.multiplayer_players where room_id=p_room and not has_left and ready_round<>room.current_round)
) then
seconds := case room.difficulty when 'easy' then 180 when 'normal' then 90 else 30 end;
update public.multiplayer_rounds set deadline=clock_timestamp()+make_interval(secs=>seconds) where room_id=p_room and round_number=room.current_round;
update public.multiplayer_rooms set status='playing',transition_at=null where id=p_room returning * into room;
end if;
if room.status<>'playing' then return; end if;
select * into turn from public.multiplayer_rounds where room_id=p_room and round_number=room.current_round;
insert into public.multiplayer_guesses(room_id,player_id,round_number,result)
select p_room,p.id,room.current_round,jsonb_build_object('score',0,'distance',null,'timedOut',true,'guess',null,'actual',jsonb_build_object('x',turn.viewpoint->'x','z',turn.viewpoint->'z'))
from public.multiplayer_players p where p.room_id=p_room and (p.has_left or turn.deadline<=clock_timestamp())
on conflict(room_id,player_id,round_number) do nothing;
if not exists(select 1 from public.multiplayer_players p where p.room_id=p_room and not exists(
select 1 from public.multiplayer_guesses g where g.room_id=p_room and g.player_id=p.id and g.round_number=room.current_round
)) then
update public.multiplayer_rounds set closed_at=clock_timestamp() where room_id=p_room and round_number=room.current_round;
update public.multiplayer_rooms set status=case when current_round=4 then 'finished' else 'results' end,
transition_at=case when current_round=4 then null else clock_timestamp()+interval '20 seconds' end where id=p_room;
end if;
end; $$;
-- Only closed rounds contribute scores or reveal locations, even to the host.
create function public.multiplayer_snapshot(p_room uuid,p_access_hash text)
returns jsonb language sql security invoker set search_path='' as $$
with room as (select * from public.multiplayer_rooms where id=p_room),
viewer as (select p.id from public.multiplayer_players p where p.room_id=p_room and p.access_hash=p_access_hash and not p.has_left),
totals as (
select p.*,coalesce((select sum((g.result->>'score')::integer) from public.multiplayer_guesses g
join public.multiplayer_rounds r on r.room_id=g.room_id and r.round_number=g.round_number
where g.room_id=p_room and g.player_id=p.id and r.closed_at is not null),0) as total
from public.multiplayer_players p where p.room_id=p_room
), ranked as (select *,dense_rank() over(order by total desc) as rank from totals)
select jsonb_build_object(
'id',room.id,'datasetId',rev.dataset_id,'name',rev.manifest->>'name','difficulty',room.difficulty,
'playerLimit',room.player_limit,'playerCount',(select count(*) from totals where not has_left),
'status',room.status,'round',room.current_round,'hostId',room.host_player_id,'you',(select id from viewer),
'serverNow',floor(extract(epoch from clock_timestamp())*1000),'transitionAt',room.transition_at,
'players',case when exists(select 1 from viewer) then coalesce((select jsonb_agg(jsonb_build_object(
'id',p.id,'name',p.display_name,'left',p.has_left,'ready',p.ready_round=room.current_round,
'submitted',exists(select 1 from public.multiplayer_guesses g where g.room_id=p_room and g.player_id=p.id and g.round_number=room.current_round),
'total',p.total,'rank',p.rank
) order by p.rank,p.joined_at,p.id) from ranked p),'[]'::jsonb) else '[]'::jsonb end,
'rounds',case when exists(select 1 from viewer) then coalesce((select jsonb_agg(jsonb_build_object(
'number',r.round_number,'deadline',r.deadline,'closedAt',r.closed_at,
'results',case when r.closed_at is not null then coalesce((select jsonb_agg(g.result||jsonb_build_object('playerId',g.player_id) order by g.player_id)
from public.multiplayer_guesses g where g.room_id=p_room and g.round_number=r.round_number),'[]'::jsonb) else '[]'::jsonb end
) order by r.round_number) from public.multiplayer_rounds r where r.room_id=p_room),'[]'::jsonb) else '[]'::jsonb end,
'myGuess',case when exists(select 1 from viewer) then (select g.result->'guess' from public.multiplayer_guesses g where g.room_id=p_room and g.player_id=(select id from viewer) and g.round_number=room.current_round) else null end
) from room join public.revisions rev on rev.id=room.revision_id;
$$;
create function public.multiplayer_action(p_room uuid,p_access_hash text,p_action text,p_payload jsonb default '{}')
returns jsonb language plpgsql security invoker set search_path='' as $$
declare room public.multiplayer_rooms; player public.multiplayer_players; turn public.multiplayer_rounds;
target uuid; n integer; px double precision; pz double precision; distance double precision;
diagonal double precision; points integer; bounds jsonb; answer jsonb; active integer;
begin
select * into room from public.multiplayer_rooms where id=p_room for update;
if not found then raise exception 'Room not found'; end if;
select * into player from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash;
if p_action='join' then
if room.status='closed' then raise exception 'This room is closed'; end if;
if player.id is not null and not player.has_left then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
if room.status<>'lobby' then raise exception 'This match has already started'; end if;
select count(*) into active from public.multiplayer_players where room_id=p_room and not has_left;
if active>=room.player_limit then raise exception 'This room is full'; end if;
if exists(select 1 from public.multiplayer_players where room_id=p_room and lower(display_name)=lower(btrim(p_payload->>'name'))) then
raise exception 'This name is already taken in this room';
end if;
insert into public.multiplayer_players(room_id,access_hash,display_name) values(p_room,p_access_hash,btrim(p_payload->>'name'));
return public.multiplayer_snapshot(p_room,p_access_hash);
end if;
if player.id is null or player.has_left then
if p_action='view' then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
raise exception 'Room membership permission required';
end if;
perform public.multiplayer_tick(p_room);
select * into room from public.multiplayer_rooms where id=p_room;
if p_action in ('start','next','kick','close') and room.host_player_id<>player.id then raise exception 'Room owner permission required'; end if;
if p_action='start' then
if room.status<>'lobby' then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
if (select count(*) from public.multiplayer_players where room_id=p_room and not has_left)<2 then raise exception 'Invite at least one friend before starting'; end if;
update public.multiplayer_rooms set status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room;
elsif p_action='ready' then
n := (p_payload->>'round')::integer;
if room.status='loading' and n=room.current_round then update public.multiplayer_players set ready_round=n where id=player.id; end if;
elsif p_action='guess' then
n := (p_payload->>'round')::integer;
if exists(select 1 from public.multiplayer_guesses where room_id=p_room and player_id=player.id and round_number=n) then
return public.multiplayer_snapshot(p_room,p_access_hash);
end if;
if room.status<>'playing' or n is distinct from room.current_round then raise exception 'Round is not active'; end if;
select * into turn from public.multiplayer_rounds where room_id=p_room and round_number=n;
px := (p_payload->>'x')::double precision; pz := (p_payload->>'z')::double precision;
if px is null or pz is null then raise exception 'Place a guess first'; end if;
select manifest->'bounds' into bounds from public.revisions where id=room.revision_id;
if px='NaN'::float8 or pz='NaN'::float8 or px<(bounds->>'minX')::float8 or px>(bounds->>'maxX')::float8 or pz<(bounds->>'minZ')::float8 or pz>(bounds->>'maxZ')::float8 then raise exception 'Guess outside map'; end if;
-- Check again after validation: a request arriving at the deadline scores zero.
if turn.deadline<=clock_timestamp() then
perform public.multiplayer_tick(p_room); return public.multiplayer_snapshot(p_room,p_access_hash);
end if;
distance := sqrt(power(px-(turn.viewpoint->>'x')::float8,2)+power(pz-(turn.viewpoint->>'z')::float8,2));
diagonal := sqrt(power((bounds->>'maxX')::float8-(bounds->>'minX')::float8,2)+power((bounds->>'maxZ')::float8-(bounds->>'minZ')::float8,2));
points := case when distance<=1 then 5000 else round(5000*exp(-5*distance/diagonal))::integer end;
answer := jsonb_build_object('score',points,'distance',distance,'timedOut',false,'guess',jsonb_build_object('x',px,'z',pz),'actual',jsonb_build_object('x',turn.viewpoint->'x','z',turn.viewpoint->'z'));
insert into public.multiplayer_guesses(room_id,player_id,round_number,result) values(p_room,player.id,n,answer);
elsif p_action='next' then
n := (p_payload->>'round')::integer;
if n<room.current_round then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
if room.status<>'results' or n is distinct from room.current_round then raise exception 'Wait for the round to finish'; end if;
update public.multiplayer_rooms set current_round=current_round+1,status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room;
elsif p_action='kick' then
if room.status<>'lobby' then raise exception 'Players can only be removed before the match starts'; end if;
target := (p_payload->>'playerId')::uuid;
if target=room.host_player_id then raise exception 'The owner cannot remove themselves'; end if;
delete from public.multiplayer_players where room_id=p_room and id=target;
elsif p_action='leave' then
if room.status='lobby' then delete from public.multiplayer_players where id=player.id;
else update public.multiplayer_players set has_left=true where id=player.id; end if;
select id into target from public.multiplayer_players where room_id=p_room and not has_left order by joined_at,id limit 1;
if target is null then update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room;
elsif room.host_player_id=player.id then update public.multiplayer_rooms set host_player_id=target where id=p_room; end if;
elsif p_action='close' then
update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room;
elsif p_action<>'view' then raise exception 'Invalid room action';
end if;
perform public.multiplayer_tick(p_room);
return public.multiplayer_snapshot(p_room,p_access_hash);
end; $$;
revoke all on function public.create_multiplayer_room(uuid,text,text,integer,text),public.multiplayer_tick(uuid),public.multiplayer_snapshot(uuid,text),public.multiplayer_action(uuid,text,text,jsonb) from public,anon,authenticated;
grant execute on function public.create_multiplayer_room(uuid,text,text,integer,text),public.multiplayer_tick(uuid),public.multiplayer_snapshot(uuid,text),public.multiplayer_action(uuid,text,text,jsonb) to service_role;

46
tests/multiplayer.test.ts Normal file
View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
createRoomSchema,
joinRoomSchema,
roomGuessSchema,
} from "../shared/multiplayer";
const map = "00000000-0000-4000-8000-000000000000";
test("room limits include the owner and stay between 2 and 16", () => {
for (const playerLimit of [2, 16])
assert.equal(
createRoomSchema.parse({
datasetId: map,
difficulty: "normal",
displayName: " Alex ",
playerLimit,
}).displayName,
"Alex",
);
for (const playerLimit of [1, 17, 2.5, "16", null])
assert.equal(
createRoomSchema.safeParse({
datasetId: map,
difficulty: "normal",
displayName: "Alex",
playerLimit,
}).success,
false,
);
});
test("multiplayer names and guesses reject hidden controls, invalid rounds, and non-finite positions", () => {
assert.equal(
joinRoomSchema.parse({ displayName: " Друг " }).displayName,
"Друг",
);
for (const displayName of ["a", "x\nname", "name\u202e", "a".repeat(33)])
assert.equal(joinRoomSchema.safeParse({ displayName }).success, false);
for (const payload of [
{ round: 5, x: 0, z: 0 },
{ round: 0, x: Infinity, z: 0 },
{ round: 0, x: NaN, z: 0 },
{ round: 0, x: 0 },
])
assert.equal(roomGuessSchema.safeParse(payload).success, false);
});