From 48b34db6f19f80209c2a45f0bf3e0bbefd4012bd Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Wed, 7 Oct 2026 12:28:45 +0500 Subject: [PATCH] Add multiplayer rooms and Vercel analytics --- app/layout.tsx | 3 + app/multiplayer.css | 217 +++++++++++ app/multiplayer/[id]/page.tsx | 13 + components/MultiplayerGame.tsx | 314 +++++++++++++++ components/MultiplayerRoom.tsx | 367 ++++++++++++++++++ components/StartGame.tsx | 82 +++- docs/multiplayer.md | 45 +++ docs/vercel-deployment.md | 12 + lib/api.ts | 3 + lib/multiplayer-api.ts | 137 +++++++ package-lock.json | 43 ++ package.json | 2 + scripts/test-multiplayer-browser.mjs | 181 +++++++++ scripts/test-multiplayer-http.mjs | 163 ++++++++ scripts/test-multiplayer-sql.mjs | 283 ++++++++++++++ shared/database.ts | 106 +++++ shared/locales/ru.json | 61 ++- shared/multiplayer.ts | 62 +++ .../20261007071259_multiplayer_rooms.sql | 214 ++++++++++ tests/multiplayer.test.ts | 46 +++ 20 files changed, 2345 insertions(+), 9 deletions(-) create mode 100644 app/multiplayer.css create mode 100644 app/multiplayer/[id]/page.tsx create mode 100644 components/MultiplayerGame.tsx create mode 100644 components/MultiplayerRoom.tsx create mode 100644 docs/multiplayer.md create mode 100644 lib/multiplayer-api.ts create mode 100644 scripts/test-multiplayer-browser.mjs create mode 100644 scripts/test-multiplayer-http.mjs create mode 100644 scripts/test-multiplayer-sql.mjs create mode 100644 shared/multiplayer.ts create mode 100644 supabase/migrations/20261007071259_multiplayer_rooms.sql create mode 100644 tests/multiplayer.test.ts diff --git a/app/layout.tsx b/app/layout.tsx index c419231..c1327e4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ + ); diff --git a/app/multiplayer.css b/app/multiplayer.css new file mode 100644 index 0000000..624afff --- /dev/null +++ b/app/multiplayer.css @@ -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; +} diff --git a/app/multiplayer/[id]/page.tsx b/app/multiplayer/[id]/page.tsx new file mode 100644 index 0000000..e65e58c --- /dev/null +++ b/app/multiplayer/[id]/page.tsx @@ -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 ; +} diff --git a/components/MultiplayerGame.tsx b/components/MultiplayerGame.tsx new file mode 100644 index 0000000..8ca9c76 --- /dev/null +++ b/components/MultiplayerGame.tsx @@ -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; + remaining: (deadline: string) => number; + onLeave: () => void; +}) { + const { t, formatNumber } = useI18n(); + const [media, setMedia] = useState(null); + const [error, setError] = useState(""); + const [pin, setPin] = useState(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(`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 ( +
+ {media && ( + + )} +
+ + +
+
+ {t("ROUND")} + {room.round + 1} / 5 +
+
+ {t("TIME LEFT")} + + {room.status === "loading" + ? t("Loading…") + : `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`} + +
+
+ {t("POINTS")} + {formatNumber(me?.total ?? 0)} +
+
+
+
+ + {t("Players")} · {room.playerCount} + +
    + {room.players.map((player) => ( +
  • + + {player.name} + {player.id === room.you && {t("(you)")}} + + {player.left + ? t("Left room") + : room.status === "loading" + ? player.ready + ? t("Ready") + : t("Loading…") + : player.submitted + ? t("Guess submitted") + : t("Guessing")} + + + {formatNumber(player.total)} +
  • + ))} +
+ +
+ {media && ( +
+ +
+
+ {t("↑ N · PLACE YOUR GUESS")} + +
+ +
+ + {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")} + + +
+
+
+ )} + {room.status === "playing" && submitted && ( +
+ {t("Guess locked. Waiting for the other players.")} +
+ )} + {room.status === "playing" && seconds === 0 && !submitted && ( +
+ {t("Time is up. Calculating round scores…")} +
+ )} + {(!media || + !loaded.panorama || + !loaded.map || + room.status === "loading") && + !error && + room.status !== "results" && ( +
+
+

{t("Preparing your expedition.")}

+

+ {loaded.panorama && loaded.map + ? t("Waiting for players to load the shared round…") + : t("Loading panorama and surface map…")} +

+ {room.status === "loading" && ( + + {t("Starting within {seconds}s", { seconds: nextIn })} + + )} +
+ )} + {error && ( +
+

{t("Connection interrupted.")}

+

{t(error)}

+ +
+ )} + {room.status === "results" && !error && ( +
+ + {t("ROUND")} {room.round + 1} {t("COMPLETE")} + +

{t("Round standings")}

+
+ {formatNumber(result?.score ?? 0)} + / 5,000 +
+

+ {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)), + })} +

+
    + {roundScores.map((score) => ( +
  1. + + { + room.players.find((player) => player.id === score.playerId) + ?.name + } + + {formatNumber(score.score)} +
  2. + ))} +
+ {owner && ( + + )} +

+ {t("Next round in {seconds}s", { seconds: nextIn })} +

+
+ )} +
+ ); +} diff --git a/components/MultiplayerRoom.tsx b/components/MultiplayerRoom.tsx new file mode 100644 index 0000000..8bc91f8 --- /dev/null +++ b/components/MultiplayerRoom.tsx @@ -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(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 | null>(null); + const version = useRef(0); + const acting = useRef(false); + const latest = useRef(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(`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; + 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(`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 ( + <> + + {error && ( +
+ {t(error)}{" "} + +
+ )} + + ); + const owner = !!room?.you && room.you === room.hostId; + const finished = room?.status === "finished"; + const winners = room?.players.filter((player) => player.rank === 1) ?? []; + return ( + <> +
+ + + {t("Play & maps")} + +
+
+ {error && ( +

+ {t(error)}{" "} + +

+ )} + {!room ? ( +

{t("Loading multiplayer room…")}

+ ) : ( + <> + + {t("Multiplayer")} · {room.name} + +

+ {finished + ? t("Final standings") + : room.status === "closed" + ? t("This room is closed") + : t("An expedition with friends.")} +

+ {!room.you && room.status === "lobby" && ( +
{ + event.preventDefault(); + void action("join", { displayName: name }); + }} + > +

+ {t("Join your friends for five rounds on the same map.")} +

+

+ {t("{count} / {limit} players", { + count: room.playerCount, + limit: room.playerLimit, + })} +

+ + setName(event.target.value)} + minLength={2} + maxLength={32} + required + autoComplete="nickname" + placeholder={t("Enter a player name")} + disabled={busy} + /> + +
+ )} + {!room.you && !["lobby", "closed"].includes(room.status) && ( +

{t("This match has already started")}

+ )} + {room.you && room.status === "lobby" && ( + <> +

+ {owner + ? t( + "Share the invite link. Start when your friends arrive.", + ) + : t("You’re in! Waiting for the room owner to start.")} +

+ +
+ event.target.select()} + /> + +
+

+ {t("{count} / {limit} players", { + count: room.playerCount, + limit: room.playerLimit, + })} +

+
    + {room.players.map((player) => ( +
  • + + {player.id === room.hostId && ( + + )}{" "} + {player.name}{" "} + {player.id === room.you && {t("(you)")}} + + {owner && player.id !== room.you && ( + + )} +
  • + ))} +
+

+ {t( + "Everyone plays the same five locations. The most points wins; equal totals share a place.", + )} +

+
+ {owner && ( + + )} + + {owner && ( + + )} +
+ {owner && room.playerCount < 2 && ( +

+ {t("Invite at least one friend before starting")} +

+ )} + + )} + {finished && room.you && ( + <> +

+

+
+ + + + + + + {room.rounds.map((turn) => ( + + ))} + + + + + {room.players.map((player) => ( + + + + {room.rounds.map((turn) => ( + + ))} + + + ))} + +
{t("Five-round totals")}
{t("Place")}{t("Player")} + {t("Round")} {turn.number + 1} + {t("Total points")}
{player.rank} + {player.name} + {player.left && · {t("Left room")}} + + {formatNumber( + turn.results.find( + (result) => result.playerId === player.id, + )?.score ?? 0, + )} + + {formatNumber(player.total)} +
+
+ + {t("Play this map again")} + + + )} + {room.status === "closed" && ( + + {t("Another adventure →")} + + )} + + )} +
+ + ); +} diff --git a/components/StartGame.tsx b/components/StartGame.tsx index 6937b09..87fe4be 100644 --- a/components/StartGame.tsx +++ b/components/StartGame.tsx @@ -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("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 }) {
{t("YOUR EXPEDITION")}

{t("Choose your challenge.")}

+
+ + +
{Object.entries(difficulties).map(([key, value]) => ( {error && ( diff --git a/docs/multiplayer.md b/docs/multiplayer.md new file mode 100644 index 0000000..2d6424e --- /dev/null +++ b/docs/multiplayer.md @@ -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 `: 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 `: desktop and mobile + invite, reconnect, map interaction, and five-round standings checks. Requires + Playwright Chromium; removes its own test room and saves screenshots locally. diff --git a/docs/vercel-deployment.md b/docs/vercel-deployment.md index 86ff123..9f981f0 100644 --- a/docs/vercel-deployment.md +++ b/docs/vercel-deployment.md @@ -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. diff --git a/lib/api.ts b/lib/api.ts index 8475af5..fdec554 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -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") { diff --git a/lib/multiplayer-api.ts b/lib/multiplayer-api.ts new file mode 100644 index 0000000..6809a16 --- /dev/null +++ b/lib/multiplayer-api.ts @@ -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; + limit: (key: string, count: number, seconds: number) => Promise; +}; +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 = {}; + 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), + }); +} diff --git a/package-lock.json b/package-lock.json index 61547f1..d36a94c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4bc0926..824f6aa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-multiplayer-browser.mjs b/scripts/test-multiplayer-browser.mjs new file mode 100644 index 0000000..eb2b02c --- /dev/null +++ b/scripts/test-multiplayer-browser.mjs @@ -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}`); + } +} diff --git a/scripts/test-multiplayer-http.mjs b/scripts/test-multiplayer-http.mjs new file mode 100644 index 0000000..9952e09 --- /dev/null +++ b/scripts/test-multiplayer-http.mjs @@ -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}`); + } +} diff --git a/scripts/test-multiplayer-sql.mjs b/scripts/test-multiplayer-sql.mjs new file mode 100644 index 0000000..63dfbe1 --- /dev/null +++ b/scripts/test-multiplayer-sql.mjs @@ -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(); +} diff --git a/shared/database.ts b/shared/database.ts index 2a3ccde..cb30bc2 100644 --- a/shared/database.ts +++ b/shared/database.ts @@ -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: { diff --git a/shared/locales/ru.json b/shared/locales/ru.json index b4a2f89..42e4a8f 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -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": "Удалять игроков можно только до начала игры" } diff --git a/shared/multiplayer.ts b/shared/multiplayer.ts new file mode 100644 index 0000000..f4596a9 --- /dev/null +++ b/shared/multiplayer.ts @@ -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[]; +}; diff --git a/supabase/migrations/20261007071259_multiplayer_rooms.sql b/supabase/migrations/20261007071259_multiplayer_rooms.sql new file mode 100644 index 0000000..9977230 --- /dev/null +++ b/supabase/migrations/20261007071259_multiplayer_rooms.sql @@ -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'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; diff --git a/tests/multiplayer.test.ts b/tests/multiplayer.test.ts new file mode 100644 index 0000000..bde8fe2 --- /dev/null +++ b/tests/multiplayer.test.ts @@ -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); +});