Compare commits
2 Commits
233e4d57f0
...
48b34db6f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 48b34db6f1 | |||
| ce26e6fdbc |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -284,3 +284,10 @@
|
||||
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); }
|
||||
|
||||
@@ -11,11 +11,13 @@ export async function GET(request: NextRequest) {
|
||||
const legacyToken = request.nextUrl.searchParams.get("legacy_recovery_token");
|
||||
const type = request.nextUrl.searchParams.get("type");
|
||||
const next = authDestination(request.nextUrl.searchParams.get("next"));
|
||||
if (type === "magiclink")
|
||||
return NextResponse.redirect(new URL("/log-in?auth=failed", origin));
|
||||
if (
|
||||
code ||
|
||||
legacyToken ||
|
||||
(tokenHash &&
|
||||
(type === "email" || type === "recovery" || type === "magiclink"))
|
||||
(type === "email" || type === "signup" || type === "recovery"))
|
||||
) {
|
||||
const client = await authClient();
|
||||
let { data, error } = code
|
||||
@@ -24,7 +26,7 @@ export async function GET(request: NextRequest) {
|
||||
? await client.auth.exchangeCodeForSession(legacyToken)
|
||||
: await client.auth.verifyOtp({
|
||||
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=...
|
||||
// instead of a PKCE code. Only Supabase can decide whether it is valid.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import "./globals.css";
|
||||
import "./block-theme.css";
|
||||
import "./account.css";
|
||||
@@ -12,8 +13,12 @@ import { SiteCursor } from "@/components/SiteCursor";
|
||||
import { I18nProvider } from "@/components/I18nProvider";
|
||||
import { getI18n } from "@/lib/i18n";
|
||||
import "./polish.css";
|
||||
import "./mobile-game.css";
|
||||
import "./multiplayer.css";
|
||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||
import { initialSiteSettings } from "@/lib/site-settings";
|
||||
import { user } from "@/lib/supabase";
|
||||
import { LegacyPasswordNotice } from "@/components/LegacyPasswordNotice";
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { t } = await getI18n();
|
||||
return {
|
||||
@@ -31,10 +36,8 @@ export default async function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [{ locale, preference, detectedLocale }, settings] = await Promise.all([
|
||||
getI18n(),
|
||||
initialSiteSettings(),
|
||||
]);
|
||||
const [{ locale, preference, detectedLocale }, settings, account] =
|
||||
await Promise.all([getI18n(), initialSiteSettings(), user()]);
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
@@ -46,8 +49,10 @@ export default async function RootLayout({
|
||||
<SiteSettingsProvider initialSettings={settings}>
|
||||
{children}
|
||||
<SiteCursor />
|
||||
<LegacyPasswordNotice enabled={!!account?.email} />
|
||||
</SiteSettingsProvider>
|
||||
</I18nProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
192
app/mobile-game.css
Normal file
192
app/mobile-game.css
Normal 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
217
app/multiplayer.css
Normal 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;
|
||||
}
|
||||
13
app/multiplayer/[id]/page.tsx
Normal file
13
app/multiplayer/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function AccountSettings({
|
||||
)}
|
||||
<PasswordForm />
|
||||
<div className="settings-recovery">
|
||||
<h3>{t("Signed up with an email link?")}</h3>
|
||||
<h3>{t("Forgot your password?")}</h3>
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Set your first password, or recover a forgotten one, using a link sent to your account email.",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MousePointer2,
|
||||
Move,
|
||||
MapPin,
|
||||
Map as MapIcon,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "./Brand";
|
||||
import { Panorama } from "./Panorama";
|
||||
@@ -174,6 +175,7 @@ export function Game({ id }: { id: string }) {
|
||||
setSession(null);
|
||||
setResult(null);
|
||||
setPin(null);
|
||||
setExpanded(false);
|
||||
setLoaded({ panorama: false, map: false });
|
||||
timingOut.current = false;
|
||||
setError("");
|
||||
@@ -228,40 +230,56 @@ export function Game({ id }: { id: string }) {
|
||||
</div>
|
||||
{session && (
|
||||
<div className={"game-map " + (expanded ? "expanded" : "")}>
|
||||
<div className="map-topline">
|
||||
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
|
||||
<button
|
||||
aria-label={expanded ? t("Collapse map") : t("Expand map")}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
<GuessMap
|
||||
key={"map-" + session.round}
|
||||
bounds={session.bounds}
|
||||
image={session.map}
|
||||
pin={result ? result.guess : pin}
|
||||
actual={result?.actual}
|
||||
onPin={setPin}
|
||||
onReady={mapReady}
|
||||
onError={onError}
|
||||
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>
|
||||
<button
|
||||
className="mobile-map-toggle"
|
||||
aria-expanded={expanded}
|
||||
aria-controls="guess-map-panel"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<MapIcon size={20} aria-hidden="true" />
|
||||
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
|
||||
{pin && (
|
||||
<small>
|
||||
X {Math.round(pin.x)} · Z {Math.round(pin.z)}
|
||||
</small>
|
||||
)}
|
||||
</button>
|
||||
<div className="game-map-content" id="guess-map-panel">
|
||||
<div className="map-topline">
|
||||
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
|
||||
<button
|
||||
aria-label={expanded ? t("Collapse map") : t("Expand map")}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
<GuessMap
|
||||
key={"map-" + session.round}
|
||||
bounds={session.bounds}
|
||||
image={session.map}
|
||||
pin={result ? result.guess : pin}
|
||||
actual={result?.actual}
|
||||
onPin={setPin}
|
||||
onReady={mapReady}
|
||||
onError={onError}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -59,7 +59,11 @@ export function GuessMap({
|
||||
zoomSnap: 0,
|
||||
maxBounds: b.pad(0.15),
|
||||
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.
|
||||
let observer: ResizeObserver | undefined;
|
||||
cleanup = () => {
|
||||
|
||||
147
components/LegacyPasswordNotice.tsx
Normal file
147
components/LegacyPasswordNotice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
314
components/MultiplayerGame.tsx
Normal file
314
components/MultiplayerGame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
components/MultiplayerRoom.tsx
Normal file
367
components/MultiplayerRoom.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -39,21 +39,21 @@ export function Panorama({
|
||||
const element = mount.current!;
|
||||
view.current = { yaw: 0, pitch: 0, fov: 80 };
|
||||
const down = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.button !== 0 || drag) return;
|
||||
element.setPointerCapture(e.pointerId);
|
||||
drag = { x: e.clientX, y: e.clientY };
|
||||
drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
|
||||
},
|
||||
move = (e: PointerEvent) => {
|
||||
if (!drag) return;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
view.current.yaw -= (e.clientX - drag.x) * 0.003;
|
||||
view.current.pitch = Math.max(
|
||||
-1.5,
|
||||
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 = () => {
|
||||
drag = null;
|
||||
up = (e: PointerEvent) => {
|
||||
if (drag?.pointerId === e.pointerId) drag = null;
|
||||
},
|
||||
wheel = (e: WheelEvent) => {
|
||||
if (!zoom) return;
|
||||
@@ -70,7 +70,7 @@ export function Panorama({
|
||||
"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) {
|
||||
callbacks.current.onError(
|
||||
"This panorama is missing one or more of its six faces.",
|
||||
@@ -133,6 +133,7 @@ export function Panorama({
|
||||
element.addEventListener("pointermove", move);
|
||||
element.addEventListener("pointerup", up);
|
||||
element.addEventListener("pointercancel", up);
|
||||
element.addEventListener("lostpointercapture", up);
|
||||
element.addEventListener("wheel", wheel, { passive: false });
|
||||
callbacks.current.onReady();
|
||||
})
|
||||
@@ -150,6 +151,7 @@ export function Panorama({
|
||||
element.removeEventListener("pointermove", move);
|
||||
element.removeEventListener("pointerup", up);
|
||||
element.removeEventListener("pointercancel", up);
|
||||
element.removeEventListener("lostpointercapture", up);
|
||||
element.removeEventListener("wheel", wheel);
|
||||
geometry?.dispose();
|
||||
material?.dispose();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "./StartGame";
|
||||
import { reportCategories } from "@/shared/moderation";
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
export function ReportContent({
|
||||
datasetId,
|
||||
@@ -66,14 +67,16 @@ export function ReportContent({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="button"
|
||||
className="button report-trigger"
|
||||
aria-label={t("Report content")}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{t("Report content")}
|
||||
<Flag className="report-icon" size={18} aria-hidden="true" />
|
||||
<span>{t("Report content")}</span>
|
||||
</button>
|
||||
{message && (
|
||||
<p role="status" className="success-message">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
|
||||
import { api } from "./StartGame";
|
||||
import { authActionSchema, authDestination } from "@/shared/account";
|
||||
|
||||
type Mode = "login" | "signup" | "recover" | "magic";
|
||||
type Mode = "login" | "signup" | "recover";
|
||||
export function SignIn({
|
||||
next = "/dashboard",
|
||||
initialError = "",
|
||||
@@ -41,9 +41,7 @@ export function SignIn({
|
||||
? { action: mode, email, password, displayName: name, next }
|
||||
: mode === "login"
|
||||
? { action: mode, email, password, next }
|
||||
: mode === "magic"
|
||||
? { action: mode, email, next }
|
||||
: { action: mode, email };
|
||||
: { action: mode, email };
|
||||
const valid = authActionSchema.safeParse(body);
|
||||
if (!valid.success) {
|
||||
setError(valid.error.issues[0].message);
|
||||
@@ -75,7 +73,6 @@ export function SignIn({
|
||||
login: "Welcome back.",
|
||||
signup: "Make yourself at home.",
|
||||
recover: "Find your way back.",
|
||||
magic: "An email, a way in.",
|
||||
}[mode];
|
||||
return (
|
||||
<div className="auth-layout">
|
||||
@@ -132,11 +129,7 @@ export function SignIn({
|
||||
? t("Choose the name people will see on your maps.")
|
||||
: mode === "recover"
|
||||
? t("We’ll email you a link to set a new password.")
|
||||
: mode === "magic"
|
||||
? t(
|
||||
"For existing accounts. Open the link on this device to continue.",
|
||||
)
|
||||
: t("Log in with your email and password.")}
|
||||
: t("Log in with your email and password.")}
|
||||
</p>
|
||||
<form onSubmit={submit} aria-busy={busy}>
|
||||
{mode === "signup" && (
|
||||
@@ -230,7 +223,7 @@ export function SignIn({
|
||||
</p>
|
||||
)}
|
||||
<button className="button primary full" disabled={busy}>
|
||||
{mode === "recover" || mode === "magic" ? (
|
||||
{mode === "recover" ? (
|
||||
<Mail size={17} />
|
||||
) : (
|
||||
<ArrowRight size={17} />
|
||||
@@ -241,24 +234,10 @@ export function SignIn({
|
||||
login: t("Log in"),
|
||||
signup: t("Create account"),
|
||||
recover: t("Send reset link"),
|
||||
magic: t("Send sign-in link"),
|
||||
}[mode]}
|
||||
</button>
|
||||
</form>
|
||||
{mode === "login" && (
|
||||
<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") && (
|
||||
{mode === "recover" && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-link auth-back"
|
||||
|
||||
@@ -3,13 +3,16 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
|
||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||
import { api } from "@/lib/client-api";
|
||||
export { api } from "@/lib/client-api";
|
||||
export function StartGame({ datasetId }: { datasetId: string }) {
|
||||
const { t } = useI18n();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
|
||||
[displayName, setDisplayName] = useState(""),
|
||||
[playerLimit, setPlayerLimit] = useState(4),
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
@@ -17,11 +20,15 @@ export function StartGame({ datasetId }: { datasetId: string }) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const session = await api<{ id: string }>("sessions", {
|
||||
datasetId,
|
||||
difficulty,
|
||||
});
|
||||
router.push("/play/" + session.id);
|
||||
const session = await api<{ id: string }>(
|
||||
mode === "solo" ? "sessions" : "rooms",
|
||||
{
|
||||
datasetId,
|
||||
difficulty,
|
||||
...(mode === "multiplayer" ? { displayName, playerLimit } : {}),
|
||||
},
|
||||
);
|
||||
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setLoading(false);
|
||||
@@ -31,6 +38,24 @@ export function StartGame({ datasetId }: { datasetId: string }) {
|
||||
<div className="start-panel">
|
||||
<span className="eyebrow">{t("YOUR EXPEDITION")}</span>
|
||||
<h2>{t("Choose your challenge.")}</h2>
|
||||
<div className="tabs" aria-label={t("Game mode")}>
|
||||
<button
|
||||
aria-pressed={mode === "solo"}
|
||||
className={mode === "solo" ? "active" : ""}
|
||||
disabled={loading}
|
||||
onClick={() => setMode("solo")}
|
||||
>
|
||||
{t("Solo")}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={mode === "multiplayer"}
|
||||
className={mode === "multiplayer" ? "active" : ""}
|
||||
disabled={loading}
|
||||
onClick={() => setMode("multiplayer")}
|
||||
>
|
||||
<Users size={15} /> {t("Multiplayer")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="difficulty-options">
|
||||
{Object.entries(difficulties).map(([key, value]) => (
|
||||
<button
|
||||
@@ -55,12 +80,53 @@ export function StartGame({ datasetId }: { datasetId: string }) {
|
||||
<p className="muted">
|
||||
{t("5 rounds · 25,000 possible points · No movement")}
|
||||
</p>
|
||||
{mode === "multiplayer" && (
|
||||
<div className="multiplayer-settings">
|
||||
<label htmlFor="room-display-name">{t("Your player name")}</label>
|
||||
<input
|
||||
id="room-display-name"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
placeholder={t("Enter a player name")}
|
||||
autoComplete="nickname"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="room-player-limit">
|
||||
{t("Player limit (including you)")}
|
||||
</label>
|
||||
<select
|
||||
id="room-player-limit"
|
||||
value={playerLimit}
|
||||
onChange={(event) => setPlayerLimit(Number(event.target.value))}
|
||||
disabled={loading}
|
||||
>
|
||||
{Array.from({ length: 15 }, (_, i) => i + 2).map((count) => (
|
||||
<option key={count} value={count}>
|
||||
{count}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Create a room, share the invite link, and start when your friends arrive.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button primary full"
|
||||
onClick={start}
|
||||
disabled={loading}
|
||||
disabled={
|
||||
loading || (mode === "multiplayer" && displayName.trim().length < 2)
|
||||
}
|
||||
>
|
||||
{loading ? t("Preparing your world…") : t("Start exploring")}
|
||||
{loading
|
||||
? t("Preparing your world…")
|
||||
: mode === "multiplayer"
|
||||
? t("Create multiplayer room")
|
||||
: t("Start exploring")}
|
||||
<ArrowRight size={17} />
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
45
docs/multiplayer.md
Normal file
45
docs/multiplayer.md
Normal 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.
|
||||
@@ -21,5 +21,17 @@ Useful options:
|
||||
- `npm run deploy -- --preview`: create a preview deployment.
|
||||
- `npm run deploy -- --help`: show usage.
|
||||
|
||||
Vercel Web Analytics is included in the root layout through
|
||||
`@vercel/analytics/next`, so page views and client-side navigation are tracked
|
||||
across the site. Enable Web Analytics in the MineGeo project's Analytics tab
|
||||
before deploying. After deployment, visit the site and navigate between pages
|
||||
to verify collection. Content blockers can prevent analytics requests.
|
||||
|
||||
Analytics uses the existing Vercel project and requires no Git connection or
|
||||
additional Supabase credentials.
|
||||
|
||||
Multiplayer uses the Supabase migration described in [multiplayer.md](multiplayer.md).
|
||||
Apply that migration to a new database before deploying the multiplayer web files.
|
||||
|
||||
The script targets the existing MineGeo project. Set `VERCEL_PROJECT_ID` and
|
||||
`VERCEL_ORG_ID` to deploy a copy to another Vercel project.
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
authDestination,
|
||||
passwordUpdateSchema,
|
||||
profileUpdateSchema,
|
||||
initialPasswordSchema,
|
||||
} from "@/shared/account";
|
||||
import { createHash } from "node:crypto";
|
||||
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { issueInitialPassword } from "@/shared/initial-password";
|
||||
|
||||
type Context = {
|
||||
jsonBody: (r: NextRequest) => Promise<unknown>;
|
||||
@@ -24,6 +26,40 @@ export async function accountApi(
|
||||
path: string[],
|
||||
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) {
|
||||
const account = await user();
|
||||
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 next = authDestination("next" in body ? body.next : null);
|
||||
const callback = new URL("/auth/callback", origin);
|
||||
callback.searchParams.set(
|
||||
"type",
|
||||
body.action === "recover" ? "recovery" : "signup",
|
||||
);
|
||||
callback.searchParams.set(
|
||||
"next",
|
||||
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.",
|
||||
});
|
||||
}
|
||||
const { error } =
|
||||
body.action === "recover"
|
||||
? await client.auth.resetPasswordForEmail(body.email, {
|
||||
redirectTo: callback.toString(),
|
||||
})
|
||||
: await client.auth.signInWithOtp({
|
||||
email: body.email,
|
||||
options: {
|
||||
shouldCreateUser: false,
|
||||
emailRedirectTo: callback.toString(),
|
||||
},
|
||||
});
|
||||
const { error } = await client.auth.resetPasswordForEmail(body.email, {
|
||||
redirectTo: callback.toString(),
|
||||
});
|
||||
if (
|
||||
error &&
|
||||
!["user_not_found", "signup_disabled"].includes(error.code ?? "")
|
||||
@@ -126,9 +157,7 @@ export async function accountApi(
|
||||
throw new Error(error.message);
|
||||
return json({
|
||||
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 sign-in 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 (path[1] !== "password") return json({ error: "Not found" }, 404);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { mediaUrl, mediaUrls } from "./catalog";
|
||||
import { ServiceError, checkServiceError } from "./service-error";
|
||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||
import { accountApi } from "./account-api";
|
||||
import { multiplayerApi } from "./multiplayer-api";
|
||||
import { contributionCutoff, reportSchema } from "../shared/moderation";
|
||||
import { siteSettingsSchema } from "../shared/site-settings";
|
||||
import { getSiteSettings } from "./site-settings";
|
||||
@@ -109,6 +110,8 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
verifyOrigin(request);
|
||||
if (path[0] === "auth" || path[0] === "profile")
|
||||
return await accountApi(request, path, { jsonBody, limit });
|
||||
if (path[0] === "rooms")
|
||||
return await multiplayerApi(request, path, { jsonBody, limit });
|
||||
const client = admin(),
|
||||
method = request.method;
|
||||
if (path[0] === "site-settings" && path.length === 1 && method === "GET") {
|
||||
|
||||
137
lib/multiplayer-api.ts
Normal file
137
lib/multiplayer-api.ts
Normal 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
43
package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.117.2",
|
||||
"@vercel/analytics": "2.0.1",
|
||||
"fflate": "0.8.3",
|
||||
"leaflet": "1.9.4",
|
||||
"lucide-react": "0.468.0",
|
||||
@@ -1793,6 +1794,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/analytics": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
|
||||
"integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@remix-run/react": "^2",
|
||||
"@sveltejs/kit": "^1 || ^2",
|
||||
"next": ">= 13",
|
||||
"nuxt": ">= 3",
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"svelte": ">= 4",
|
||||
"vue": "^3",
|
||||
"vue-router": "^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@remix-run/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"next": {
|
||||
"optional": true
|
||||
},
|
||||
"nuxt": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
},
|
||||
"vue-router": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"worker": "tsx worker/main.ts",
|
||||
"test": "tsx --test tests/*.test.ts",
|
||||
"test:leaderboard": "node scripts/test-leaderboard-sql.mjs",
|
||||
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
|
||||
"test:renderer": "node scripts/test-renderer.mjs",
|
||||
"render:map": "node scripts/render-map.mjs",
|
||||
"test:production": "node scripts/test-production.mjs",
|
||||
@@ -27,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.117.2",
|
||||
"@vercel/analytics": "2.0.1",
|
||||
"fflate": "0.8.3",
|
||||
"leaflet": "1.9.4",
|
||||
"lucide-react": "0.468.0",
|
||||
|
||||
71
scripts/test-initial-password-sql.mjs
Normal file
71
scripts/test-initial-password-sql.mjs
Normal 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();
|
||||
}
|
||||
181
scripts/test-multiplayer-browser.mjs
Normal file
181
scripts/test-multiplayer-browser.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
if (fs.existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const rooms = [];
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
|
||||
});
|
||||
try {
|
||||
const { data: maps, error } = await db
|
||||
.from("datasets")
|
||||
.select("id")
|
||||
.eq("state", "published")
|
||||
.limit(1);
|
||||
if (error) throw error;
|
||||
assert.ok(maps.length);
|
||||
const desktop = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
locale: "en-US",
|
||||
});
|
||||
const mobile = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
locale: "en-US",
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
});
|
||||
const host = await desktop.newPage(),
|
||||
friend = await mobile.newPage();
|
||||
const pageErrors = [];
|
||||
for (const page of [host, friend])
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await host.goto(`${origin}/servers/${maps[0].id}`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 60000,
|
||||
});
|
||||
await host.getByRole("button", { name: "Multiplayer", exact: true }).click();
|
||||
await host.getByLabel("Your player name").fill("Browser Host");
|
||||
await host.getByLabel("Player limit (including you)").selectOption("2");
|
||||
await host
|
||||
.getByRole("button", { name: "Create multiplayer room", exact: true })
|
||||
.click();
|
||||
await host.waitForURL("**/multiplayer/*", { timeout: 30000 });
|
||||
const id = host.url().split("/").pop();
|
||||
rooms.push(id);
|
||||
await expect(host.getByLabel("Invite link")).toHaveValue(
|
||||
`${origin}/multiplayer/${id}`,
|
||||
);
|
||||
await friend.goto(host.url(), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
await friend.getByLabel("Your player name").fill("Browser Friend");
|
||||
await friend.getByRole("button", { name: "Join room", exact: true }).click();
|
||||
await expect(
|
||||
friend.getByText("You’re in! Waiting for the room owner to start.", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
host.getByRole("button", { name: "Start multiplayer match", exact: true }),
|
||||
).toBeEnabled({ timeout: 20000 });
|
||||
fs.mkdirSync("artifacts/multiplayer", { recursive: true });
|
||||
await host.screenshot({
|
||||
path: "artifacts/multiplayer/desktop-lobby.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await friend.screenshot({
|
||||
path: "artifacts/multiplayer/mobile-lobby.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.ok(
|
||||
await friend.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
"mobile lobby fits viewport",
|
||||
);
|
||||
await host
|
||||
.getByRole("button", { name: "Start multiplayer match", exact: true })
|
||||
.click();
|
||||
for (let round = 0; round < 5; round++) {
|
||||
await Promise.all([
|
||||
expect(host.locator(".game-info strong").first()).toHaveText(
|
||||
`${round + 1} / 5`,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
expect(friend.locator(".game-info strong").first()).toHaveText(
|
||||
`${round + 1} / 5`,
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
]);
|
||||
await Promise.all([
|
||||
expect(host.locator(".game-loading")).toHaveCount(0, { timeout: 45000 }),
|
||||
expect(friend.locator(".game-loading")).toHaveCount(0, {
|
||||
timeout: 45000,
|
||||
}),
|
||||
]);
|
||||
if (round === 0) {
|
||||
await host.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(host.locator(".game-loading")).toHaveCount(0, {
|
||||
timeout: 45000,
|
||||
});
|
||||
}
|
||||
await friend
|
||||
.getByRole("button", { name: "Open guess map", exact: true })
|
||||
.click();
|
||||
for (const [page, fraction] of [
|
||||
[host, 0.48],
|
||||
[friend, 0.7],
|
||||
]) {
|
||||
const map = page.getByRole("application", { name: /Minecraft map/ });
|
||||
const box = await map.boundingBox();
|
||||
assert.ok(box);
|
||||
await map.click({
|
||||
position: { x: box.width * fraction, y: box.height * 0.5 },
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Submit guess", exact: true }),
|
||||
).toBeEnabled();
|
||||
if (round === 0 && page === host)
|
||||
await page.screenshot({
|
||||
path: "artifacts/multiplayer/desktop-round.png",
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "Submit guess", exact: true })
|
||||
.click();
|
||||
}
|
||||
if (round < 4) {
|
||||
await expect(
|
||||
host.getByRole("heading", { name: "Round standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(
|
||||
friend.getByRole("heading", { name: "Round standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await host.getByRole("button", { name: /^Next round/ }).click();
|
||||
}
|
||||
console.log(`Browser round ${round + 1}/5 completed.`);
|
||||
}
|
||||
await expect(
|
||||
host.getByRole("heading", { name: "Final standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
await expect(
|
||||
friend.getByRole("heading", { name: "Final standings", exact: true }),
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
assert.equal(await host.locator(".multiplayer-final tbody tr").count(), 2);
|
||||
await host.screenshot({
|
||||
path: "artifacts/multiplayer/desktop-results.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await friend.screenshot({
|
||||
path: "artifacts/multiplayer/mobile-results.png",
|
||||
fullPage: true,
|
||||
});
|
||||
assert.ok(
|
||||
await friend.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
"mobile results fit viewport with table scrolling",
|
||||
);
|
||||
assert.deepEqual(pageErrors, []);
|
||||
console.log(
|
||||
"Desktop/mobile browser multiplayer passed: create, invite, join, reconnect, five shared rounds, map guesses, and final standings. No page errors.",
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (rooms.length) {
|
||||
const { error } = await db
|
||||
.from("multiplayer_rooms")
|
||||
.delete()
|
||||
.in("id", rooms);
|
||||
if (error)
|
||||
throw new Error(`Browser test-room cleanup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
163
scripts/test-multiplayer-http.mjs
Normal file
163
scripts/test-multiplayer-http.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||||
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
||||
const db = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||||
);
|
||||
const rooms = [];
|
||||
function player() {
|
||||
let cookie;
|
||||
return async (path, body, expected = 200) => {
|
||||
const response = await fetch(`${origin}/api/v1/${path}`, {
|
||||
method: body === undefined ? "GET" : "POST",
|
||||
headers: {
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { origin, "content-type": "application/json" }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (response.headers.get("set-cookie"))
|
||||
cookie = response.headers.get("set-cookie").split(";")[0];
|
||||
const data = await response.json();
|
||||
assert.equal(response.status, expected, JSON.stringify({ path, data }));
|
||||
assert.match(response.headers.get("cache-control"), /no-store/);
|
||||
return data;
|
||||
};
|
||||
}
|
||||
try {
|
||||
const host = player(),
|
||||
friend = player(),
|
||||
outsider = player();
|
||||
const catalog = await host("catalog");
|
||||
const datasetId = catalog.maps[0]?.id;
|
||||
assert.ok(datasetId, "A published map is required");
|
||||
const created = await host("rooms", {
|
||||
datasetId,
|
||||
difficulty: "hard",
|
||||
displayName: "HTTP Host",
|
||||
playerLimit: 2,
|
||||
});
|
||||
rooms.push(created.id);
|
||||
const path = `rooms/${created.id}`;
|
||||
const hostView = await host(path);
|
||||
const preview = await outsider(path);
|
||||
assert.equal(preview.you, null);
|
||||
assert.deepEqual(preview.rounds, []);
|
||||
await outsider(`${path}/media`, undefined, 403);
|
||||
const joined = await friend(`${path}/join`, { displayName: "HTTP Friend" });
|
||||
assert.equal(
|
||||
(await friend(`${path}/join`, { displayName: "HTTP Other" })).you,
|
||||
joined.you,
|
||||
);
|
||||
await outsider(`${path}/join`, { displayName: "Overflow" }, 400);
|
||||
await friend(`${path}/start`, {}, 403);
|
||||
await host(`${path}/start`, {});
|
||||
let results;
|
||||
for (let round = 0; round < 5; round++) {
|
||||
const [hostMedia, friendMedia] = await Promise.all([
|
||||
host(`${path}/media`),
|
||||
friend(`${path}/media`),
|
||||
]);
|
||||
assert.equal(hostMedia.round, round);
|
||||
assert.equal(friendMedia.round, round);
|
||||
assert.deepEqual(
|
||||
hostMedia.faces.map((url) => new URL(url).pathname),
|
||||
friendMedia.faces.map((url) => new URL(url).pathname),
|
||||
);
|
||||
assert.equal(hostMedia.faces.length, 6);
|
||||
if (round === 0)
|
||||
for (const url of [hostMedia.map, hostMedia.faces[0]])
|
||||
assert.equal((await fetch(url)).status, 200, "Signed media readable");
|
||||
await host(`${path}/ready`, { round });
|
||||
const playing = await friend(`${path}/ready`, { round });
|
||||
assert.equal(playing.status, "playing");
|
||||
const synchronized = await host(path);
|
||||
assert.equal(
|
||||
synchronized.rounds[round].deadline,
|
||||
playing.rounds[round].deadline,
|
||||
);
|
||||
const pointResult = await db
|
||||
.from("multiplayer_rounds")
|
||||
.select("viewpoint")
|
||||
.eq("room_id", created.id)
|
||||
.eq("round_number", round)
|
||||
.single();
|
||||
if (pointResult.error) throw pointResult.error;
|
||||
const point = pointResult.data.viewpoint;
|
||||
const first = await host(`${path}/guess`, {
|
||||
round,
|
||||
x: point.x,
|
||||
z: point.z,
|
||||
});
|
||||
assert.equal(first.status, "playing");
|
||||
assert.deepEqual(first.rounds[round].results, []);
|
||||
assert.equal(
|
||||
(await host(path)).players.find((p) => p.id === hostView.you).submitted,
|
||||
true,
|
||||
"Reconnect preserves submitted guess",
|
||||
);
|
||||
await host(`${path}/guess`, {
|
||||
round,
|
||||
x: hostMedia.bounds.minX,
|
||||
z: hostMedia.bounds.minZ,
|
||||
});
|
||||
const b = hostMedia.bounds;
|
||||
const x = point.x - (b.minX + b.maxX) / 2 >= 0 ? b.minX : b.maxX;
|
||||
const z = point.z - (b.minZ + b.maxZ) / 2 >= 0 ? b.minZ : b.maxZ;
|
||||
results = await friend(`${path}/guess`, { round, x, z });
|
||||
assert.equal(results.status, round === 4 ? "finished" : "results");
|
||||
assert.equal(
|
||||
results.rounds[round].results.find((r) => r.playerId === hostView.you)
|
||||
.score,
|
||||
5000,
|
||||
);
|
||||
if (round < 4) await host(`${path}/next`, { round });
|
||||
}
|
||||
assert.equal(results.players[0].id, hostView.you);
|
||||
assert.equal(results.players[0].total, 25000);
|
||||
assert.equal(results.rounds.flatMap((round) => round.results).length, 10);
|
||||
const capacityHost = player();
|
||||
const full = await capacityHost("rooms", {
|
||||
datasetId,
|
||||
difficulty: "normal",
|
||||
displayName: "Capacity Host",
|
||||
playerLimit: 16,
|
||||
});
|
||||
rooms.push(full.id);
|
||||
await Promise.all(
|
||||
Array.from({ length: 14 }, (_, i) =>
|
||||
player()(`rooms/${full.id}/join`, { displayName: `Capacity ${i + 2}` }),
|
||||
),
|
||||
);
|
||||
const attempts = await Promise.allSettled([
|
||||
player()(`rooms/${full.id}/join`, { displayName: "Last A" }),
|
||||
player()(`rooms/${full.id}/join`, { displayName: "Last B" }),
|
||||
]);
|
||||
assert.equal(
|
||||
attempts.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"Exactly one of the simultaneous last-slot joins succeeds",
|
||||
);
|
||||
const rejection = attempts.find((result) => result.status === "rejected");
|
||||
assert.match(rejection.reason.message, /This room is full/);
|
||||
assert.equal((await capacityHost(`rooms/${full.id}`)).playerCount, 16);
|
||||
console.log(
|
||||
"Multiplayer HTTP smoke passed: private invite joining, shared signed media and timers, five-round ranking, reconnects, retries, and simultaneous 16-player capacity checks.",
|
||||
);
|
||||
} finally {
|
||||
if (rooms.length) {
|
||||
const { error } = await db
|
||||
.from("multiplayer_rooms")
|
||||
.delete()
|
||||
.in("id", rooms);
|
||||
if (error)
|
||||
throw new Error(`Multiplayer test-room cleanup failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
283
scripts/test-multiplayer-sql.mjs
Normal file
283
scripts/test-multiplayer-sql.mjs
Normal file
@@ -0,0 +1,283 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
|
||||
// Isolated PostgreSQL: fixtures never enter production rooms or leaderboards.
|
||||
const db = new PGlite();
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role;
|
||||
create schema auth; create table auth.users(id uuid primary key);
|
||||
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
|
||||
create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
|
||||
for (const file of (await readdir("supabase/migrations"))
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort())
|
||||
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||
const account = (
|
||||
await db.query(
|
||||
"insert into auth.users values(gen_random_uuid()) returning id",
|
||||
)
|
||||
).rows[0].id;
|
||||
const dataset = (
|
||||
await db.query(
|
||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||
values($1,'Multiplayer fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
|
||||
[account],
|
||||
)
|
||||
).rows[0].id;
|
||||
const manifest = {
|
||||
name: "Multiplayer fixture",
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `point${i}`,
|
||||
x: 10 + i,
|
||||
z: 10 + i,
|
||||
y: 64,
|
||||
})),
|
||||
};
|
||||
const revision = (
|
||||
await db.query(
|
||||
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
|
||||
[dataset, JSON.stringify(manifest)],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query("update public.datasets set revision_id=$1 where id=$2", [
|
||||
revision,
|
||||
dataset,
|
||||
]);
|
||||
const secret = (n) => String(n).padStart(64, "0");
|
||||
async function create(cap = 2) {
|
||||
return (
|
||||
await db.query(
|
||||
"select public.create_multiplayer_room($1,'hard','Host',$2,$3) as id",
|
||||
[dataset, cap, secret(1)],
|
||||
)
|
||||
).rows[0].id;
|
||||
}
|
||||
async function act(room, who, type = "view", payload = {}) {
|
||||
return (
|
||||
await db.query("select public.multiplayer_action($1,$2,$3,$4) as room", [
|
||||
room,
|
||||
secret(who),
|
||||
type,
|
||||
JSON.stringify(payload),
|
||||
])
|
||||
).rows[0].room;
|
||||
}
|
||||
for (const cap of [1, 17, null])
|
||||
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
|
||||
const room = await create();
|
||||
let view = await act(room, 1);
|
||||
const host = view.you;
|
||||
assert.equal(view.playerLimit, 2);
|
||||
assert.equal(view.players.length, 1);
|
||||
assert.equal(view.rounds.length, 5);
|
||||
assert.ok(view.rounds.every((turn) => turn.results.length === 0));
|
||||
const outsider = await act(room, 3);
|
||||
assert.equal(outsider.you, null);
|
||||
assert.deepEqual(outsider.players, []);
|
||||
assert.deepEqual(outsider.rounds, []);
|
||||
assert.ok(!JSON.stringify(view).includes('"viewpoint"'));
|
||||
assert.ok(!JSON.stringify(view).includes("access_hash"));
|
||||
await assert.rejects(act(room, 1, "start"), /Invite at least one friend/);
|
||||
await assert.rejects(act(room, 3, "start"), /membership permission/);
|
||||
const joined = await act(room, 2, "join", { name: "Friend" });
|
||||
const friend = joined.you;
|
||||
assert.equal(
|
||||
(await act(room, 2, "join", { name: "Changed name" })).you,
|
||||
friend,
|
||||
);
|
||||
await assert.rejects(act(room, 3, "join", { name: "Extra" }), /room is full/);
|
||||
await assert.rejects(act(room, 2, "start"), /owner permission/);
|
||||
view = await act(room, 1, "start");
|
||||
assert.equal(view.status, "loading");
|
||||
await assert.rejects(
|
||||
act(room, 3, "join", { name: "Late" }),
|
||||
/already started/,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(room, 1, "guess", { round: 0, x: 10, z: 10 }),
|
||||
/Round is not active/,
|
||||
);
|
||||
assert.equal((await act(room, 1, "ready", { round: 0 })).status, "loading");
|
||||
view = await act(room, 2, "ready", { round: 0 });
|
||||
assert.equal(view.status, "playing");
|
||||
const deadline = view.rounds[0].deadline;
|
||||
assert.equal(
|
||||
(await act(room, 1, "ready", { round: 0 })).rounds[0].deadline,
|
||||
deadline,
|
||||
);
|
||||
await assert.rejects(
|
||||
act(room, 1, "guess", { round: 0, x: 65, z: 0 }),
|
||||
/Guess outside map/,
|
||||
);
|
||||
const location = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
|
||||
[room],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
view = await act(room, 1, "guess", {
|
||||
round: 0,
|
||||
x: location.x,
|
||||
z: location.z,
|
||||
});
|
||||
assert.equal(view.status, "playing");
|
||||
assert.deepEqual(view.rounds[0].results, []);
|
||||
assert.ok(
|
||||
view.players.every((player) => player.total === 0),
|
||||
"scores concealed until everyone finishes",
|
||||
);
|
||||
assert.deepEqual(view.myGuess, { x: location.x, z: location.z });
|
||||
await act(room, 1, "guess", { round: 0, x: 0, z: 0 });
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select count(*)::integer as count from public.multiplayer_guesses where room_id=$1 and player_id=$2",
|
||||
[room, host],
|
||||
)
|
||||
).rows[0].count,
|
||||
1,
|
||||
);
|
||||
view = await act(room, 2, "guess", { round: 0, x: 0, z: 0 });
|
||||
assert.equal(view.status, "results");
|
||||
assert.equal(
|
||||
view.rounds[0].results.find((result) => result.playerId === host).score,
|
||||
5000,
|
||||
);
|
||||
assert.ok(
|
||||
view.rounds[0].results.find((result) => result.playerId === friend).score <
|
||||
5000,
|
||||
);
|
||||
await assert.rejects(act(room, 2, "next", { round: 0 }), /owner permission/);
|
||||
assert.equal(
|
||||
(await act(room, 1, "guess", { round: 0, x: 0, z: 0 })).status,
|
||||
"results",
|
||||
"retry cannot alter a submitted guess",
|
||||
);
|
||||
for (let round = 1; round < 5; round++) {
|
||||
await act(room, 1, "next", { round: round - 1 });
|
||||
await act(room, 1, "ready", { round });
|
||||
await act(room, 2, "ready", { round });
|
||||
const point = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=$2",
|
||||
[room, round],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
await act(room, 1, "guess", { round, x: point.x, z: point.z });
|
||||
if (round === 2) {
|
||||
await db.query(
|
||||
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=$2",
|
||||
[room, round],
|
||||
);
|
||||
view = await act(room, 2, "guess", { round, x: point.x, z: point.z });
|
||||
assert.equal(
|
||||
view.rounds[round].results.find((result) => result.playerId === friend)
|
||||
.score,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
view.rounds[round].results.find((result) => result.playerId === friend)
|
||||
.timedOut,
|
||||
true,
|
||||
);
|
||||
} else view = await act(room, 2, "guess", { round, x: 0, z: 0 });
|
||||
}
|
||||
assert.equal(view.status, "finished");
|
||||
assert.equal(view.players[0].id, host);
|
||||
assert.equal(view.players[0].total, 25000);
|
||||
assert.equal(view.players[0].rank, 1);
|
||||
assert.equal(view.players[1].rank, 2);
|
||||
assert.equal(view.rounds.flatMap((turn) => turn.results).length, 10);
|
||||
|
||||
const capacity = await create(16);
|
||||
for (let n = 2; n <= 16; n++)
|
||||
await act(capacity, n, "join", { name: `Player ${n}` });
|
||||
await assert.rejects(
|
||||
act(capacity, 17, "join", { name: "Overflow" }),
|
||||
/room is full/,
|
||||
);
|
||||
assert.equal((await act(capacity, 1)).playerCount, 16);
|
||||
const removed = (await act(capacity, 16)).you;
|
||||
await act(capacity, 1, "kick", { playerId: removed });
|
||||
assert.equal((await act(capacity, 16)).you, null);
|
||||
await act(capacity, 17, "join", { name: "Replacement" });
|
||||
await act(capacity, 1, "leave");
|
||||
assert.equal(
|
||||
(await act(capacity, 2)).hostId,
|
||||
(await act(capacity, 2)).you,
|
||||
"ownership transfers when host leaves",
|
||||
);
|
||||
await assert.rejects(
|
||||
act(capacity, 18, "join", { name: "player 2" }),
|
||||
/name is already taken/,
|
||||
);
|
||||
|
||||
const stalled = await create();
|
||||
await act(stalled, 2, "join", { name: "Offline" });
|
||||
await act(stalled, 1, "start");
|
||||
await act(stalled, 1, "ready", { round: 0 });
|
||||
await db.query(
|
||||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||||
[stalled],
|
||||
);
|
||||
assert.equal(
|
||||
(await act(stalled, 1)).status,
|
||||
"playing",
|
||||
"offline loader cannot block the match",
|
||||
);
|
||||
await db.query(
|
||||
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=0",
|
||||
[stalled],
|
||||
);
|
||||
view = await act(stalled, 1);
|
||||
assert.equal(view.status, "results");
|
||||
assert.ok(
|
||||
view.players.every((player) => player.rank === 1),
|
||||
"equal totals share a rank",
|
||||
);
|
||||
await db.query(
|
||||
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
|
||||
[stalled],
|
||||
);
|
||||
assert.equal(
|
||||
(await act(stalled, 1)).round,
|
||||
1,
|
||||
"rounds progress without host clicks",
|
||||
);
|
||||
await act(stalled, 2, "leave");
|
||||
await act(stalled, 1, "ready", { round: 1 });
|
||||
const point = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=1",
|
||||
[stalled],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
view = await act(stalled, 1, "guess", { round: 1, x: point.x, z: point.z });
|
||||
assert.equal(view.status, "results", "departed players cannot block scoring");
|
||||
await act(stalled, 1, "close");
|
||||
assert.equal((await act(stalled, 1)).status, "closed");
|
||||
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
for (const table of [
|
||||
"multiplayer_rooms",
|
||||
"multiplayer_players",
|
||||
"multiplayer_rounds",
|
||||
"multiplayer_guesses",
|
||||
])
|
||||
await assert.rejects(
|
||||
db.query(`select * from public.${table}`),
|
||||
/permission denied/,
|
||||
);
|
||||
await assert.rejects(act(room, 1), /permission denied/);
|
||||
await assert.rejects(create(), /permission denied/);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
console.log(
|
||||
"Multiplayer SQL checks passed: 16-player cap, owner permissions, private answers, synchronized rounds, deadlines, retries, ties, departure, and client-role isolation.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
@@ -36,13 +36,13 @@ export const authActionSchema = z.discriminatedUnion("action", [
|
||||
})
|
||||
.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
|
||||
.object({ displayName: displayNameSchema })
|
||||
.strict();
|
||||
export const initialPasswordSchema = z
|
||||
.object({ password: newPasswordSchema })
|
||||
.strict();
|
||||
export const passwordUpdateSchema = z
|
||||
.object({
|
||||
password: newPasswordSchema,
|
||||
|
||||
@@ -43,6 +43,93 @@ export type Database = {
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
multiplayer_rooms: {
|
||||
Row: {
|
||||
id: string;
|
||||
revision_id: string;
|
||||
host_player_id: string;
|
||||
difficulty: string;
|
||||
player_limit: number;
|
||||
status: string;
|
||||
current_round: number;
|
||||
transition_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
revision_id: string;
|
||||
host_player_id: string;
|
||||
difficulty: string;
|
||||
player_limit: number;
|
||||
status?: string;
|
||||
current_round?: number;
|
||||
transition_at?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
host_player_id?: string;
|
||||
status?: string;
|
||||
current_round?: number;
|
||||
transition_at?: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_rounds: {
|
||||
Row: {
|
||||
room_id: string;
|
||||
round_number: number;
|
||||
viewpoint: Json;
|
||||
deadline: string | null;
|
||||
closed_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
room_id: string;
|
||||
round_number: number;
|
||||
viewpoint: Json;
|
||||
deadline?: string | null;
|
||||
closed_at?: string | null;
|
||||
};
|
||||
Update: { deadline?: string | null; closed_at?: string | null };
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_players: {
|
||||
Row: {
|
||||
id: string;
|
||||
room_id: string;
|
||||
access_hash: string;
|
||||
display_name: string;
|
||||
ready_round: number;
|
||||
has_left: boolean;
|
||||
joined_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
room_id: string;
|
||||
access_hash: string;
|
||||
display_name: string;
|
||||
ready_round?: number;
|
||||
has_left?: boolean;
|
||||
joined_at?: string;
|
||||
};
|
||||
Update: { ready_round?: number; has_left?: boolean };
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_guesses: {
|
||||
Row: {
|
||||
room_id: string;
|
||||
player_id: string;
|
||||
round_number: number;
|
||||
result: Json;
|
||||
};
|
||||
Insert: {
|
||||
room_id: string;
|
||||
player_id: string;
|
||||
round_number: number;
|
||||
result: Json;
|
||||
};
|
||||
Update: { result?: Json };
|
||||
Relationships: [];
|
||||
};
|
||||
site_settings: {
|
||||
Row: {
|
||||
id: boolean;
|
||||
@@ -540,6 +627,27 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
create_multiplayer_room: {
|
||||
Args: {
|
||||
p_dataset: string;
|
||||
p_difficulty: string;
|
||||
p_name: string;
|
||||
p_limit: number;
|
||||
p_access_hash: string;
|
||||
};
|
||||
Returns: string;
|
||||
};
|
||||
multiplayer_action: {
|
||||
Args: {
|
||||
p_room: string;
|
||||
p_access_hash: string;
|
||||
p_action: string;
|
||||
p_payload: Json;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
legacy_password_needed: { Args: { p_user: string }; Returns: boolean };
|
||||
claim_legacy_password: { Args: { p_user: string }; Returns: boolean };
|
||||
set_beta_badge: {
|
||||
Args: { p_reviewer: string; p_enabled: boolean };
|
||||
Returns: boolean;
|
||||
|
||||
28
shared/initial-password.ts
Normal file
28
shared/initial-password.ts
Normal 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);
|
||||
}
|
||||
@@ -587,5 +587,82 @@
|
||||
"Hidden for all players": "Скрыт для всех игроков",
|
||||
"Hide 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
62
shared/multiplayer.ts
Normal 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[];
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
v2.119.0
|
||||
v2.120.0
|
||||
@@ -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;
|
||||
214
supabase/migrations/20261007071259_multiplayer_rooms.sql
Normal file
214
supabase/migrations/20261007071259_multiplayer_rooms.sql
Normal 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;
|
||||
112
tests/initial-password.test.ts
Normal file
112
tests/initial-password.test.ts
Normal 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
46
tests/multiplayer.test.ts
Normal 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
Reference in New Issue
Block a user