Compare commits

...

2 Commits

39 changed files with 3107 additions and 105 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -284,3 +284,10 @@
gap: 8px; gap: 8px;
} }
} }
.legacy-password-dialog { width: min(540px, calc(100vw - 24px)); }
.legacy-password-dialog > svg { color: var(--green); }
.legacy-password-dialog h2 { margin-top: 16px; }
.legacy-password-dialog .field input { width: 100%; font: 14px monospace; padding: 12px; background: var(--bg); color: var(--text); border: 1px solid var(--line); }
.legacy-password-dialog > .text-link { margin-top: 18px; }
.password-saved { display: flex; gap: 10px; align-items: flex-start; margin: 20px 0; }
.password-saved input { flex-shrink: 0; margin-top: 5px; accent-color: var(--green); }

View File

@@ -11,11 +11,13 @@ export async function GET(request: NextRequest) {
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token"); const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
const type = request.nextUrl.searchParams.get("type"); const type = request.nextUrl.searchParams.get("type");
const next = authDestination(request.nextUrl.searchParams.get("next")); const next = authDestination(request.nextUrl.searchParams.get("next"));
if (type === "magiclink")
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
if ( if (
code || code ||
legacyToken || legacyToken ||
(tokenHash && (tokenHash &&
(type === "email" || type === "recovery" || type === "magiclink")) (type === "email" || type === "signup" || type === "recovery"))
) { ) {
const client = await authClient(); const client = await authClient();
let { data, error } = code let { data, error } = code
@@ -24,7 +26,7 @@ export async function GET(request: NextRequest) {
? await client.auth.exchangeCodeForSession(legacyToken) ? await client.auth.exchangeCodeForSession(legacyToken)
: await client.auth.verifyOtp({ : await client.auth.verifyOtp({
token_hash: tokenHash!, token_hash: tokenHash!,
type: type as "email" | "recovery" | "magiclink", type: type as "email" | "signup" | "recovery",
}); });
// Older email templates could place a recovery token hash in /code=... // Older email templates could place a recovery token hash in /code=...
// instead of a PKCE code. Only Supabase can decide whether it is valid. // instead of a PKCE code. Only Supabase can decide whether it is valid.

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
import "./globals.css"; import "./globals.css";
import "./block-theme.css"; import "./block-theme.css";
import "./account.css"; import "./account.css";
@@ -12,8 +13,12 @@ import { SiteCursor } from "@/components/SiteCursor";
import { I18nProvider } from "@/components/I18nProvider"; import { I18nProvider } from "@/components/I18nProvider";
import { getI18n } from "@/lib/i18n"; import { getI18n } from "@/lib/i18n";
import "./polish.css"; import "./polish.css";
import "./mobile-game.css";
import "./multiplayer.css";
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider"; import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
import { initialSiteSettings } from "@/lib/site-settings"; import { initialSiteSettings } from "@/lib/site-settings";
import { user } from "@/lib/supabase";
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const { t } = await getI18n(); const { t } = await getI18n();
return { return {
@@ -31,10 +36,8 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [{ locale, preference, detectedLocale }, settings] = await Promise.all([ const [{ locale, preference, detectedLocale }, settings, account] =
getI18n(), await Promise.all([getI18n(), initialSiteSettings(), user()]);
initialSiteSettings(),
]);
return ( return (
<html lang={locale}> <html lang={locale}>
<body> <body>
@@ -46,8 +49,10 @@ export default async function RootLayout({
<SiteSettingsProvider initialSettings={settings}> <SiteSettingsProvider initialSettings={settings}>
{children} {children}
<SiteCursor /> <SiteCursor />
<LegacyPasswordNotice enabled={!!account?.email} />
</SiteSettingsProvider> </SiteSettingsProvider>
</I18nProvider> </I18nProvider>
<Analytics />
</body> </body>
</html> </html>
); );

192
app/mobile-game.css Normal file
View File

@@ -0,0 +1,192 @@
.mobile-map-toggle,
.report-icon {
display: none;
}
@media (max-width: 700px) {
.game-page {
min-height: 0;
}
.game-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
gap: 8px;
padding: max(10px, env(safe-area-inset-top))
max(10px, env(safe-area-inset-right)) 12px
max(10px, env(safe-area-inset-left));
}
.game-header .brand {
font-size: 17px;
white-space: nowrap;
gap: 6px;
}
.game-header .brand-cube {
flex-shrink: 0;
}
.game-header .report-trigger {
width: 44px;
height: 44px;
padding: 0;
}
.game-header .report-trigger > span {
display: none;
}
.game-header .report-icon {
display: block;
}
.game-info {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
width: 100%;
gap: 8px;
padding: 8px;
border-radius: 10px;
}
.game-info > div {
min-width: 0;
}
.game-info small {
white-space: nowrap;
font-size: 8px;
letter-spacing: 0;
}
.game-info strong {
white-space: nowrap;
font-size: 17px;
line-height: 1.4;
}
.panorama-controls {
top: calc(max(10px, env(safe-area-inset-top)) + 112px);
left: max(10px, env(safe-area-inset-left));
gap: 6px;
}
.panorama-controls button {
width: 44px;
height: 44px;
padding: 0;
}
.game-page .game-map,
.game-page .game-map.expanded {
right: max(8px, env(safe-area-inset-right));
left: max(8px, env(safe-area-inset-left));
bottom: max(10px, env(safe-area-inset-bottom));
width: auto;
max-width: none;
box-shadow: 0 8px 24px #0006;
}
.game-map:not(.expanded) .game-map-content {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-map-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 56px;
padding: 12px;
border: 0;
color: #fff1d0;
background: #241e18;
font: 14px var(--font-pixel, monospace);
}
.mobile-map-toggle small {
font: 11px monospace;
}
.game-map.expanded .mobile-map-toggle {
display: none;
}
.game-map .map-topline {
min-height: 44px;
padding: 0 8px 0 12px;
}
.game-map .map-topline button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
}
.game-page .game-map .map-surface {
height: min(46dvh, 360px);
border-width: 12px;
border-image-width: 12px;
}
.game-map .map-actions {
padding: 8px 10px 10px;
}
.game-map .map-actions .button {
min-height: 48px;
padding: 10px 12px;
font-size: 13px;
white-space: nowrap;
}
.game-map .map-actions small {
margin-bottom: 8px;
}
.game-map .leaflet-bar a {
width: 40px;
height: 40px;
line-height: 40px;
}
.look-help {
display: none;
}
.round-result {
max-width: calc(100% - 24px);
max-height: calc(100dvh - 140px);
overflow-y: auto;
padding: 20px;
}
html[lang] .round-result h2 {
font-size: 21px;
}
.round-result .score {
font-size: 42px;
}
}
@media (max-height: 500px) and (max-width: 950px) {
.game-page {
min-height: 0;
}
.game-header {
display: flex;
padding: 8px 12px;
gap: 10px;
}
.game-header .brand {
font-size: 16px;
}
.game-header .report-trigger {
order: 3;
}
.game-info {
width: auto;
flex: 1;
max-width: 300px;
margin-left: auto;
gap: 16px;
padding: 6px 10px;
}
.panorama-controls {
top: 72px;
left: 12px;
}
.game-page .game-map,
.game-page .game-map.expanded {
left: auto;
width: min(440px, calc(100vw - 80px));
right: 8px;
bottom: max(8px, env(safe-area-inset-bottom));
}
.game-page .game-map .map-surface {
height: max(100px, calc(100dvh - 190px));
}
.round-result {
max-height: calc(100dvh - 80px);
}
}

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

@@ -134,7 +134,7 @@ export function AccountSettings({
)} )}
<PasswordForm /> <PasswordForm />
<div className="settings-recovery"> <div className="settings-recovery">
<h3>{t("Signed up with an email link?")}</h3> <h3>{t("Forgot your password?")}</h3>
<p className="muted"> <p className="muted">
{t( {t(
"Set your first password, or recover a forgotten one, using a link sent to your account email.", "Set your first password, or recover a forgotten one, using a link sent to your account email.",

View File

@@ -9,6 +9,7 @@ import {
MousePointer2, MousePointer2,
Move, Move,
MapPin, MapPin,
Map as MapIcon,
} from "lucide-react"; } from "lucide-react";
import { Brand } from "./Brand"; import { Brand } from "./Brand";
import { Panorama } from "./Panorama"; import { Panorama } from "./Panorama";
@@ -174,6 +175,7 @@ export function Game({ id }: { id: string }) {
setSession(null); setSession(null);
setResult(null); setResult(null);
setPin(null); setPin(null);
setExpanded(false);
setLoaded({ panorama: false, map: false }); setLoaded({ panorama: false, map: false });
timingOut.current = false; timingOut.current = false;
setError(""); setError("");
@@ -228,40 +230,56 @@ export function Game({ id }: { id: string }) {
</div> </div>
{session && ( {session && (
<div className={"game-map " + (expanded ? "expanded" : "")}> <div className={"game-map " + (expanded ? "expanded" : "")}>
<div className="map-topline"> <button
<span>{t("↑ N · PLACE YOUR GUESS")}</span> className="mobile-map-toggle"
<button aria-expanded={expanded}
aria-label={expanded ? t("Collapse map") : t("Expand map")} aria-controls="guess-map-panel"
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded(true)}
> >
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />} <MapIcon size={20} aria-hidden="true" />
</button> <span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
</div> {pin && (
<GuessMap <small>
key={"map-" + session.round} X {Math.round(pin.x)} · Z {Math.round(pin.z)}
bounds={session.bounds} </small>
image={session.map} )}
pin={result ? result.guess : pin} </button>
actual={result?.actual} <div className="game-map-content" id="guess-map-panel">
onPin={setPin} <div className="map-topline">
onReady={mapReady} <span>{t("↑ N · PLACE YOUR GUESS")}</span>
onError={onError} <button
disabled={!!result || busy} aria-label={expanded ? t("Collapse map") : t("Expand map")}
/> onClick={() => setExpanded((v) => !v)}
<div className="map-actions"> >
<small> {expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
{pin </button>
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}` </div>
: t("Click the map to drop a pin")} <GuessMap
</small> key={"map-" + session.round}
<button bounds={session.bounds}
className="button primary full" image={session.map}
disabled={!pin || busy || !!result || !current?.deadline} pin={result ? result.guess : pin}
onClick={() => guess()} actual={result?.actual}
> onPin={setPin}
<MapPin size={15} /> onReady={mapReady}
{busy ? t("Checking your guess…") : t("Submit guess")} onError={onError}
</button> disabled={!!result || busy}
/>
<div className="map-actions">
<small>
{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 || !!result || !current?.deadline}
onClick={() => guess()}
>
<MapPin size={15} />
{busy ? t("Checking your guess…") : t("Submit guess")}
</button>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -59,7 +59,11 @@ export function GuessMap({
zoomSnap: 0, zoomSnap: 0,
maxBounds: b.pad(0.15), maxBounds: b.pad(0.15),
attributionControl: false, attributionControl: false,
zoomControl: false,
}); });
L.control
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
.addTo(m);
// Install cleanup immediately, including when later initialization fails. // Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined; let observer: ResizeObserver | undefined;
cleanup = () => { cleanup = () => {

View File

@@ -0,0 +1,147 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { Copy, KeyRound } from "lucide-react";
import { useI18n } from "./I18nProvider";
import { api } from "@/lib/client-api";
import { generateInitialPassword } from "@/shared/initial-password";
export function LegacyPasswordNotice({ enabled }: { enabled: boolean }) {
const { t } = useI18n();
const pathname = usePathname();
const dialog = useRef<HTMLDialogElement>(null);
const [needed, setNeeded] = useState(false);
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!enabled || pathname?.startsWith("/auth/") || password) return;
let active = true;
api<{ needed: boolean }>("auth/initial-password")
.then((data) => {
if (active) setNeeded(data.needed);
})
.catch(() => {});
return () => {
active = false;
};
}, [enabled, pathname, password]);
useEffect(() => {
if (!needed) return;
const element = dialog.current;
element?.showModal();
return () => {
element?.close();
};
}, [needed]);
async function generate() {
if (busy) return;
setBusy(true);
setError("");
const generated = generateInitialPassword();
try {
await api("auth/initial-password", { password: generated });
// Only keep the confirmed password in this mounted dialog's memory.
setPassword(generated);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function dismiss() {
setNeeded(false);
setPassword("");
setSaved(false);
setCopied(false);
setError("");
}
async function copy() {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
} catch {
setError("Select the password and copy it manually.");
}
}
if (!needed) return null;
return (
<dialog
ref={dialog}
className="report-dialog legacy-password-dialog"
aria-labelledby="legacy-password-title"
onCancel={(event) => {
if (password || busy) event.preventDefault();
else dismiss();
}}
>
<KeyRound size={28} aria-hidden="true" />
<h2 id="legacy-password-title">
{password
? t("Your one-time password screen.")
: t("Your account needs a password.")}
</h2>
<p>
{password
? t(
"Save this password now. After you close this screen, it cannot be shown again. You can change it in account settings.",
)
: t(
"You joined before passwords were required. Create a strong password for email-and-password login.",
)}
</p>
{password ? (
<>
<label className="field">
{t("Your new account password")}
<input
readOnly
value={password}
autoComplete="off"
spellCheck={false}
/>
</label>
<button className="button" onClick={copy}>
<Copy size={16} aria-hidden="true" />
{copied ? t("Copied") : t("Copy password")}
</button>
<label className="password-saved">
<input
type="checkbox"
checked={saved}
onChange={(e) => setSaved(e.target.checked)}
/>
{t("I saved this password somewhere safe.")}
</label>
<button
className="button primary full"
disabled={!saved}
onClick={dismiss}
>
{t("Done — I saved it")}
</button>
</>
) : (
<>
<button
className="button primary full"
disabled={busy}
onClick={generate}
>
{busy ? t("Creating password…") : t("Create and show my password")}
</button>
<button className="text-link" disabled={busy} onClick={dismiss}>
{t("Not now")}
</button>
</>
)}
{error && (
<p role="alert" className="error-message">
{t(error)}
</p>
)}
</dialog>
);
}

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

@@ -39,21 +39,21 @@ export function Panorama({
const element = mount.current!; const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 }; view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => { const down = (e: PointerEvent) => {
if (e.button !== 0) return; if (e.button !== 0 || drag) return;
element.setPointerCapture(e.pointerId); element.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY }; drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
}, },
move = (e: PointerEvent) => { move = (e: PointerEvent) => {
if (!drag) return; if (!drag || drag.pointerId !== e.pointerId) return;
view.current.yaw -= (e.clientX - drag.x) * 0.003; view.current.yaw -= (e.clientX - drag.x) * 0.003;
view.current.pitch = Math.max( view.current.pitch = Math.max(
-1.5, -1.5,
Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003), Math.min(1.5, view.current.pitch + (e.clientY - drag.y) * 0.003),
); );
drag = { x: e.clientX, y: e.clientY }; drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
}, },
up = () => { up = (e: PointerEvent) => {
drag = null; if (drag?.pointerId === e.pointerId) drag = null;
}, },
wheel = (e: WheelEvent) => { wheel = (e: WheelEvent) => {
if (!zoom) return; if (!zoom) return;
@@ -70,7 +70,7 @@ export function Panorama({
"The panorama graphics context was lost. Reconnect to restore the view.", "The panorama graphics context was lost. Reconnect to restore the view.",
); );
}; };
let drag: { x: number; y: number } | null = null; let drag: { x: number; y: number; pointerId: number } | null = null;
if (urls.length !== 6) { if (urls.length !== 6) {
callbacks.current.onError( callbacks.current.onError(
"This panorama is missing one or more of its six faces.", "This panorama is missing one or more of its six faces.",
@@ -133,6 +133,7 @@ export function Panorama({
element.addEventListener("pointermove", move); element.addEventListener("pointermove", move);
element.addEventListener("pointerup", up); element.addEventListener("pointerup", up);
element.addEventListener("pointercancel", up); element.addEventListener("pointercancel", up);
element.addEventListener("lostpointercapture", up);
element.addEventListener("wheel", wheel, { passive: false }); element.addEventListener("wheel", wheel, { passive: false });
callbacks.current.onReady(); callbacks.current.onReady();
}) })
@@ -150,6 +151,7 @@ export function Panorama({
element.removeEventListener("pointermove", move); element.removeEventListener("pointermove", move);
element.removeEventListener("pointerup", up); element.removeEventListener("pointerup", up);
element.removeEventListener("pointercancel", up); element.removeEventListener("pointercancel", up);
element.removeEventListener("lostpointercapture", up);
element.removeEventListener("wheel", wheel); element.removeEventListener("wheel", wheel);
geometry?.dispose(); geometry?.dispose();
material?.dispose(); material?.dispose();

View File

@@ -4,6 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { reportCategories } from "@/shared/moderation"; import { reportCategories } from "@/shared/moderation";
import { Flag } from "lucide-react";
export function ReportContent({ export function ReportContent({
datasetId, datasetId,
@@ -66,14 +67,16 @@ export function ReportContent({
return ( return (
<> <>
<button <button
className="button" className="button report-trigger"
aria-label={t("Report content")}
onClick={() => { onClick={() => {
setOpen(true); setOpen(true);
setError(""); setError("");
setMessage(""); setMessage("");
}} }}
> >
{t("Report content")} <Flag className="report-icon" size={18} aria-hidden="true" />
<span>{t("Report content")}</span>
</button> </button>
{message && ( {message && (
<p role="status" className="success-message"> <p role="status" className="success-message">

View File

@@ -7,7 +7,7 @@ import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
import { api } from "./StartGame"; import { api } from "./StartGame";
import { authActionSchema, authDestination } from "@/shared/account"; import { authActionSchema, authDestination } from "@/shared/account";
type Mode = "login" | "signup" | "recover" | "magic"; type Mode = "login" | "signup" | "recover";
export function SignIn({ export function SignIn({
next = "/dashboard", next = "/dashboard",
initialError = "", initialError = "",
@@ -41,9 +41,7 @@ export function SignIn({
? { action: mode, email, password, displayName: name, next } ? { action: mode, email, password, displayName: name, next }
: mode === "login" : mode === "login"
? { action: mode, email, password, next } ? { action: mode, email, password, next }
: mode === "magic" : { action: mode, email };
? { action: mode, email, next }
: { action: mode, email };
const valid = authActionSchema.safeParse(body); const valid = authActionSchema.safeParse(body);
if (!valid.success) { if (!valid.success) {
setError(valid.error.issues[0].message); setError(valid.error.issues[0].message);
@@ -75,7 +73,6 @@ export function SignIn({
login: "Welcome back.", login: "Welcome back.",
signup: "Make yourself at home.", signup: "Make yourself at home.",
recover: "Find your way back.", recover: "Find your way back.",
magic: "An email, a way in.",
}[mode]; }[mode];
return ( return (
<div className="auth-layout"> <div className="auth-layout">
@@ -132,11 +129,7 @@ export function SignIn({
? t("Choose the name people will see on your maps.") ? t("Choose the name people will see on your maps.")
: mode === "recover" : mode === "recover"
? t("We’ll email you a link to set a new password.") ? t("We’ll email you a link to set a new password.")
: mode === "magic" : t("Log in with your email and password.")}
? t(
"For existing accounts. Open the link on this device to continue.",
)
: t("Log in with your email and password.")}
</p> </p>
<form onSubmit={submit} aria-busy={busy}> <form onSubmit={submit} aria-busy={busy}>
{mode === "signup" && ( {mode === "signup" && (
@@ -230,7 +223,7 @@ export function SignIn({
</p> </p>
)} )}
<button className="button primary full" disabled={busy}> <button className="button primary full" disabled={busy}>
{mode === "recover" || mode === "magic" ? ( {mode === "recover" ? (
<Mail size={17} /> <Mail size={17} />
) : ( ) : (
<ArrowRight size={17} /> <ArrowRight size={17} />
@@ -241,24 +234,10 @@ export function SignIn({
login: t("Log in"), login: t("Log in"),
signup: t("Create account"), signup: t("Create account"),
recover: t("Send reset link"), recover: t("Send reset link"),
magic: t("Send sign-in link"),
}[mode]} }[mode]}
</button> </button>
</form> </form>
{mode === "login" && ( {mode === "recover" && (
<div className="auth-alternative">
<span>{t("Already use email links?")}</span>
<button
type="button"
className="text-link"
disabled={busy}
onClick={() => changeMode("magic")}
>
{t("Send me a sign-in link")}
</button>
</div>
)}
{(mode === "recover" || mode === "magic") && (
<button <button
type="button" type="button"
className="text-link auth-back" className="text-link auth-back"

View File

@@ -3,13 +3,16 @@ import { useI18n } from "@/components/I18nProvider";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; 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 { difficulties, type Difficulty } from "@/shared/protocol";
import { api } from "@/lib/client-api"; import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api"; export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) { export function StartGame({ datasetId }: { datasetId: string }) {
const { t } = useI18n(); const { t } = useI18n();
const [difficulty, setDifficulty] = useState<Difficulty>("normal"), const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[displayName, setDisplayName] = useState(""),
[playerLimit, setPlayerLimit] = useState(4),
[loading, setLoading] = useState(false), [loading, setLoading] = useState(false),
[error, setError] = useState(""); [error, setError] = useState("");
const router = useRouter(); const router = useRouter();
@@ -17,11 +20,15 @@ export function StartGame({ datasetId }: { datasetId: string }) {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const session = await api<{ id: string }>("sessions", { const session = await api<{ id: string }>(
datasetId, mode === "solo" ? "sessions" : "rooms",
difficulty, {
}); datasetId,
router.push("/play/" + session.id); difficulty,
...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
},
);
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
setLoading(false); setLoading(false);
@@ -31,6 +38,24 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<div className="start-panel"> <div className="start-panel">
<span className="eyebrow">{t("YOUR EXPEDITION")}</span> <span className="eyebrow">{t("YOUR EXPEDITION")}</span>
<h2>{t("Choose your challenge.")}</h2> <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"> <div className="difficulty-options">
{Object.entries(difficulties).map(([key, value]) => ( {Object.entries(difficulties).map(([key, value]) => (
<button <button
@@ -55,12 +80,53 @@ export function StartGame({ datasetId }: { datasetId: string }) {
<p className="muted"> <p className="muted">
{t("5 rounds · 25,000 possible points · No movement")} {t("5 rounds · 25,000 possible points · No movement")}
</p> </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 <button
className="button primary full" className="button primary full"
onClick={start} 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} /> <ArrowRight size={17} />
</button> </button>
{error && ( {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 -- --preview`: create a preview deployment.
- `npm run deploy -- --help`: show usage. - `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 The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
`VERCEL_ORG_ID` to deploy a copy to another Vercel project. `VERCEL_ORG_ID` to deploy a copy to another Vercel project.

View File

@@ -8,10 +8,12 @@ import {
authDestination, authDestination,
passwordUpdateSchema, passwordUpdateSchema,
profileUpdateSchema, profileUpdateSchema,
initialPasswordSchema,
} from "@/shared/account"; } from "@/shared/account";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery"; import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
import { requestOrigin } from "./request-origin"; import { requestOrigin } from "./request-origin";
import { issueInitialPassword } from "@/shared/initial-password";
type Context = { type Context = {
jsonBody: (r: NextRequest) => Promise<unknown>; jsonBody: (r: NextRequest) => Promise<unknown>;
@@ -24,6 +26,40 @@ export async function accountApi(
path: string[], path: string[],
context: Context, context: Context,
) { ) {
if (
path[0] === "auth" &&
path[1] === "initial-password" &&
path.length === 2
) {
const account = await user();
if (!account?.email || !account.email_confirmed_at)
return json({ error: "Log in to manage your account" }, 401);
if (request.method === "GET") {
return json({
needed: required(
await admin().rpc("legacy_password_needed", { p_user: account.id }),
),
});
}
if (request.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const body = initialPasswordSchema.parse(await context.jsonBody(request));
await context.limit(`initial-password:${account.id}`, 3, 600);
await issueInitialPassword(account.id, body.password, {
claim: async (id) =>
required(await admin().rpc("claim_legacy_password", { p_user: id })),
update: async (id, password) => {
const { error } = await admin().auth.admin.updateUserById(id, {
password,
});
if (error)
throw new Error(
"Password setup could not be confirmed. Use password recovery before signing out.",
);
},
});
return json({ ok: true });
}
if (path[0] === "profile" && path.length === 1) { if (path[0] === "profile" && path.length === 1) {
const account = await user(); const account = await user();
if (!account) return json({ error: "Log in to manage your account" }, 401); if (!account) return json({ error: "Log in to manage your account" }, 401);
@@ -63,6 +99,10 @@ export async function accountApi(
const origin = requestOrigin(request, process.env.MINEGEO_URL); const origin = requestOrigin(request, process.env.MINEGEO_URL);
const next = authDestination("next" in body ? body.next : null); const next = authDestination("next" in body ? body.next : null);
const callback = new URL("/auth/callback", origin); const callback = new URL("/auth/callback", origin);
callback.searchParams.set(
"type",
body.action === "recover" ? "recovery" : "signup",
);
callback.searchParams.set( callback.searchParams.set(
"next", "next",
body.action === "recover" body.action === "recover"
@@ -107,18 +147,9 @@ export async function accountApi(
"Check your email to confirm your account. If you already have an account, log in or reset your password.", "Check your email to confirm your account. If you already have an account, log in or reset your password.",
}); });
} }
const { error } = const { error } = await client.auth.resetPasswordForEmail(body.email, {
body.action === "recover" redirectTo: callback.toString(),
? await client.auth.resetPasswordForEmail(body.email, { });
redirectTo: callback.toString(),
})
: await client.auth.signInWithOtp({
email: body.email,
options: {
shouldCreateUser: false,
emailRedirectTo: callback.toString(),
},
});
if ( if (
error && error &&
!["user_not_found", "signup_disabled"].includes(error.code ?? "") !["user_not_found", "signup_disabled"].includes(error.code ?? "")
@@ -126,9 +157,7 @@ export async function accountApi(
throw new Error(error.message); throw new Error(error.message);
return json({ return json({
message: message:
body.action === "recover" "If an account exists for this email, a password reset link is on its way. Check your inbox.",
? "If an account exists for this email, a password reset link is on its way. Check your inbox."
: "If an account exists for this email, a sign-in link is on its way. Check your inbox.",
}); });
} }
if (path[1] !== "password") return json({ error: "Not found" }, 404); if (path[1] !== "password") return json({ error: "Not found" }, 404);

View File

@@ -15,6 +15,7 @@ import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error"; import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin"; import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api"; import { accountApi } from "./account-api";
import { multiplayerApi } from "./multiplayer-api";
import { contributionCutoff, reportSchema } from "../shared/moderation"; import { contributionCutoff, reportSchema } from "../shared/moderation";
import { siteSettingsSchema } from "../shared/site-settings"; import { siteSettingsSchema } from "../shared/site-settings";
import { getSiteSettings } from "./site-settings"; import { getSiteSettings } from "./site-settings";
@@ -109,6 +110,8 @@ export async function handle(request: NextRequest, path: string[]) {
verifyOrigin(request); verifyOrigin(request);
if (path[0] === "auth" || path[0] === "profile") if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit }); return await accountApi(request, path, { jsonBody, limit });
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
const client = admin(), const client = admin(),
method = request.method; method = request.method;
if (path[0] === "site-settings" && path.length === 1 && method === "GET") { 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": { "dependencies": {
"@supabase/ssr": "0.7.0", "@supabase/ssr": "0.7.0",
"@supabase/supabase-js": "2.117.2", "@supabase/supabase-js": "2.117.2",
"@vercel/analytics": "2.0.1",
"fflate": "0.8.3", "fflate": "0.8.3",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"lucide-react": "0.468.0", "lucide-react": "0.468.0",
@@ -1793,6 +1794,48 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@webgpu/types": {
"version": "0.1.74", "version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",

View File

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

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
create schema auth;
create function auth.uid() returns uuid language sql as $$ select nullif(current_setting('test.uid',true),'')::uuid $$;
create table auth.users(id uuid primary key,encrypted_password text,email text,email_confirmed_at timestamptz,is_anonymous boolean);
insert into auth.users values
('00000000-0000-4000-8000-000000000001','', 'legacy@example.com',now(),false),
('00000000-0000-4000-8000-000000000002','existing-hash','password@example.com',now(),false),
('00000000-0000-4000-8000-000000000003',null,'unverified@example.com',null,false),
('00000000-0000-4000-8000-000000000004',null,null,null,true);`);
await db.exec(
await readFile(
"supabase/migrations/20261007055439_legacy_password_claims.sql",
"utf8",
),
);
for (const role of ["anon", "authenticated"]) {
await db.exec(`set role ${role}`);
await assert.rejects(
db.query(
"select public.claim_legacy_password('00000000-0000-4000-8000-000000000001')",
),
/permission denied/,
);
await assert.rejects(
db.query(
"select public.legacy_password_needed('00000000-0000-4000-8000-000000000001')",
),
/permission denied/,
);
await db.exec("reset role");
}
await db.exec("set role service_role");
const call = async (fn, ending) =>
(
await db.query(
`select public.${fn}('00000000-0000-4000-8000-00000000000${ending}') as result`,
)
).rows[0].result;
assert.equal(await call("legacy_password_needed", 1), true);
for (const ending of [2, 3, 4, 5]) {
assert.equal(await call("legacy_password_needed", ending), false);
assert.equal(await call("claim_legacy_password", ending), false);
}
await db.exec(
"select set_config('test.uid','00000000-0000-4000-8000-000000000002',false)",
);
assert.equal(await call("claim_legacy_password", 1), false);
await db.exec("select set_config('test.uid','',false)");
assert.equal(await call("claim_legacy_password", 1), true);
assert.equal(await call("claim_legacy_password", 1), false);
assert.equal(await call("legacy_password_needed", 1), false);
const columns = (
await db.query(
"select column_name from information_schema.columns where table_schema='minegeo_private' and table_name='legacy_password_claims'",
)
).rows.map((r) => r.column_name);
assert.deepEqual(columns.sort(), ["claimed_at", "user_id"]);
console.log(
"Initial password SQL passed: verified legacy owners eligible once; existing passwords protected; anonymous, unverified and cross-account attempts denied; no password stored.",
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
await db.close();
}

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

@@ -36,13 +36,13 @@ export const authActionSchema = z.discriminatedUnion("action", [
}) })
.strict(), .strict(),
z.object({ action: z.literal("recover"), email }).strict(), z.object({ action: z.literal("recover"), email }).strict(),
z
.object({ action: z.literal("magic"), email, next: z.string().optional() })
.strict(),
]); ]);
export const profileUpdateSchema = z export const profileUpdateSchema = z
.object({ displayName: displayNameSchema }) .object({ displayName: displayNameSchema })
.strict(); .strict();
export const initialPasswordSchema = z
.object({ password: newPasswordSchema })
.strict();
export const passwordUpdateSchema = z export const passwordUpdateSchema = z
.object({ .object({
password: newPasswordSchema, password: newPasswordSchema,

View File

@@ -43,6 +43,93 @@ export type Database = {
}; };
public: { public: {
Tables: { 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: { site_settings: {
Row: { Row: {
id: boolean; id: boolean;
@@ -540,6 +627,27 @@ export type Database = {
[_ in never]: never; [_ in never]: never;
}; };
Functions: { 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: { set_beta_badge: {
Args: { p_reviewer: string; p_enabled: boolean }; Args: { p_reviewer: string; p_enabled: boolean };
Returns: boolean; Returns: boolean;

View File

@@ -0,0 +1,28 @@
import { initialPasswordSchema } from "./account";
export function generateInitialPassword() {
const bytes = crypto.getRandomValues(new Uint8Array(24));
// Every byte contributes eight random bits, encoded as two hex characters.
return (
"Aa7!" +
Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
);
}
export async function issueInitialPassword(
userId: string,
password: string,
operations: {
claim: (id: string) => Promise<boolean>;
update: (id: string, password: string) => Promise<void>;
},
) {
initialPasswordSchema.parse({ password });
if (!(await operations.claim(userId)))
throw new Error(
"A password is already set or was already issued. Use password recovery if needed.",
);
// A claim is never released after an uncertain Auth response: the password
// may already have changed, so a retry must not overwrite it with a new one.
await operations.update(userId, password);
}

View File

@@ -587,5 +587,82 @@
"Hidden for all players": "Скрыт для всех игроков", "Hidden for all players": "Скрыт для всех игроков",
"Hide beta badge": "Скрыть значок BETA", "Hide beta badge": "Скрыть значок BETA",
"Show beta badge": "Показать значок BETA", "Show beta badge": "Показать значок BETA",
"Setting saved. Open pages update within 30 seconds.": "Настройка сохранена. Открытые страницы обновятся в течение 30 секунд." "Setting saved. Open pages update within 30 seconds.": "Настройка сохранена. Открытые страницы обновятся в течение 30 секунд.",
"Forgot your password?": "Забыл пароль?",
"Open guess map": "Открыть карту",
"Edit your guess": "Изменить метку",
"Your one-time password screen.": "Сохрани свой пароль.",
"Your account needs a password.": "Для аккаунта нужен пароль.",
"Save this password now. After you close this screen, it cannot be shown again. You can change it in account settings.": "Сохрани пароль сейчас. После закрытия этого окна его нельзя будет показать снова. Сменить пароль можно в настройках аккаунта.",
"You joined before passwords were required. Create a strong password for email-and-password login.": "Ты зарегистрировался, когда пароль ещё не требовался. Создай надёжный пароль для входа по почте и паролю.",
"Your new account password": "Твой новый пароль",
"Copy password": "Скопировать пароль",
"Copied": "Скопировано",
"I saved this password somewhere safe.": "Я сохранил пароль в надёжном месте.",
"Done — I saved it": "Готово — пароль сохранён",
"Creating password…": "Создаём пароль…",
"Create and show my password": "Создать и показать пароль",
"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.": "Не удалось подтвердить установку пароля. Восстанови пароль перед выходом из аккаунта.",
"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

@@ -1 +1 @@
v2.119.0 v2.120.0

View File

@@ -0,0 +1,50 @@
create schema if not exists minegeo_private;
revoke all on schema minegeo_private from public, anon, authenticated;
grant usage on schema minegeo_private to service_role;
create table minegeo_private.legacy_password_claims (
user_id uuid primary key references auth.users(id) on delete cascade,
claimed_at timestamptz not null default now()
);
alter table minegeo_private.legacy_password_claims enable row level security;
revoke all on minegeo_private.legacy_password_claims from public, anon, authenticated;
grant select, insert on minegeo_private.legacy_password_claims to service_role;
create policy "Server reads password claims" on minegeo_private.legacy_password_claims
for select to service_role using (true);
create policy "Server creates password claims" on minegeo_private.legacy_password_claims
for insert to service_role with check (true);
-- This private helper reads only password eligibility, never returns a hash,
-- and never stores a plaintext password. Only the trusted server can call it.
create function minegeo_private.legacy_password(p_user uuid, p_claim boolean)
returns boolean language plpgsql security definer set search_path = '' as $$
declare eligible boolean; claimed uuid;
begin
if auth.uid() is not null and auth.uid() <> p_user then return false; end if;
select coalesce(encrypted_password, '') = '' and email is not null
and email_confirmed_at is not null and not coalesce(is_anonymous, false)
into eligible from auth.users where id = p_user for update;
if not coalesce(eligible, false) then return false; end if;
if not p_claim then
return not exists(select 1 from minegeo_private.legacy_password_claims where user_id = p_user);
end if;
insert into minegeo_private.legacy_password_claims(user_id) values(p_user)
on conflict(user_id) do nothing returning user_id into claimed;
return claimed is not null;
end;
$$;
revoke all on function minegeo_private.legacy_password(uuid,boolean) from public, anon, authenticated;
grant execute on function minegeo_private.legacy_password(uuid,boolean) to service_role;
create function public.legacy_password_needed(p_user uuid)
returns boolean language sql security invoker set search_path = '' as $$
select minegeo_private.legacy_password(p_user, false);
$$;
create function public.claim_legacy_password(p_user uuid)
returns boolean language sql security invoker set search_path = '' as $$
select minegeo_private.legacy_password(p_user, true);
$$;
revoke all on function public.legacy_password_needed(uuid) from public, anon, authenticated;
revoke all on function public.claim_legacy_password(uuid) from public, anon, authenticated;
grant execute on function public.legacy_password_needed(uuid) to service_role;
grant execute on function public.claim_legacy_password(uuid) to service_role;

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;

View File

@@ -0,0 +1,112 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
authActionSchema,
initialPasswordSchema,
newPasswordSchema,
} from "../shared/account";
import {
generateInitialPassword,
issueInitialPassword,
} from "../shared/initial-password";
test("email-link sign-in is rejected, password login and recovery remain supported", () => {
assert.equal(
authActionSchema.safeParse({ action: "magic", email: "owner@example.com" })
.success,
false,
);
assert.equal(
authActionSchema.safeParse({
action: "recover",
email: "owner@example.com",
}).success,
true,
);
assert.equal(
authActionSchema.safeParse({
action: "login",
email: "owner@example.com",
password: "existing-password",
}).success,
true,
);
});
test("generated first passwords are strong, unique and cannot target another account", () => {
const passwords = new Set(
Array.from({ length: 100 }, generateInitialPassword),
);
assert.equal(passwords.size, 100);
for (const password of passwords) {
assert.equal(newPasswordSchema.safeParse(password).success, true);
assert.ok(
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/\d/.test(password) &&
/[!]/.test(password),
);
}
assert.equal(
initialPasswordSchema.safeParse({
password: generateInitialPassword(),
userId: "someone-else",
}).success,
false,
);
});
test("first password issuance cannot overwrite a password or replay an uncertain Auth update", async () => {
let claimed = false,
updates = 0;
const operations = {
claim: async () => {
if (claimed) return false;
claimed = true;
return true;
},
update: async (id: string) => {
assert.equal(id, "verified-owner");
updates++;
},
};
await issueInitialPassword(
"verified-owner",
generateInitialPassword(),
operations,
);
await assert.rejects(
issueInitialPassword(
"verified-owner",
generateInitialPassword(),
operations,
),
/already/,
);
assert.equal(updates, 1);
let uncertainClaimed = false;
const uncertain = {
claim: async () => {
if (uncertainClaimed) return false;
uncertainClaimed = true;
return true;
},
update: async () => {
throw new Error("network interrupted");
},
};
await assert.rejects(
issueInitialPassword(
"verified-owner",
generateInitialPassword(),
uncertain,
),
/network interrupted/,
);
await assert.rejects(
issueInitialPassword(
"verified-owner",
generateInitialPassword(),
uncertain,
),
/already/,
);
});

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

File diff suppressed because one or more lines are too long