- {t("↑ N · PLACE YOUR GUESS")}
+
+ {t(
+ room.difficultySettings.compass === false
+ ? "PLACE YOUR GUESS"
+ : "↑ N · PLACE YOUR GUESS",
+ )}
+
)}
-
+
);
}
diff --git a/components/Panorama.tsx b/components/Panorama.tsx
index 2b4a411..32043a5 100644
--- a/components/Panorama.tsx
+++ b/components/Panorama.tsx
@@ -15,11 +15,13 @@ void main(){vec3 d=normalize(vDirection);vec3 a=abs(d);vec2 uv;vec4 color;if(a.x
export function Panorama({
urls,
zoom,
+ pan = true,
onReady,
onError,
}: {
urls: string[];
zoom: boolean;
+ pan?: boolean;
onReady: () => void;
onError: (message: string) => void;
}) {
@@ -39,7 +41,7 @@ export function Panorama({
const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => {
- if (e.button !== 0 || drag) return;
+ if (!pan || e.button !== 0 || drag) return;
element.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY, pointerId: e.pointerId };
},
@@ -160,14 +162,18 @@ export function Panorama({
renderer?.dispose();
renderer?.domElement.remove();
};
- }, [urls, zoom]);
+ }, [urls, zoom, pan]);
return (
<>
{
diff --git a/components/SiteNav.tsx b/components/SiteNav.tsx
index 381489f..397d882 100644
--- a/components/SiteNav.tsx
+++ b/components/SiteNav.tsx
@@ -7,6 +7,7 @@ import { usePathname } from "next/navigation";
const links = [
["/servers", "Play & maps"],
["/leaderboard", "Leaderboard"],
+ ["/tournaments", "Tournaments"],
["/downloads", "Downloads"],
["/about", "Guide"],
["/docs", "Docs"],
diff --git a/components/StartGame.tsx b/components/StartGame.tsx
index f1a20f3..6981672 100644
--- a/components/StartGame.tsx
+++ b/components/StartGame.tsx
@@ -9,6 +9,8 @@ import {
difficulties,
customDifficultySchema,
type DifficultyPreset,
+ type ScoringMode,
+ difficultyRuleDefaults,
} from "@/shared/protocol";
import { api } from "@/lib/client-api";
import { mergeDifficultyPresets } from "@/shared/difficulty-presets";
@@ -27,6 +29,13 @@ export function StartGame({
[difficultyName, setDifficultyName] = useState(""),
[difficultySeconds, setDifficultySeconds] = useState(90),
[difficultyZoom, setDifficultyZoom] = useState(true),
+ [difficultyRules, setDifficultyRules] = useState<{
+ pan: boolean;
+ compass: boolean;
+ coordinates: boolean;
+ mapZoom: boolean;
+ scoring: ScoringMode;
+ }>(difficultyRuleDefaults),
[savingDifficulty, setSavingDifficulty] = useState(false),
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
[playerLimit, setPlayerLimit] = useState(4),
@@ -67,6 +76,7 @@ export function StartGame({
name: difficultyName,
seconds: difficultySeconds,
zoom: difficultyZoom,
+ ...difficultyRules,
});
if (!parsed.success) {
setError(parsed.error.issues[0].message);
@@ -85,6 +95,7 @@ export function StartGame({
setDifficultyName("");
setDifficultySeconds(90);
setDifficultyZoom(true);
+ setDifficultyRules(difficultyRuleDefaults);
setShowDifficultyForm(false);
} catch (failure) {
setError((failure as Error).message);
@@ -157,6 +168,18 @@ export function StartGame({
{option.zoom ? t("Zoom allowed") : t("No zoom")}
+ {!(option.id in difficulties) && (
+
+ {t(
+ option.scoring === "strict"
+ ? "Strict scoring"
+ : option.scoring === "gentle"
+ ? "Gentle scoring"
+ : "Normal scoring",
+ )}{" "}
+ · {t(option.pan === false ? "Fixed view" : "Panning allowed")}
+
+ )}
))}
@@ -219,6 +242,52 @@ export function StartGame({
/>
{t("Allow zoom")}
+ {(
+ [
+ ["pan", "Allow panorama panning"],
+ ["compass", "Show north indicator"],
+ ["coordinates", "Show guess coordinates"],
+ ["mapZoom", "Allow guess-map zoom"],
+ ] as const
+ ).map(([key, label]) => (
+
+ ))}
+
+
+
+ {t(
+ "Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.",
+ )}
+
import("./MultiplayerGame").then((module) => module.MultiplayerGame),
+ { ssr: false },
+);
+import { roundClock } from "@/shared/round-clock";
+import {
+ serverLocalToIso,
+ type TournamentRecord,
+ type TournamentView,
+} from "@/shared/tournaments";
+import type { DifficultyPreset } from "@/shared/protocol";
+
+type Events = {
+ tournaments: TournamentRecord[];
+ serverNow: number;
+ serverTimezone: string;
+};
+function time(value: string, zone: string) {
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: zone,
+ }).format(new Date(value));
+}
+export function TournamentIndex({ signedIn }: { signedIn: boolean }) {
+ const { t } = useI18n();
+ const router = useRouter();
+ const [events, setEvents] = useState(null);
+ const [maps, setMaps] = useState<{ id: string; name: string }[]>([]);
+ const [presets, setPresets] = useState([]);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [clock, setClock] = useState(0);
+ const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
+ const [roundSeconds, setRoundSeconds] = useState(450);
+ const offset = useRef(0);
+ const load = useCallback(async () => {
+ try {
+ const next = await api("tournaments");
+ offset.current = next.serverNow - Date.now();
+ setEvents(next);
+ setClock(next.serverNow);
+ setError("");
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ }, []);
+ useEffect(() => {
+ void load();
+ const timer = setInterval(() => {
+ setClock(Date.now() + offset.current);
+ }, 1000);
+ const poll = setInterval(() => void load(), 30000);
+ return () => {
+ clearInterval(timer);
+ clearInterval(poll);
+ };
+ }, [load]);
+ useEffect(() => {
+ if (!creating) return;
+ let active = true;
+ Promise.all([
+ api<{ maps: { id: string; name: string }[] }>("catalog"),
+ api<{ presets: DifficultyPreset[] }>("difficulty-presets"),
+ ])
+ .then(([catalog, difficulties]) => {
+ if (active) {
+ setMaps(catalog.maps);
+ setPresets(difficulties.presets);
+ }
+ })
+ .catch((e) => {
+ if (active) setError(e.message);
+ });
+ return () => {
+ active = false;
+ };
+ }, [creating]);
+ async function create(event: FormEvent) {
+ event.preventDefault();
+ if (busy || !events) return;
+ const form = new FormData(event.currentTarget);
+ setBusy(true);
+ setError("");
+ try {
+ const result = await api<{ id: string }>("tournaments", {
+ name: form.get("name"),
+ datasetId: form.get("dataset"),
+ difficulty: form.get("difficulty"),
+ playerLimit: Number(form.get("limit")),
+ roundCount: Number(form.get("rounds")),
+ roundTimeSeconds: Number(form.get("seconds")),
+ timerMode: form.get("timerMode"),
+ startsAt: serverLocalToIso(
+ String(form.get("start")),
+ events.serverTimezone,
+ ),
+ announceDiscord: form.has("discord"),
+ });
+ router.push(`/tournaments/${result.id}`);
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ return (
+
+
+
+
+
+ {t("COMMUNITY TOURNAMENTS")}
+
+
{t("One world. Up to 32 rivals.")}
+
+ {t(
+ "Every tournament round contains five mini-rounds. Everyone plays the same locations.",
+ )}
+
+
+ {signedIn ? (
+
setCreating(!creating)}
+ >
+
+ {t(creating ? "Close" : "Create tournament")}
+
+ ) : (
+
+ {t("Log in to create or join")}
+
+ )}
+
+ {events && (
+
+
+ {t("Server time")}:{" "}
+ {time(
+ new Date(clock || events.serverNow).toISOString(),
+ events.serverTimezone,
+ )}{" "}
+ · {events.serverTimezone}
+
+ )}
+ {error && (
+
+ {t(error)}{" "}
+ void load()}>
+ {t("Try again")}
+
+
+ )}
+ {creating && signedIn && (
+
+ )}
+
+ {events?.tournaments.map((event) => (
+
+
+
+
+ {t(event.status)}
+
+
+
{event.name}
+
+
+ {time(event.starts_at, event.server_timezone)} ·{" "}
+ {event.server_timezone}
+
+
+
+ {t("Up to {count} players", { count: event.player_limit })} ·{" "}
+ {t("{count} tournament rounds", { count: event.round_count })}
+
+
{t("Open tournament")} →
+
+ ))}
+
+ {events && !events.tournaments.length && (
+
+
+
{t("The next champion could be you.")}
+
+ {t(
+ "Create the first community tournament or check back for upcoming events.",
+ )}
+
+
+ )}
+
+ );
+}
+
+export function TournamentLobby({
+ id,
+ signedIn,
+}: {
+ id: string;
+ signedIn: boolean;
+}) {
+ const { t, formatNumber } = useI18n();
+ const [event, setEvent] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const acting = useRef(false),
+ version = useRef(0);
+ const latest = useRef(null);
+ const clock = useRef | null>(null);
+ const accept = useCallback((next: TournamentView, sent: number) => {
+ clock.current = roundClock(next.serverNow, sent, performance.now());
+ latest.current = next;
+ setEvent(next);
+ setError("");
+ }, []);
+ const load = useCallback(async () => {
+ if (acting.current) return;
+ const current = ++version.current,
+ sent = performance.now();
+ try {
+ const next = await api(`tournaments/${id}`);
+ if (current === version.current) accept(next, sent);
+ } catch (e) {
+ if (current === version.current) setError((e as Error).message);
+ }
+ }, [id, accept]);
+ useEffect(() => {
+ let stopped = false;
+ let timer: ReturnType;
+ const poll = async () => {
+ await load();
+ if (
+ !stopped &&
+ !["finished", "cancelled"].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);
+ };
+ }, [load]);
+ const action = useCallback(
+ async (type: string, payload: object = {}) => {
+ if (acting.current) return false;
+ acting.current = true;
+ setBusy(true);
+ const current = ++version.current,
+ sent = performance.now();
+ try {
+ const next = await api(
+ `tournaments/${id}/${type}`,
+ payload,
+ );
+ if (current === version.current) accept(next, sent);
+ return true;
+ } catch (e) {
+ if (current === version.current) setError((e 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,
+ [],
+ );
+ const room = event?.currentRoom;
+ const owner = event?.you === event?.ownerId && !!event?.you;
+ const joined = !!event?.players.some((p) => p.accountId === event.you);
+ const gameAction = useCallback(
+ (type: string, payload: object = {}) =>
+ action(type, { ...payload, tournamentRound: event?.currentRound ?? 0 }),
+ [action, event?.currentRound],
+ );
+ if (!event)
+ return (
+
+
← {t("Tournaments")}
+ {error ? (
+
+ {t(error)}{" "}
+ void load()} className="button">
+ {t("Try again")}
+
+
+ ) : (
+
{t("Loading tournament…")}
+ )}
+
+ );
+ async function edit(e: FormEvent) {
+ e.preventDefault();
+ if (!event) return;
+ const form = new FormData(e.currentTarget);
+ try {
+ if (
+ await action("edit", {
+ startsAt: serverLocalToIso(
+ String(form.get("start")),
+ event.serverTimezone,
+ ),
+ playerLimit: Number(form.get("limit")),
+ announceDiscord: form.has("discord"),
+ })
+ )
+ setEditing(false);
+ } catch (failure) {
+ setError((failure as Error).message);
+ }
+ }
+ return (
+ <>
+ {event.status === "running" && room?.you && (
+
+
+
+ {event.name} ·{" "}
+ {t("Tournament round {round} of {total}", {
+ round: event.currentRound + 1,
+ total: event.roundCount,
+ })}
+
+
{
+ document
+ .getElementById("tournament-standings")
+ ?.scrollIntoView({ behavior: "instant" });
+ }}
+ />
+
+ )}
+
+
+ ← {t("Tournaments")}
+
+
+
+
+
+ {t(event.status)}
+
+
{event.name}
+
+ {time(event.startsAt, event.serverTimezone)} ·{" "}
+ {event.serverTimezone}
+
+
+
{
+ try {
+ await navigator.clipboard.writeText(window.location.href);
+ setCopied(true);
+ } catch {
+ setError("Copy the tournament link from your address bar");
+ }
+ }}
+ >
+
+ {t(copied ? "Copied!" : "Copy invite")}
+
+
+ {error && (
+
+ {t(error)}
+
+ )}
+
+
+
+ {event.players.length} / {event.playerLimit}
+
+
+ {t("{count} tournament rounds", { count: event.roundCount })} × 5{" "}
+ {t("mini-rounds")}
+
+
+ {event.roundTimeSeconds}{" "}
+ {t(
+ event.timerMode === "shared"
+ ? "seconds per tournament round"
+ : "seconds per mini-round",
+ )}
+
+
+ {owner && event.announceDiscord && (
+
+ {t("Discord announcement")}:{" "}
+ {t(event.announcementState ?? "pending")}
+
+ )}
+ {event.status === "scheduled" && (
+
+ {!signedIn ? (
+
+ {t("Log in to create or join")}
+
+ ) : joined ? (
+ <>
+
+ {t(
+ "You’re registered. Stay on this page when the tournament starts.",
+ )}
+
+ {!owner && (
+ void action("leave")}
+ >
+ {t("Leave tournament")}
+
+ )}
+ >
+ ) : (
+ = event.playerLimit}
+ onClick={() => void action("join")}
+ >
+ {t(
+ event.players.length >= event.playerLimit
+ ? "Tournament is full"
+ : "Join tournament",
+ )}
+
+ )}
+ {owner && (
+ <>
+ setEditing(!editing)}
+ >
+ {t("Tournament settings")}
+
+ void action("cancel")}
+ >
+ {t("Cancel tournament")}
+
+ >
+ )}
+
+ )}
+ {editing && owner && event.status === "scheduled" && (
+
+ )}
+ {event.status === "finished" && (
+
+
+
+
{t("TOURNAMENT CHAMPION")}
+
+ {event.players.find((p) => p.accountId === event.winnerId)
+ ?.name ?? t("Explorer")}
+
+
+ {t(
+ "The latest tournament champion holds first place on the global leaderboard for seven days.",
+ )}
+
+
+ {t("Global leaderboard")} →
+
+
+
+ )}
+ {event.status === "cancelled" && (
+
+ {t(
+ "This tournament was cancelled. Scheduled tournaments need at least two eligible players at the start.",
+ )}
+
+ )}
+
+ {t(
+ event.status === "finished"
+ ? "Final standings"
+ : "Tournament standings",
+ )}
+
+
+ {t(
+ "Points count after each mini-round closes. Ties use total distance, then registration order.",
+ )}
+
+
+
+
+
+ | {t("Rank")} |
+ {t("Explorer")} |
+ {t("Total points")} |
+
+
+
+ {event.players.map((player) => (
+
+ | {player.rank} |
+
+ {player.name}
+ {player.accountId === event.winnerId ? " 🏆" : ""}
+ |
+ {formatNumber(player.total)} |
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/docs/cinematics.md b/docs/cinematics.md
index bf90d7e..6d4495a 100644
--- a/docs/cinematics.md
+++ b/docs/cinematics.md
@@ -2,18 +2,18 @@
## Deliverables
-- `public/media/minegeo-discord-banner.gif`: 600 × 240, 3-second looping banner at 24 fps. Created in Three.js; the camera, compass sweep, pins and terrain movement return to their initial state at the loop boundary.
+- `public/media/minegeo-discord-banner.gif`: 600 × 240, 3-second looping banner at 24 fps. Created in Three.js; the camera, compass sweep and pin targeting return to their initial state at the loop boundary.
- `public/media/minegeo-discord-banner.png`: 1200 × 480 still fallback.
-- `public/media/minegeo-trailer.mp4`: 1280 × 720, 30 seconds at 24 fps, H.264/AAC, optimized for progressive web playback. Includes an original synthesized ambient soundtrack with no speech.
+- `public/media/minegeo-trailer.mp4`: 1280 × 720, 30 seconds at 30 fps, H.264/AAC, optimized for progressive web playback. Includes an original synthesized ambient soundtrack with no speech.
- `public/media/minegeo-trailer-poster.jpg` and `minegeo-trailer.vtt`: poster and English captions.
-The trailer combines a Three.js opening and closing with genuine screenshots of the homepage, map catalog, in-game panorama, and map setup, captured through the browser from the local app against the real published catalog. Screenshots are stored in `cinematics/screenshots/` and animated inside a framed window; they are still captures, not a gameplay recording. A guest game was opened for the panorama capture; no score was submitted. The intro/closing terrain, hut, arch, pins, particles, compass, typography and sound are generated locally; no third-party footage or music is used. Typography uses the project's existing licensed Monocraft font.
+The trailer combines a Three.js opening and closing with genuine screenshots of the homepage, map catalog, in-game panorama, and map setup, captured through the browser from the local app against the real published catalog. Screenshots are stored in `cinematics/screenshots/` and presented in a minimal frame with a brief entrance and a still reading hold; they are still captures, not a gameplay recording. A guest game was opened for the panorama capture; no score was submitted. The intro/closing terrain, hut, arch, pins, particles, compass, typography and sound are generated locally; no third-party footage or music is used. Typography uses the project's existing licensed Monocraft font.
The homepage provides native video controls, captions, a descriptive text alternative, and a download link. The video does not autoplay and uses `preload="none"`.
## First visit
-`FirstVisitIntro` runs only on the homepage and lasts 4.75 seconds. It uses CSS terrain tiles, a compass/pin reveal, and text fading out before the backdrop reveals the site. The Skip button and Escape dismiss it early. It bypasses the animation when reduced motion is requested and closes if that preference changes during playback.
+`FirstVisitIntro` runs only on the homepage and lasts 4 seconds. A single isometric map tile enters, a golden pin settles, and the wordmark enters with a 60ms stagger. Text exits at 3.3 seconds, the map exits at 3.5 seconds, and the backdrop exits at 3.75 seconds. The Skip button and Escape dismiss it early. It bypasses the animation when reduced motion is requested and closes if that preference changes during playback.
The `minegeo:intro:v1` local-storage key remembers a visit per browser/origin. If local storage is unavailable, it falls back to session storage and then in-memory state. The regular page always renders on the server; JavaScript failure cannot leave it hidden. The welcome does not interrupt an already-open dialog.
@@ -29,3 +29,13 @@ node scripts/render-cinematics.mjs --preview
```
The preview binds to `127.0.0.1:3110`. Open `/?mode=banner` for the loop or `/?time=16&still=1` for a trailer still. Temporary frames and synthesized audio stay in ignored `.data/cinematic-render/`; deliverables are written to `public/media/`. The export uses an isolated headless graphics renderer, does not access user browser sessions, and needs no credentials or network assets.
+
+## Motion direction and performance
+
+Rebuilt with the Animate and Animation Performance skills. Entrances and exits use the canonical ease-out curve (0.23, 1, 0.32, 1); the camera move and map-target alignment use ease-in-out (0.77, 0, 0.175, 1). The Discord wordmark stays readable throughout its 3-second loop.
+
+The 30-second trailer opens for 3 seconds, then gives each actual website screenshot 5 seconds: homepage (3–8), catalog (8–13), gameplay (13–18), map setup (18–23). The final 7 seconds hold the brand and site address. Screenshots settle within 300ms and then remain still. The gameplay chapter highlights the existing guess map after 2 seconds. Original audio transitions and captions follow the same chapter boundaries.
+
+The first-visit animation is CSS-only and changes transform/opacity; it has no animated blur, layout properties, perpetual loops or per-frame React updates. Skip and Escape close immediately. Reduced motion bypasses the decorative intro. The offline Three.js renderer caches static terrain shadows; none of that renderer ships to the homepage. Native video playback is opt-in, with no preload or autoplay.
+
+Verification covers timeline/easing tests, browser checks of Skip, Escape, automatic dismissal, repeat visits and reduced-motion bypass, production compilation, and encoded media dimensions/duration/frame count. No physical-phone frame-rate measurement has been made; this is not a verified 60fps claim.
diff --git a/docs/tournaments.md b/docs/tournaments.md
new file mode 100644
index 0000000..b3c15a2
--- /dev/null
+++ b/docs/tournaments.md
@@ -0,0 +1,44 @@
+# Community tournaments
+
+Signed-in users with active accounts can create a tournament at `/tournaments` and share its `/tournaments/{id}` page. The organizer chooses a published map with at least five distinct panorama locations, a difficulty, a scheduled start, a player cap from 2 to 32, and 1 to 10 tournament rounds. Every tournament round contains exactly five mini-rounds.
+
+The organizer selects either a shared time budget across the five mini-rounds (the default), or a separate timer for each mini-round. Shared budgets accept 15–3000 seconds; per-mini-round timers accept 15–600 seconds. Both use 15-second increments. The shared clock starts when the first mini-round becomes playable, excludes initial loading, and keeps running through later loading and result screens. When it expires, all unplayed mini-rounds close with zero points. Difficulty settings are saved with the tournament, so later preset changes do not alter a running competition.
+
+The schedule form displays the application server's timezone. Its local date/time input is interpreted in that zone, even when the organizer's browser uses another zone. The server validates future starts within 90 days; browser clocks do not decide when gameplay begins. Discord announcements include the server date/time and zone, plus Discord timestamps displayed in each reader's local time.
+
+The organizer joins automatically. Registration and departures close at the scheduled start; the roster is then locked. At least two active registered players are required to start, otherwise the tournament is cancelled. An organizer can cancel their scheduled or running tournament. Scheduled settings can be changed without removing already registered players through a lower cap. Owners cannot leave their own event; cancellation is available instead.
+
+All players receive the same locations within a mini-round. The server decides deadlines, validates guesses, calculates scores, and reveals results only for closed mini-rounds. Tournament pages show final standings at completion. Inactive accounts cannot keep playing or win. Tournament membership credentials remain on the server and ordinary multiplayer joins cannot bypass the tournament roster.
+
+The latest eligible tournament winner is featured in first place on the global, all-difficulties leaderboard for seven days after completion. This does not alter their earned score or rewrite difficulty-specific rankings. A newer eligible winner takes over the featured position; expiry restores the ordinary ordering.
+
+## Discord announcements
+
+An announcement is queued only when the organizer enables the Discord setting. Delivery becomes due 15 minutes before the scheduled start. If the setting is enabled or an event is created within that window, the next dispatcher run can announce it while registration remains open. Started, cancelled, expired, or opted-out events are not announced. Rescheduling an unsent event moves its announcement time. Each tournament has one durable announcement record; editing an already announced event does not ping the channel again.
+
+Tournament posts use the existing server-only `DISCORD_BOT_TOKEN`. The destination is fixed to news channel `1558048830770122772`; no additional channel environment variable is needed. Only roles `1558053709554909205` and `1558052962817933322` are allowed to receive mention notifications. User names and tournament titles are escaped, and users, `@everyone`, and `@here` cannot add notifications.
+
+The bot needs access to the destination channel with Send Messages and Embed Links. The selected roles must be mentionable, or the bot must have the relevant role-mention permission. Bot credentials must stay in server environment configuration and must never use a `NEXT_PUBLIC_` variable.
+
+Delivery uses two-minute database leases, a stable Discord nonce, bounded request timeouts, and up to eight attempts with backoff. Rate limits and temporary failures return work to the queue. Permanent configuration errors stop the attempt and save a safe error message. Discord deduplicates matching nonces only within its recent-message window; an unusually delayed ambiguous network response can still prevent an absolute exactly-once guarantee.
+
+## Runtime and deployment
+
+Apply the checked-in migrations before deploying the application code, in filename order. The relevant additions are:
+
+1. `supabase/migrations/20261018060642_extended_difficulty_rules.sql`
+2. `supabase/migrations/20261018060844_tournaments.sql`
+
+Use the project's normal controlled database migration process. If applying these through the Supabase SQL editor, run each complete file once, in that order, after the earlier project migrations. These files are schema changes, not application `.env` settings. Do not clear production data to repair a missing table. If PostgREST reports a stale schema cache after a successful migration, reload its schema cache with `NOTIFY pgrst, 'reload schema';`.
+
+The migration creates the tournament tables, membership and private-room guards, the durable announcement outbox, and server-only RPCs. RLS is enabled and anonymous/authenticated direct access is revoked; the application uses its server-side Supabase secret to perform authorized actions. Keep the application and worker on the same schema version.
+
+`npm start` starts the web application and companion worker. The worker calls `tick_tournaments` and drains due tournament announcements every 30 seconds, independently of panorama rendering. Keep that worker running: scheduling must continue even when nobody has the site open. When hosting only the web application, arrange a scheduled authenticated GET to `/api/integrations/discord/dispatch` at least once per minute with `Authorization: Bearer `. The endpoint rejects requests without the matching server secret and also advances tournaments before draining announcements.
+
+A missing migration or database connection pauses scheduling; the worker retries and limits its warning to once every five minutes. Repair the schema/connectivity rather than relying on visitors to advance events. Failed Discord deliveries are stored in `tournament_announcements`; fix the bot configuration before retrying. Rescheduling or explicitly changing an unsent announcement setting can requeue it according to the migration's retry rules.
+
+## Verification
+
+`npm test` includes tournament schema-validation, API, timer, and announcement tests. `npm run typecheck -- --incremental false` checks the server/client contracts. `node scripts/test-tournaments-sql.mjs` runs SQL integration tests against an isolated in-memory PostgreSQL-compatible fixture and requires the local test dependency at `.data/sql-verification/node_modules/@electric-sql/pglite`.
+
+The SQL suite covers the 32-player limit, locked owner actions, registration, shared game engine, standings, announcement leases, and restricted direct database access. Announcement unit tests use fake Discord clients; they do not publish messages. Apply production migrations and verify a real opted-in event deliberately after deployment; no external test announcement is required during local development.
diff --git a/lib/api.ts b/lib/api.ts
index 19bf3f1..99e3c54 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -24,6 +24,8 @@ import { jsonBody } from "./json-body";
import { accountApi } from "./account-api";
import { accountProfile } from "./profile";
import { communityApi } from "./community-api";
+import { tournamentApi } from "./tournament-api";
+import { tournamentDiscordAfterResponse } from "./tournament-discord-server";
import { multiplayerApi } from "./multiplayer-api";
import { bugReportApi } from "./bug-report-api";
import { moderatorUsersApi } from "./moderator-users-api";
@@ -155,6 +157,20 @@ export async function handle(request: NextRequest, path: string[]) {
return accountProfile(account);
},
});
+ if (path[0] === "tournaments")
+ return await tournamentApi(request, path, {
+ db: admin(),
+ account: async () => {
+ const account = await user();
+ if (account) await accountProfile(account);
+ return account;
+ },
+ jsonBody,
+ limit,
+ mediaUrls,
+ announce: tournamentDiscordAfterResponse,
+ serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ });
if (path[0] === "rooms")
return await multiplayerApi(request, path, { jsonBody, limit });
if (path[0] === "bug-reports")
@@ -171,10 +187,17 @@ export async function handle(request: NextRequest, path: string[]) {
const presets = required(
await client
.from("difficulty_presets")
- .select("id,name,seconds,zoom")
+ .select(
+ "id,name,seconds,zoom,pan,compass,coordinates,map_zoom,scoring",
+ )
.order("name", { ascending: true }),
);
- return NextResponse.json({ presets });
+ return NextResponse.json({
+ presets: presets.map(({ map_zoom, ...preset }) => ({
+ ...preset,
+ mapZoom: map_zoom,
+ })),
+ });
}
if (method === "POST") {
const body = customDifficultySchema.parse(await jsonBody(request));
@@ -182,13 +205,18 @@ export async function handle(request: NextRequest, path: string[]) {
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
await limit("difficulty-presets:" + hash(address), 8, 3600);
+ const { mapZoom, ...fields } = body;
const { data, error } = await client
.from("difficulty_presets")
- .insert(body)
- .select("id,name,seconds,zoom")
+ .insert({ ...fields, map_zoom: mapZoom })
+ .select(
+ "id,name,seconds,zoom,pan,compass,coordinates,map_zoom,scoring",
+ )
.single();
checkDifficultyPresetError(error);
- return NextResponse.json({ preset: data });
+ return NextResponse.json({
+ preset: data ? { ...data, mapZoom: data.map_zoom } : null,
+ });
}
}
if (path[0] === "health" && path.length === 1 && method === "GET") {
diff --git a/lib/discord-client.ts b/lib/discord-client.ts
index 42f8783..a404681 100644
--- a/lib/discord-client.ts
+++ b/lib/discord-client.ts
@@ -46,7 +46,9 @@ export type DiscordMessage = {
fields: { name: string; value: string; inline: boolean }[];
footer: { text: string };
}[];
- allowed_mentions: { parse: never[] };
+ allowed_mentions:
+ | { parse: never[] }
+ | { roles: string[]; users: never[]; replied_user: false };
nonce: string;
enforce_nonce: true;
};
diff --git a/lib/multiplayer-api.ts b/lib/multiplayer-api.ts
index 53014d1..71294ec 100644
--- a/lib/multiplayer-api.ts
+++ b/lib/multiplayer-api.ts
@@ -81,6 +81,17 @@ export async function multiplayerApi(
return respond({ id });
}
const id = z.string().uuid().parse(path[1]);
+ const linked = await client
+ .from("multiplayer_rooms")
+ .select("tournament_id")
+ .eq("id", id)
+ .maybeSingle();
+ if (linked.error) required(linked);
+ if (linked.data?.tournament_id)
+ throw new ServiceError(
+ "Use the tournament page to join or play this event",
+ 409,
+ );
const action = path[2] ?? "view";
if (action === "discord" && path.length === 3 && request.method === "POST") {
const view = required(
diff --git a/lib/tournament-api.ts b/lib/tournament-api.ts
new file mode 100644
index 0000000..53ea87f
--- /dev/null
+++ b/lib/tournament-api.ts
@@ -0,0 +1,188 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type { Database } from "@/shared/database";
+import {
+ tournamentEdit,
+ tournamentSettings,
+ type TournamentView,
+} from "@/shared/tournaments";
+import {
+ datasetSchema,
+ faces,
+ panoramaPath,
+ viewpointSchema,
+} from "@/shared/protocol";
+import { roomGuessSchema, roomRoundSchema } from "@/shared/multiplayer";
+import { ServiceError, checkServiceError } from "./service-error";
+
+type Dependencies = {
+ db: SupabaseClient;
+ account: () => Promise<{ id: string } | null>;
+ jsonBody: (request: NextRequest) => Promise;
+ limit: (key: string, count: number, seconds: number) => Promise;
+ mediaUrls: (paths: (string | null)[]) => Promise<(string | null)[]>;
+ announce: (target?: string) => void;
+ serverTimezone: string;
+};
+function unwrap(result: {
+ data: T;
+ error: { message: string; code?: string } | null;
+}): NonNullable {
+ checkServiceError(result.error);
+ if (result.data == null) throw new ServiceError("Tournament not found", 404);
+ return result.data;
+}
+export async function tournamentApi(
+ request: NextRequest,
+ path: string[],
+ deps: Dependencies,
+) {
+ const { db } = deps;
+ const respond = (value: unknown) =>
+ NextResponse.json(value, { headers: { "Cache-Control": "no-store" } });
+ if (path.length === 1 && request.method === "GET") {
+ const tournaments = unwrap(
+ await db
+ .from("tournaments")
+ .select(
+ "id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord,finished_at,winner_id",
+ )
+ .order("starts_at", { ascending: false })
+ .limit(100),
+ );
+ return respond({
+ tournaments,
+ serverNow: Date.now(),
+ serverTimezone: deps.serverTimezone,
+ });
+ }
+ if (path.length === 1 && request.method === "POST") {
+ const account = await deps.account();
+ if (!account) throw new ServiceError("Sign in to create a tournament", 401);
+ const body = tournamentSettings.parse(await deps.jsonBody(request));
+ const start = Date.parse(body.startsAt),
+ now = Date.now();
+ if (start <= now || start > now + 90 * 86400000)
+ throw new ServiceError(
+ "Schedule the tournament within the next 90 days",
+ 400,
+ );
+ await deps.limit("tournaments:create:" + account.id, 5, 86400);
+ const id = unwrap(
+ await db.rpc("create_tournament", {
+ p_owner: account.id,
+ p_payload: { ...body, serverTimezone: deps.serverTimezone },
+ }),
+ );
+ deps.announce(String(id));
+ return respond({ id });
+ }
+ if (path.length < 2 || path.length > 3)
+ throw new ServiceError("Not found", 404);
+ const id = z.string().uuid().parse(path[1]);
+ const action = path[2] ?? "view";
+ const read =
+ request.method === "GET" && (action === "view" || action === "media");
+ const write =
+ request.method === "POST" &&
+ [
+ "join",
+ "leave",
+ "cancel",
+ "edit",
+ "start",
+ "ready",
+ "guess",
+ "next",
+ ].includes(action);
+ if (!read && !write) throw new ServiceError("Not found", 404);
+ const account = await deps.account();
+ if (!account && (write || action === "media"))
+ throw new ServiceError("Sign in to join or play a tournament", 401);
+ let payload = {};
+ if (write) {
+ const body = await deps.jsonBody(request);
+ payload =
+ action === "guess"
+ ? roomGuessSchema
+ .extend({ tournamentRound: z.number().int().min(0).max(9) })
+ .strict()
+ .parse(body)
+ : ["ready", "next"].includes(action)
+ ? roomRoundSchema
+ .extend({ tournamentRound: z.number().int().min(0).max(9) })
+ .strict()
+ .parse(body)
+ : action === "edit"
+ ? tournamentEdit.parse(body)
+ : z.object({}).strict().parse(body);
+ await deps.limit(
+ `tournaments:${action}:${account!.id}`,
+ action === "guess" || action === "ready" ? 180 : 30,
+ 60,
+ );
+ }
+ const view = unwrap(
+ await db.rpc("tournament_action", {
+ p_tournament: id,
+ p_account: account?.id ?? null,
+ p_action: read ? "view" : action,
+ p_payload: payload,
+ }),
+ ) as unknown as TournamentView;
+ if (write && action === "edit") deps.announce(id);
+ if (action !== "media") return respond(view);
+ const requestedRound = Number(
+ z
+ .string()
+ .regex(/^[0-9]$/)
+ .parse(request.nextUrl.searchParams.get("tournamentRound") ?? undefined),
+ );
+ if (requestedRound !== view.currentRound)
+ throw new ServiceError(
+ "The tournament advanced. Reconnect to load the current round.",
+ 409,
+ );
+ const room = view.currentRoom;
+ if (
+ !room?.you ||
+ !room.id ||
+ !["loading", "playing", "results", "finished"].includes(room.status)
+ )
+ throw new ServiceError("This round is not available yet", 409);
+ const [record, point] = await Promise.all([
+ db
+ .from("multiplayer_rooms")
+ .select("revision_id")
+ .eq("id", room.id)
+ .single(),
+ db
+ .from("multiplayer_rounds")
+ .select("viewpoint")
+ .eq("room_id", room.id)
+ .eq("round_number", room.round)
+ .single(),
+ ]);
+ const revision = unwrap(
+ await db
+ .from("revisions")
+ .select("asset_root,manifest")
+ .eq("id", unwrap(record).revision_id)
+ .single(),
+ );
+ const manifest = datasetSchema.parse(revision.manifest);
+ const viewpoint = viewpointSchema.parse(unwrap(point).viewpoint);
+ const signed = await deps.mediaUrls([
+ manifest.mapPath ?? `${revision.asset_root}/map.webp`,
+ ...faces.map((face) =>
+ panoramaPath(revision.asset_root, viewpoint, `${face}.webp`),
+ ),
+ ]);
+ return respond({
+ round: room.round,
+ map: signed[0],
+ bounds: manifest.bounds,
+ faces: signed.slice(1),
+ });
+}
diff --git a/lib/tournament-discord-server.ts b/lib/tournament-discord-server.ts
new file mode 100644
index 0000000..f1be153
--- /dev/null
+++ b/lib/tournament-discord-server.ts
@@ -0,0 +1,18 @@
+import "server-only";
+import { after } from "next/server";
+import { admin } from "./supabase";
+import { discordConfiguration } from "./discord-client";
+import { dispatchTournamentAnnouncements } from "./tournament-discord";
+
+export function tournamentDiscordAfterResponse(target?: string) {
+ if (!discordConfiguration(process.env).token) return;
+ after(async () => {
+ try {
+ await dispatchTournamentAnnouncements(admin(), process.env, target);
+ } catch {
+ console.error(
+ "Tournament announcement delivery paused; saved announcements will retry.",
+ );
+ }
+ });
+}
diff --git a/lib/tournament-discord.ts b/lib/tournament-discord.ts
new file mode 100644
index 0000000..93de73d
--- /dev/null
+++ b/lib/tournament-discord.ts
@@ -0,0 +1,260 @@
+import { createHash, randomUUID } from "node:crypto";
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type { Database } from "../shared/database";
+import {
+ DiscordClient,
+ DiscordDeliveryError,
+ discordConfiguration,
+ discordText,
+ type DiscordMessage,
+} from "./discord-client";
+
+export const TOURNAMENT_NEWS_CHANNEL = "1558048830770122772";
+export const TOURNAMENT_EVENT_ROLES = [
+ "1558053709554909205",
+ "1558052962817933322",
+] as const;
+export const TOURNAMENT_ANNOUNCEMENT_LEAD_MS = 15 * 60 * 1000;
+
+export type TournamentAnnouncementSource = {
+ id: string;
+ name: string;
+ owner: string;
+ starts_at: string;
+ server_timezone: string;
+ player_limit: number;
+ round_count: number;
+ round_time_seconds: number;
+ timer_mode: "shared" | "perMini";
+ status: string;
+ announce_discord: boolean;
+};
+export type TournamentAnnouncementJob = {
+ id: string;
+ tournament_id: string;
+ state: "pending" | "sending" | "sent" | "skipped" | "failed";
+ attempts: number;
+ next_attempt_at: string;
+ lease_owner: string | null;
+ lease_until: string | null;
+ channel_id: string | null;
+ message_id: string | null;
+ sent_at: string | null;
+ last_error: string | null;
+};
+
+export function tournamentAnnouncementMessage(
+ event: string,
+ source: TournamentAnnouncementSource,
+): DiscordMessage {
+ if (!/^[a-zA-Z0-9-]{1,80}$/.test(source.id))
+ throw new Error("Invalid tournament link.");
+ const timestamp = Date.parse(source.starts_at);
+ if (!Number.isFinite(timestamp))
+ throw new Error("Invalid tournament start time.");
+ const serverTime = new Intl.DateTimeFormat("en-GB", {
+ dateStyle: "full",
+ timeStyle: "short",
+ timeZone: source.server_timezone,
+ }).format(timestamp);
+ const link = `https://minegeo.site/tournaments/${source.id}`;
+ return {
+ content: `${TOURNAMENT_EVENT_ROLES.map((role) => `<@&${role}>`).join(", ")}.\n\n## ${discordText(source.name, 120)} will starting soon!\n\nAccess it by this link [${link}](${link})\n\n-------`,
+ allowed_mentions: {
+ roles: [...TOURNAMENT_EVENT_ROLES],
+ users: [],
+ replied_user: false,
+ },
+ nonce: createHash("sha256")
+ .update(`tournament:${event}`)
+ .digest("hex")
+ .slice(0, 24),
+ enforce_nonce: true,
+ embeds: [
+ {
+ title: "A community expedition is about to begin!",
+ url: link,
+ description:
+ "Join the lobby before the scheduled start. Play the same locations, climb the final standings, and compete for the champion spotlight on the global leaderboard for seven days.",
+ color: 0x55d6ad,
+ thumbnail: { url: "https://minegeo.site/images/minegeo-mark.webp" },
+ fields: [
+ {
+ name: "Organizer",
+ value: discordText(source.owner, 80),
+ inline: true,
+ },
+ {
+ name: "Players",
+ value: `Up to ${source.player_limit}`,
+ inline: true,
+ },
+ {
+ name: "Format",
+ value: `${source.round_count} tournament round${source.round_count === 1 ? "" : "s"} · 5 mini-rounds each`,
+ inline: false,
+ },
+ {
+ name: "Round timer",
+ value:
+ source.timer_mode === "shared"
+ ? `${source.round_time_seconds} seconds shared across all 5 mini-rounds`
+ : `${source.round_time_seconds} seconds per mini-round`,
+ inline: false,
+ },
+ {
+ name: "Starts · server time",
+ value: `${serverTime} (${discordText(source.server_timezone, 80)})`,
+ inline: false,
+ },
+ {
+ name: "Starts · your local time",
+ value: ` · `,
+ inline: false,
+ },
+ ],
+ footer: { text: "MineGeo · Community tournaments" },
+ },
+ ],
+ };
+}
+
+export async function deliverTournamentAnnouncement(
+ job: TournamentAnnouncementJob,
+ dependencies: {
+ client: Pick;
+ load: () => Promise;
+ finish: (patch: Partial) => Promise;
+ now?: () => number;
+ },
+) {
+ const { client, load, finish, now = Date.now } = dependencies;
+ const released = { lease_owner: null, lease_until: null };
+ try {
+ const source = await load();
+ if (
+ !source ||
+ source.id !== job.tournament_id ||
+ !source.announce_discord ||
+ source.status !== "scheduled" ||
+ Date.parse(source.starts_at) <= now()
+ ) {
+ await finish({ ...released, state: "skipped", last_error: null });
+ return;
+ }
+ const due = Date.parse(source.starts_at) - TOURNAMENT_ANNOUNCEMENT_LEAD_MS;
+ // Rescheduling must never produce an early announcement, even with an old lease.
+ if (due > now()) {
+ await finish({
+ ...released,
+ state: "pending",
+ next_attempt_at: new Date(due).toISOString(),
+ last_error: null,
+ });
+ return;
+ }
+ const message = await client.send(
+ TOURNAMENT_NEWS_CHANNEL,
+ tournamentAnnouncementMessage(job.id, source),
+ );
+ await finish({
+ ...released,
+ state: "sent",
+ channel_id: TOURNAMENT_NEWS_CHANNEL,
+ message_id: message,
+ sent_at: new Date(now()).toISOString(),
+ last_error: null,
+ });
+ } catch (failure) {
+ const error =
+ failure instanceof DiscordDeliveryError
+ ? failure
+ : new DiscordDeliveryError(
+ "Tournament announcement could not be confirmed. Delivery will retry.",
+ );
+ await finish({
+ ...released,
+ state: error.permanent || job.attempts >= 8 ? "failed" : "pending",
+ last_error: error.message,
+ next_attempt_at: new Date(
+ now() +
+ Math.max(
+ error.retryAfter,
+ Math.min(3600, 30 * 2 ** (job.attempts - 1)),
+ ) *
+ 1000,
+ ).toISOString(),
+ });
+ }
+}
+
+export async function dispatchTournamentAnnouncements(
+ db: SupabaseClient,
+ env: Record = process.env,
+ target?: string,
+) {
+ const config = discordConfiguration(env);
+ if (!config.token) return;
+ const worker = randomUUID();
+ const claimed = await db
+ .rpc("claim_tournament_announcements", {
+ p_worker: worker,
+ p_target: target ?? null,
+ })
+ .abortSignal(AbortSignal.timeout(10000));
+ if (claimed.error)
+ throw new Error("Could not claim saved tournament announcements.");
+ const client = new DiscordClient(config.token);
+ // Claiming a batch must not make its last job wait through every other send:
+ // the durable leases last two minutes; rate-limited sends return to the queue.
+ const deliveries = await Promise.allSettled(
+ (claimed.data ?? []).map((job) =>
+ deliverTournamentAnnouncement(job, {
+ client,
+ load: async () => {
+ const tournament = await db
+ .from("tournaments")
+ .select(
+ "id,name,owner_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,status,announce_discord",
+ )
+ .eq("id", job.tournament_id)
+ .abortSignal(AbortSignal.timeout(10000))
+ .maybeSingle();
+ if (tournament.error)
+ throw new Error("Could not load tournament announcement.");
+ if (!tournament.data) return null;
+ const owner = tournament.data.owner_id
+ ? await db
+ .from("profiles")
+ .select("display_name")
+ .eq("id", tournament.data.owner_id)
+ .abortSignal(AbortSignal.timeout(10000))
+ .maybeSingle()
+ : { data: null, error: null };
+ if (owner.error)
+ throw new Error("Could not load tournament organizer.");
+ return {
+ ...tournament.data,
+ owner: owner.data?.display_name ?? "Explorer",
+ };
+ },
+ finish: async (patch) => {
+ const result = await db
+ .from("tournament_announcements")
+ .update(patch)
+ .eq("id", job.id)
+ .eq("lease_owner", worker)
+ .select("id")
+ .abortSignal(AbortSignal.timeout(10000))
+ .single();
+ if (result.error)
+ throw new Error("Could not save tournament announcement delivery.");
+ },
+ }),
+ ),
+ );
+ if (deliveries.some((result) => result.status === "rejected"))
+ throw new Error(
+ "Some tournament deliveries could not be saved; their leases will expire for retry.",
+ );
+}
diff --git a/package.json b/package.json
index de382ca..72be1b0 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"test:multiplayer": "node scripts/test-multiplayer-sql.mjs",
"test:round-panoramas": "node scripts/test-round-panoramas-sql.mjs",
"test:difficulty-presets": "node scripts/test-difficulty-presets-sql.mjs",
+ "test:tournaments": "node scripts/test-tournaments-sql.mjs",
"test:renderer": "node scripts/test-renderer.mjs",
"render:map": "node scripts/render-map.mjs",
"test:production": "node scripts/test-production.mjs",
diff --git a/public/media/minegeo-discord-banner.gif b/public/media/minegeo-discord-banner.gif
index ab81811..6f23ccf 100644
Binary files a/public/media/minegeo-discord-banner.gif and b/public/media/minegeo-discord-banner.gif differ
diff --git a/public/media/minegeo-discord-banner.png b/public/media/minegeo-discord-banner.png
index b9d88cf..d199d30 100644
Binary files a/public/media/minegeo-discord-banner.png and b/public/media/minegeo-discord-banner.png differ
diff --git a/public/media/minegeo-trailer-poster.jpg b/public/media/minegeo-trailer-poster.jpg
index aabd8d2..4aeb6b8 100644
Binary files a/public/media/minegeo-trailer-poster.jpg and b/public/media/minegeo-trailer-poster.jpg differ
diff --git a/public/media/minegeo-trailer.mp4 b/public/media/minegeo-trailer.mp4
index a5f724a..c493eb1 100644
Binary files a/public/media/minegeo-trailer.mp4 and b/public/media/minegeo-trailer.mp4 differ
diff --git a/public/media/minegeo-trailer.vtt b/public/media/minegeo-trailer.vtt
index 72c26f4..e2f4e49 100644
--- a/public/media/minegeo-trailer.vtt
+++ b/public/media/minegeo-trailer.vtt
@@ -1,25 +1,25 @@
WEBVTT
-00:00.000 --> 00:04.000
+00:00.000 --> 00:03.000
[Ambient electronic music. A floating voxel island emerges.]
-Every world has a place. But can you find yours?
+Get lost. Find your world.
-00:04.000 --> 00:09.000
+00:03.000 --> 00:08.000
[Screenshot of the real MineGeo homepage.]
-Your expedition starts here. A new way to see Minecraft.
+Your expedition starts here. A Minecraft guessing game. A new way to explore.
-00:09.000 --> 00:14.000
+00:08.000 --> 00:13.000
[Screenshot of the community map catalog.]
Pick your world. Browse published maps from the community.
-00:14.000 --> 00:19.000
+00:13.000 --> 00:18.000
[Real in-game panorama with a pin selected on the map.]
Look. Guess. Discover. Look around, then place your pin.
-00:19.000 --> 00:24.000
+00:18.000 --> 00:23.000
[Screenshot of map details and difficulty options.]
Make it your expedition. Choose your map, difficulty, and game mode.
-00:24.000 --> 00:30.000
-MineGeo. Get lost in a familiar world.
+00:23.000 --> 00:30.000
+MineGeo. Get lost. Find your world.
Your next adventure starts at minegeo.site.
diff --git a/scripts/render-cinematics.mjs b/scripts/render-cinematics.mjs
index a2f155a..248b27d 100644
--- a/scripts/render-cinematics.mjs
+++ b/scripts/render-cinematics.mjs
@@ -18,6 +18,8 @@ await mkdir(work, { recursive: true });
const routes = new Map([
["/", "cinematics/index.html"],
["/cinematics/scene.mjs", "cinematics/scene.mjs"],
+ ["/cinematics/composition.mjs", "cinematics/composition.mjs"],
+ ["/cinematics/motion.mjs", "cinematics/motion.mjs"],
["/three/three.module.js", "node_modules/three/build/three.module.js"],
["/three/three.core.js", "node_modules/three/build/three.core.js"],
["/fonts/Monocraft.ttf", "public/fonts/Monocraft.ttf"],
@@ -134,7 +136,7 @@ if (previewOnly) {
"-vcodec",
"png",
"-framerate",
- "24",
+ "30",
"-i",
"pipe:0",
"-i",
@@ -168,14 +170,17 @@ if (previewOnly) {
encoderError = e;
});
const done = once(ffmpeg, "close");
- for (let frame = 0; frame < 720; frame++) {
+ for (let frame = 0; frame < 900; frame++) {
if (encoderError) throw encoderError;
- await page.evaluate((t) => window.renderCinematic(t), frame / 24);
- const png = await page.screenshot({ type: "png" });
+ const encoded = await page.evaluate((t) => {
+ window.renderCinematic(t);
+ return window.cinematicCanvas.toDataURL("image/png").split(",")[1];
+ }, frame / 30);
+ const png = Buffer.from(encoded, "base64");
if (!ffmpeg.stdin.write(png)) await once(ffmpeg.stdin, "drain");
- if (frame % 48 === 0)
+ if (frame % 60 === 0)
console.log(
- `Trailer: ${frame}/720 frames (${Math.round((frame / 720) * 100)}%)`,
+ `Trailer: ${frame}/900 frames (${Math.round((frame / 900) * 100)}%)`,
);
}
ffmpeg.stdin.end();
@@ -251,7 +256,7 @@ function createSoundtrack() {
Math.sin(2 * Math.PI * 55 * beat - 7 * Math.exp(-beat * 25)) *
Math.exp(-beat * 12) *
0.045;
- const swell = [4, 9, 14, 19, 24].reduce(
+ const swell = [3, 8, 13, 18, 23].reduce(
(n, c) => n + Math.exp(-(((t - c) / 0.45) ** 2)),
0,
);
@@ -276,28 +281,28 @@ function createSoundtrack() {
function createCaptions() {
return `WEBVTT
-00:00.000 --> 00:04.000
+00:00.000 --> 00:03.000
[Ambient electronic music. A floating voxel island emerges.]
-Every world has a place. But can you find yours?
+Get lost. Find your world.
-00:04.000 --> 00:09.000
+00:03.000 --> 00:08.000
[Screenshot of the real MineGeo homepage.]
-Your expedition starts here. A new way to see Minecraft.
+Your expedition starts here. A Minecraft guessing game. A new way to explore.
-00:09.000 --> 00:14.000
+00:08.000 --> 00:13.000
[Screenshot of the community map catalog.]
Pick your world. Browse published maps from the community.
-00:14.000 --> 00:19.000
+00:13.000 --> 00:18.000
[Real in-game panorama with a pin selected on the map.]
Look. Guess. Discover. Look around, then place your pin.
-00:19.000 --> 00:24.000
+00:18.000 --> 00:23.000
[Screenshot of map details and difficulty options.]
Make it your expedition. Choose your map, difficulty, and game mode.
-00:24.000 --> 00:30.000
-MineGeo. Get lost in a familiar world.
+00:23.000 --> 00:30.000
+MineGeo. Get lost. Find your world.
Your next adventure starts at minegeo.site.
`;
}
diff --git a/scripts/test-difficulty-presets-sql.mjs b/scripts/test-difficulty-presets-sql.mjs
index d4fff7e..4e727d9 100644
--- a/scripts/test-difficulty-presets-sql.mjs
+++ b/scripts/test-difficulty-presets-sql.mjs
@@ -119,6 +119,7 @@ try {
)
).rows[0].id;
const manifest = {
+ bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
@@ -163,8 +164,174 @@ try {
[id],
)
).rows[0].difficulty_settings,
- { id: preset, name: "Playable preset 45", seconds: 45, zoom: false },
+ {
+ id: preset,
+ name: "Playable preset 45",
+ seconds: 45,
+ zoom: false,
+ pan: true,
+ compass: true,
+ coordinates: true,
+ mapZoom: true,
+ scoring: "normal",
+ },
);
+ await assert.rejects(
+ db.query(
+ "update public.difficulty_presets set scoring='unsupported' where id=$1",
+ [preset],
+ ),
+ (error) => error.code === "23514",
+ );
+ await db.query(
+ "update public.difficulty_presets set pan=false,compass=false,coordinates=false,map_zoom=false,scoring='strict' where id=$1",
+ [preset],
+ );
+ const settings = (
+ await db.query("select public.resolve_difficulty_preset($1) settings", [
+ preset,
+ ])
+ ).rows[0].settings;
+ assert.deepEqual(settings, {
+ id: preset,
+ name: "Playable preset 45",
+ seconds: 45,
+ zoom: false,
+ pan: false,
+ compass: false,
+ coordinates: false,
+ mapZoom: false,
+ scoring: "strict",
+ });
+ // Existing sessions retain the original curve even after the library changes.
+ assert.equal(
+ (
+ await db.query(
+ "select difficulty_settings->>'scoring' scoring from public.game_sessions where id=$1",
+ [solo],
+ )
+ ).rows[0].scoring,
+ "normal",
+ );
+ for (const scoring of ["strict", "normal", "gentle"]) {
+ await db.query(
+ "update public.difficulty_presets set scoring=$1 where id=$2",
+ [scoring, preset],
+ );
+ const sid = (
+ await db.query("select public.create_game($1,$2,$3,$4) id", [
+ map,
+ preset,
+ owner,
+ "score-" + scoring,
+ ])
+ ).rows[0].id;
+ assert.equal(
+ (
+ await db.query(
+ "select count(*)::int total from public.game_rounds where session_id=$1",
+ [sid],
+ )
+ ).rows[0].total,
+ 5,
+ );
+ await db.query("select public.activate_round($1,0,$2)", [
+ sid,
+ "score-" + scoring,
+ ]);
+ const point = (
+ await db.query(
+ "select viewpoint from public.game_rounds where session_id=$1 and round_number=0",
+ [sid],
+ )
+ ).rows[0].viewpoint;
+ const expected = (
+ await db.query("select public.score_difficulty_guess($1,$2,$3) points", [
+ Math.hypot(32 - point.x, 32 - point.z),
+ Math.hypot(64, 64),
+ scoring,
+ ])
+ ).rows[0].points;
+ const actual = (
+ await db.query("select public.submit_guess($1,0,$2,32,32) result", [
+ sid,
+ "score-" + scoring,
+ ])
+ ).rows[0].result;
+ assert.equal(actual.score, expected);
+ const room = (
+ await db.query(
+ "select public.create_map_room($1,$2,'Scoring host',2,$3,$4) id",
+ [map, preset, "a".repeat(64), owner],
+ )
+ ).rows[0].id;
+ await db.query(
+ "select public.multiplayer_action($1,$2,'join','{\"name\":\"Guest\"}')",
+ [room, "c".repeat(64)],
+ );
+ await db.query("select public.multiplayer_action($1,$2,'start')", [
+ room,
+ "a".repeat(64),
+ ]);
+ await db.query(
+ "select public.multiplayer_action($1,$2,'ready','{\"round\":0}')",
+ [room, "a".repeat(64)],
+ );
+ await db.query(
+ "select public.multiplayer_action($1,$2,'ready','{\"round\":0}')",
+ [room, "c".repeat(64)],
+ );
+ const mp = (
+ await db.query(
+ "select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
+ [room],
+ )
+ ).rows[0].viewpoint;
+ await db.query(
+ 'select public.multiplayer_action($1,$2,\'guess\',\'{"round":0,"x":32,"z":32}\')',
+ [room, "a".repeat(64)],
+ );
+ const scored = (
+ await db.query(
+ "select result from public.multiplayer_guesses where room_id=$1 and round_number=0",
+ [room],
+ )
+ ).rows[0].result;
+ const expectedMulti = (
+ await db.query("select public.score_difficulty_guess($1,$2,$3) points", [
+ Math.hypot(32 - mp.x, 32 - mp.z),
+ Math.hypot(64, 64),
+ scoring,
+ ])
+ ).rows[0].points;
+ assert.equal(scored.score, expectedMulti);
+ }
+ const decay = (
+ await db.query(
+ "select public.score_difficulty_guess(20,100,'strict') strict,public.score_difficulty_guess(20,100,'normal') normal,public.score_difficulty_guess(20,100,'gentle') gentle",
+ )
+ ).rows[0];
+ assert.ok(decay.strict < decay.normal && decay.normal < decay.gentle);
+ assert.equal(
+ (
+ await db.query(
+ "select public.score_difficulty_guess(0,100,'strict') points",
+ )
+ ).rows[0].points,
+ 5000,
+ );
+ await assert.rejects(
+ db.query("select public.score_difficulty_guess(1,100,'unknown')"),
+ /Invalid scoring rules/,
+ );
+ assert.equal(
+ (
+ await db.query(
+ "select has_function_privilege('anon','public.score_difficulty_guess(double precision,double precision,text)','EXECUTE') allowed",
+ )
+ ).rows[0].allowed,
+ false,
+ );
assert.equal(
(
await db.query(
diff --git a/scripts/test-leaderboard-sql.mjs b/scripts/test-leaderboard-sql.mjs
index 54abc26..832bfde 100644
--- a/scripts/test-leaderboard-sql.mjs
+++ b/scripts/test-leaderboard-sql.mjs
@@ -8,7 +8,10 @@ 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);`);
+ create schema storage;
+ create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
+ create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
+ create function storage.foldername(text) returns text[] language sql as $$ select string_to_array($1, '/') $$;`);
for (const file of (await readdir("supabase/migrations"))
.filter((file) => file.endsWith(".sql"))
.sort()) {
@@ -63,7 +66,7 @@ try {
) {
const session = (
await db.query(
- "insert into public.game_sessions(revision_id,owner_id,access_hash,difficulty,current_round) values($1,$2,'private-fixture-secret',$3,$4) returning id",
+ "insert into public.game_sessions(revision_id,owner_id,access_hash,difficulty,difficulty_settings,current_round) values($1,$2,'private-fixture-secret',$3,public.resolve_difficulty_preset($3),$4) returning id",
[revision, owner, difficulty, current],
)
).rows[0].id;
diff --git a/scripts/test-multiplayer-sql.mjs b/scripts/test-multiplayer-sql.mjs
index 57f1e5e..91dfa96 100644
--- a/scripts/test-multiplayer-sql.mjs
+++ b/scripts/test-multiplayer-sql.mjs
@@ -9,7 +9,10 @@ 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);`);
+ create schema storage;
+ create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
+ create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
+ create function storage.foldername(text) returns text[] language sql as $$ select string_to_array($1, '/') $$;`);
for (const file of (await readdir("supabase/migrations"))
.filter((file) => file.endsWith(".sql"))
.sort())
@@ -50,8 +53,8 @@ try {
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)],
+ "select public.create_map_room($1,'hard','Host',$2,$3,$4) as id",
+ [dataset, cap, secret(1), account],
)
).rows[0].id;
}
@@ -166,6 +169,10 @@ try {
console.log(
"Multiplayer SQL account identity: forged names rejected, guest joins retained, authenticated transfer and guest-only closure verified.",
);
+ // Subsequent match fixtures use the same authenticated account as their host.
+ await db.query("update public.profiles set display_name='Host' where id=$1", [
+ account,
+ ]);
for (const cap of [1, 17, null])
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
const room = await create();
diff --git a/scripts/test-tournaments-sql.mjs b/scripts/test-tournaments-sql.mjs
new file mode 100644
index 0000000..a25cc10
--- /dev/null
+++ b/scripts/test-tournaments-sql.mjs
@@ -0,0 +1,518 @@
+import assert from "node:assert/strict";
+import { randomUUID } from "node:crypto";
+import { readFile, readdir } 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;
+ 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,allowed_mime_types text[]);
+ create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
+ create function storage.foldername(text) returns text[] language sql as $$select string_to_array($1,'/')$$;`);
+ for (const file of (await readdir("supabase/migrations"))
+ .filter((f) => f.endsWith(".sql"))
+ .sort())
+ await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
+ const q = async (sql, args = []) => (await db.query(sql, args)).rows;
+ const users = [];
+ for (let i = 0; i < 34; i++) {
+ const id = randomUUID();
+ users.push(id);
+ await q("insert into auth.users values($1)", [id]);
+ await q(
+ "insert into public.profiles(id,display_name) values($1,$2) on conflict(id) do update set display_name=excluded.display_name",
+ [id, `Player ${i}`],
+ );
+ }
+ const map = (
+ await q(
+ `insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
+ values($1,'Tournament fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
+ [users[0]],
+ )
+ )[0].id;
+ const manifest = {
+ name: "Tournament fixture",
+ bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
+ viewpoints: Array.from({ length: 5 }, (_, i) => ({
+ id: `p${i}`,
+ x: 10 + i,
+ y: 64,
+ z: 10 + i,
+ })),
+ };
+ const rev = (
+ await q(
+ "insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
+ [map, JSON.stringify(manifest)],
+ )
+ )[0].id;
+ await q("update public.datasets set revision_id=$1 where id=$2", [rev, map]);
+ const base = {
+ name: "Community Cup",
+ datasetId: map,
+ difficulty: "normal",
+ playerLimit: 32,
+ roundCount: 2,
+ roundTimeSeconds: 30,
+ timerMode: "perMini",
+ startsAt: new Date(Date.now() + 3600000).toISOString(),
+ serverTimezone: "UTC",
+ announceDiscord: true,
+ };
+ const create = async (payload = {}) =>
+ (
+ await q("select public.create_tournament($1,$2) id", [
+ users[0],
+ JSON.stringify({ ...base, ...payload }),
+ ])
+ )[0].id;
+ const act = async (id, user, action = "view", payload = {}) =>
+ (
+ await q("select public.tournament_action($1,$2,$3,$4) value", [
+ id,
+ user,
+ action,
+ JSON.stringify(payload),
+ ])
+ )[0].value;
+ for (const payload of [
+ { playerLimit: 33 },
+ { playerLimit: 1 },
+ { roundCount: 0 },
+ { roundTimeSeconds: 16 },
+ { serverTimezone: "Invalid/Zone" },
+ { startsAt: new Date(0).toISOString() },
+ ])
+ await assert.rejects(create(payload));
+ await assert.rejects(
+ q("select public.create_tournament(null,$1)", [JSON.stringify(base)]),
+ /Active account/,
+ );
+ const tid = await create();
+ for (let i = 1; i < 32; i++) await act(tid, users[i], "join");
+ assert.equal((await act(tid, users[1], "join")).players.length, 32);
+ await assert.rejects(act(tid, users[32], "join"), /full/);
+ await assert.rejects(act(tid, users[1], "edit", {}), /owner/);
+ await assert.rejects(
+ act(tid, users[0], "edit", { playerLimit: 31 }),
+ /exclude/,
+ );
+ await assert.rejects(act(tid, users[0], "start"), /scheduled start/);
+ const worker = randomUUID();
+ assert.equal(
+ (
+ await q("select * from public.claim_tournament_announcements($1,$2)", [
+ worker,
+ tid,
+ ])
+ ).length,
+ 0,
+ );
+ await act(tid, users[0], "edit", {
+ startsAt: new Date(Date.now() + 14 * 60000).toISOString(),
+ });
+ assert.equal(
+ (
+ await q("select * from public.claim_tournament_announcements($1,$2)", [
+ worker,
+ tid,
+ ])
+ ).length,
+ 1,
+ );
+ assert.equal(
+ (
+ await q("select * from public.claim_tournament_announcements($1,$2)", [
+ randomUUID(),
+ tid,
+ ])
+ ).length,
+ 0,
+ );
+ await q(
+ "update public.tournament_announcements set attempts=8,lease_until=clock_timestamp()-interval '1 second' where tournament_id=$1",
+ [tid],
+ );
+ assert.equal(
+ (
+ await q("select * from public.claim_tournament_announcements($1,$2)", [
+ worker,
+ tid,
+ ])
+ ).length,
+ 0,
+ );
+ assert.equal(
+ (
+ await q(
+ "select state from public.tournament_announcements where tournament_id=$1",
+ [tid],
+ )
+ )[0].state,
+ "failed",
+ );
+ await act(tid, users[0], "edit", {
+ startsAt: new Date(Date.now() + 13 * 60000).toISOString(),
+ });
+ assert.equal(
+ (
+ await q("select * from public.claim_tournament_announcements($1,$2)", [
+ worker,
+ tid,
+ ])
+ ).length,
+ 1,
+ );
+ await q(
+ "update public.tournament_announcements set lease_until=clock_timestamp()-interval '1 second' where tournament_id=$1",
+ [tid],
+ );
+ assert.equal(
+ (
+ await q("select * from public.claim_tournament_announcements($1,$2)", [
+ worker,
+ tid,
+ ])
+ ).length,
+ 1,
+ );
+ await act(tid, users[0], "edit", { announceDiscord: false });
+ assert.equal(
+ (
+ await q(
+ "select state from public.tournament_announcements where tournament_id=$1",
+ [tid],
+ )
+ )[0].state,
+ "skipped",
+ );
+ await q(
+ "update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
+ [tid],
+ );
+ await q("select public.tick_tournaments()");
+ let state = await act(tid, users[0]);
+ assert.equal(state.status, "running");
+ assert.equal(state.currentRoom.players.length, 32);
+ assert.equal(state.currentRoom.rounds.length, 5);
+ assert.equal(state.currentRoom.difficultySettings.seconds, 30);
+ assert.equal(state.currentRoom.difficultySettings.roundCount, 5);
+ assert.equal(state.announcementState, "skipped");
+ assert.equal((await act(tid, users[1])).announcementState, null);
+ assert(!JSON.stringify(state).includes("viewpoint"));
+ for (const row of await q(
+ "select access_hash from public.tournament_members where tournament_id=$1",
+ [tid],
+ ))
+ assert(!JSON.stringify(state).includes(row.access_hash));
+ assert.equal((await act(tid, null)).currentRoom, null);
+ await assert.rejects(act(tid, users[32], "join"), /Registration/);
+ await assert.rejects(
+ q(
+ "select public.multiplayer_action($1,$2,'join','{\"name\":\"Intruder\"}')",
+ [state.roomId, "f".repeat(64)],
+ ),
+ /already started/,
+ );
+ await assert.rejects(
+ q(
+ "insert into public.multiplayer_players(room_id,access_hash,display_name,account_id) values($1,$2,'Intruder',$3)",
+ [state.roomId, "f".repeat(64), users[32]],
+ ),
+ /Tournament membership/,
+ );
+ await assert.rejects(
+ q("select public.create_map_room($1,'normal','Host',32,$2,$3)", [
+ map,
+ "a".repeat(64),
+ users[0],
+ ]),
+ /16/,
+ );
+ // Play all ten server-scored mini rounds; the owner wins perfect guesses.
+ for (let outer = 0; outer < 2; outer++) {
+ state = await act(tid, users[0]);
+ const room = state.roomId;
+ for (let mini = 0; mini < 5; mini++) {
+ await q(
+ "update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
+ [room],
+ );
+ await q("select public.tournament_tick($1)", [tid]);
+ await q(
+ "update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1 and status='loading'",
+ [room],
+ );
+ await q("select public.tournament_tick($1)", [tid]);
+ const answer = (
+ await q(
+ "select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=$2",
+ [room, mini],
+ )
+ )[0].viewpoint;
+ await assert.rejects(
+ act(tid, users[0], "guess", {
+ round: mini,
+ tournamentRound: outer - 1,
+ x: answer.x,
+ z: answer.z,
+ }),
+ /round changed/,
+ );
+ state = await act(tid, users[0], "guess", {
+ round: mini,
+ tournamentRound: outer,
+ x: answer.x,
+ z: answer.z,
+ score: 999999,
+ });
+ assert.equal(state.players[0].total, outer * 25000 + mini * 5000); // open round remains private/unscored
+ for (let i = 1; i < 32; i++)
+ state = await act(tid, users[i], "guess", {
+ round: mini,
+ tournamentRound: outer,
+ x: 64,
+ z: 64,
+ score: 999999,
+ });
+ }
+ }
+ state = await act(tid, users[0]);
+ assert.equal(state.status, "finished");
+ assert.equal(state.winnerId, users[0]);
+ assert.equal(state.players[0].total, 50000);
+ const finishedAt = state.finishedAt;
+ await q("select public.tick_tournaments()");
+ assert.equal((await act(tid, users[0])).finishedAt, finishedAt);
+ const board = (await q("select public.player_leaderboard() value"))[0].value;
+ assert.equal(board.entries[0].player_id, users[0]);
+ assert.equal(board.entries[0].rank, 1);
+ assert.equal(board.entries[0].best_score, 0);
+ assert.equal(board.entries[0].tournament_champion.tournament_id, tid);
+ assert.equal(
+ (await q("select public.player_leaderboard('normal') value"))[0].value
+ .entries.length,
+ 0,
+ );
+ assert.equal(
+ (await q("select public.player_leaderboard('all','nonexistent') value"))[0]
+ .value.matching_players,
+ 0,
+ );
+ // Solo scores remain unchanged; winner insertion/de-duplication preserves pagination.
+ for (let i = 0; i < 3; i++) {
+ const sid = (
+ await q("select public.create_game($1,'normal',$2,$3) id", [
+ map,
+ users[i],
+ String(i).repeat(64),
+ ])
+ )[0].id;
+ await q("update public.game_sessions set current_round=5 where id=$1", [
+ sid,
+ ]);
+ await q("update public.game_rounds set result=$2 where session_id=$1", [
+ sid,
+ JSON.stringify({ score: 1000 + i }),
+ ]);
+ }
+ let ranked = (await q("select public.player_leaderboard() value"))[0].value;
+ assert.equal(ranked.total_players, 3);
+ assert.equal(ranked.entries[0].player_id, users[0]);
+ assert.equal(ranked.entries[0].best_score, 5000);
+ const secondPage = (
+ await q("select public.player_leaderboard('all','',2,2) value")
+ )[0].value;
+ assert.equal(secondPage.pages, 2);
+ assert.equal(secondPage.entries.length, 1);
+ assert.equal(secondPage.entries[0].rank, 3);
+ const newer = await create({ announceDiscord: false });
+ await q(
+ "update public.tournaments set status='finished',finished_at=clock_timestamp(),winner_id=$2 where id=$1",
+ [newer, users[1]],
+ );
+ ranked = (await q("select public.player_leaderboard() value"))[0].value;
+ assert.equal(ranked.entries[0].player_id, users[1]);
+ assert.equal(ranked.entries[0].tournament_champion.tournament_id, newer);
+ assert.equal(
+ ranked.entries.filter((p) => p.tournament_champion !== null).length,
+ 1,
+ );
+ await q(
+ "update public.tournaments set finished_at=clock_timestamp()-interval '8 days' where id=$1",
+ [tid],
+ );
+ await q(
+ "update public.tournaments set finished_at=clock_timestamp()-interval '8 days' where id=$1",
+ [newer],
+ );
+ ranked = (await q("select public.player_leaderboard() value"))[0].value;
+ assert.equal(ranked.entries[0].player_id, users[2]);
+ assert(ranked.entries.every((p) => p.tournament_champion === null));
+ for (const role of ["anon", "authenticated"])
+ for (const table of [
+ "tournaments",
+ "tournament_members",
+ "tournament_rounds",
+ "tournament_announcements",
+ ])
+ assert.equal(
+ (
+ await q("select has_table_privilege($1,$2,'SELECT') allowed", [
+ role,
+ `public.${table}`,
+ ])
+ )[0].allowed,
+ false,
+ );
+ for (const role of ["anon", "authenticated"])
+ assert.equal(
+ (
+ await q(
+ "select has_function_privilege($1,'public.tournament_action(uuid,uuid,text,jsonb)','EXECUTE') allowed",
+ [role],
+ )
+ )[0].allowed,
+ false,
+ );
+ for (const fn of await q(
+ "select proname,prosecdef,proconfig from pg_catalog.pg_proc p join pg_catalog.pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and (p.proname like '%tournament%' or p.proname='player_leaderboard')",
+ )) {
+ assert.equal(fn.prosecdef, false, fn.proname);
+ assert(
+ fn.proconfig.some((setting) => setting.startsWith("search_path=")),
+ fn.proname,
+ );
+ }
+ const alone = await create({ announceDiscord: false });
+ await q(
+ "update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
+ [alone],
+ );
+ assert.equal((await act(alone, users[0])).status, "cancelled");
+ // A shared round clock starts after loading and caps subsequent mini rounds.
+ const shared = await create({
+ timerMode: "shared",
+ roundCount: 1,
+ roundTimeSeconds: 90,
+ announceDiscord: false,
+ });
+ await act(shared, users[1], "join");
+ await q(
+ "update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
+ [shared],
+ );
+ let sharedState = await act(shared, users[0]);
+ assert.equal(sharedState.timerMode, "shared");
+ assert.equal(sharedState.roundDeadline, null);
+ await act(shared, users[0], "ready", { round: 0, tournamentRound: 0 });
+ sharedState = await act(shared, users[1], "ready", {
+ round: 0,
+ tournamentRound: 0,
+ });
+ const sharedDeadline = sharedState.roundDeadline;
+ assert(sharedDeadline);
+ assert.equal(sharedState.currentRoom.status, "playing");
+ const firstAnswer = (
+ await q(
+ "select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
+ [sharedState.roomId],
+ )
+ )[0].viewpoint;
+ for (const user of users.slice(0, 2))
+ await act(shared, user, "guess", {
+ round: 0,
+ tournamentRound: 0,
+ x: firstAnswer.x,
+ z: firstAnswer.z,
+ });
+ await q(
+ "update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
+ [sharedState.roomId],
+ );
+ await act(shared, users[0], "ready", { round: 1, tournamentRound: 0 });
+ sharedState = await act(shared, users[1], "ready", {
+ round: 1,
+ tournamentRound: 0,
+ });
+ assert.equal(sharedState.roundDeadline, sharedDeadline);
+ assert.equal(sharedState.currentRoom.rounds[1].deadline, sharedDeadline);
+ await q(
+ "update public.tournament_rounds set deadline=clock_timestamp()-interval '1 second' where tournament_id=$1",
+ [shared],
+ );
+ await assert.rejects(
+ act(shared, users[0], "guess", {
+ round: 1,
+ tournamentRound: 0,
+ x: 64,
+ z: 64,
+ score: 999999,
+ }),
+ );
+ // An invalid action rolls back its transaction, so a normal poll settles it.
+ sharedState = await act(shared, users[0]);
+ assert.equal(sharedState.status, "finished");
+ assert.equal(sharedState.players[0].total, 5000);
+ assert.equal(
+ sharedState.currentRoom.rounds.filter((r) => r.closedAt !== null).length,
+ 5,
+ );
+ assert(
+ sharedState.currentRoom.rounds
+ .slice(1)
+ .every((r) =>
+ r.results.every((result) => result.score === 0 && result.timedOut),
+ ),
+ );
+ await assert.rejects(create({ timerMode: "perMini", roundTimeSeconds: 615 }));
+ await assert.rejects(create({ timerMode: "shared", roundTimeSeconds: 3015 }));
+ const bigShared = await create({
+ timerMode: "shared",
+ roundTimeSeconds: 3000,
+ announceDiscord: false,
+ });
+ assert.equal(
+ (await act(bigShared, users[0])).difficultySettings.seconds,
+ 600,
+ );
+ const moderated = await create({ announceDiscord: false });
+ await act(moderated, users[1], "join");
+ await q("update public.datasets set state='draft' where id=$1", [map]);
+ assert.equal((await act(moderated, users[0])).status, "cancelled");
+ await q("update public.datasets set state='published' where id=$1", [map]);
+ const abandoned = await create({ announceDiscord: false });
+ await act(abandoned, users[1], "join");
+ await q(
+ "update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
+ [abandoned],
+ );
+ await act(abandoned, users[0]);
+ await q(
+ "update public.profiles set account_status='banned' where id=any($1::uuid[])",
+ [users.slice(0, 2)],
+ );
+ assert.equal((await act(abandoned, null)).status, "cancelled");
+ await assert.rejects(create(), /Active account/);
+ await q(
+ "update public.profiles set account_status='active' where id=any($1::uuid[])",
+ [users.slice(0, 2)],
+ );
+ // Account removal must preserve event history instead of blocking Auth deletion.
+ await q("delete from auth.users where id=$1", [users[0]]);
+ assert.equal((await act(tid, null)).ownerId, null);
+ console.log(
+ "Tournament SQL passed: 32-player cap, admission/auth, private rooms, 2×5 mini-rounds, shared/per-mini clocks, server scores, schedules/outbox leases, immutable results, seven-day champion and account deletion.",
+ );
+} catch (error) {
+ console.error(
+ error.message,
+ error.where ?? "",
+ error.stack?.split("\n").slice(0, 3).join("\n"),
+ );
+ process.exitCode = 1;
+} finally {
+ await db.close();
+}
diff --git a/shared/database.ts b/shared/database.ts
index 4b469bd..3f1132e 100644
--- a/shared/database.ts
+++ b/shared/database.ts
@@ -62,6 +62,22 @@ export type Database = {
};
public: {
Tables: {
+ tournaments: {
+ Row: import("./tournaments").TournamentRecord;
+ Insert: Partial;
+ Update: Partial;
+ Relationships: [];
+ };
+ tournament_announcements: {
+ Row: import("../lib/tournament-discord").TournamentAnnouncementJob;
+ Insert: Partial<
+ import("../lib/tournament-discord").TournamentAnnouncementJob
+ >;
+ Update: Partial<
+ import("../lib/tournament-discord").TournamentAnnouncementJob
+ >;
+ Relationships: [];
+ };
map_reactions: {
Row: {
dataset_id: string;
@@ -183,6 +199,7 @@ export type Database = {
};
multiplayer_rooms: {
Row: {
+ tournament_id: string | null;
id: string;
revision_id: string;
host_player_id: string;
@@ -195,6 +212,7 @@ export type Database = {
created_at: string;
};
Insert: {
+ tournament_id?: string | null;
id?: string;
revision_id: string;
host_player_id: string;
@@ -216,6 +234,11 @@ export type Database = {
};
difficulty_presets: {
Row: {
+ pan: boolean;
+ compass: boolean;
+ coordinates: boolean;
+ map_zoom: boolean;
+ scoring: "normal" | "strict" | "gentle";
created_at: string;
id: string;
name: string;
@@ -223,6 +246,11 @@ export type Database = {
zoom: boolean;
};
Insert: {
+ pan?: boolean;
+ compass?: boolean;
+ coordinates?: boolean;
+ map_zoom?: boolean;
+ scoring?: "normal" | "strict" | "gentle";
created_at?: string;
id?: string;
name: string;
@@ -230,6 +258,11 @@ export type Database = {
zoom: boolean;
};
Update: {
+ pan?: boolean;
+ compass?: boolean;
+ coordinates?: boolean;
+ map_zoom?: boolean;
+ scoring?: "normal" | "strict" | "gentle";
name?: string;
seconds?: number;
zoom?: boolean;
@@ -818,6 +851,27 @@ export type Database = {
[_ in never]: never;
};
Functions: {
+ create_tournament: {
+ Args: { p_owner: string; p_payload: Json };
+ Returns: string;
+ };
+ tournament_action: {
+ Args: {
+ p_tournament: string;
+ p_account: string | null;
+ p_action: string;
+ p_payload?: Json;
+ };
+ Returns: Json;
+ };
+ tick_tournaments: {
+ Args: Record;
+ Returns: undefined;
+ };
+ claim_tournament_announcements: {
+ Args: { p_worker: string; p_target?: string | null };
+ Returns: import("../lib/tournament-discord").TournamentAnnouncementJob[];
+ };
map_reaction_summary: {
Args: { p_dataset: string };
Returns: { likes: number; dislikes: number }[];
diff --git a/shared/leaderboard.ts b/shared/leaderboard.ts
index 3422a2d..a046cf9 100644
--- a/shared/leaderboard.ts
+++ b/shared/leaderboard.ts
@@ -14,6 +14,11 @@ export type LeaderboardEntry =
best_score: number;
average_score: number;
games_played: number;
+ tournament_champion?: {
+ tournament_id: string;
+ name: string;
+ expires_at: string;
+ } | null;
};
export type LeaderboardData = {
entries: LeaderboardEntry[];
diff --git a/shared/locales/ru.json b/shared/locales/ru.json
index eb8dc19..a1f756a 100644
--- a/shared/locales/ru.json
+++ b/shared/locales/ru.json
@@ -678,7 +678,7 @@
"Create multiplayer room": "Создать комнату",
"Retry": "Повторить",
"Loading multiplayer room…": "Загрузка комнаты…",
- "Final standings": "Итоговая таблица",
+ "Final standings": "Итоговый рейтинг",
"This room is closed": "Эта комната закрыта",
"An expedition with friends.": "Экспедиция с друзьями.",
"Join your friends for five rounds on the same map.": "Присоединитесь к друзьям: пять раундов на одной карте.",
@@ -965,5 +965,84 @@
"Your account is active. You can use MineGeo normally.": "Ваш аккаунт активен. Вы можете пользоваться MineGeo.",
"Account changes are disabled while access is restricted.": "Изменения аккаунта недоступны, пока доступ ограничен.",
"View account status & appeal": "Статус аккаунта и апелляция",
- "← Account access & appeals": "← Доступ к аккаунту и апелляции"
+ "← Account access & appeals": "← Доступ к аккаунту и апелляции",
+ "Tournaments": "Турниры",
+ "COMMUNITY TOURNAMENTS": "ТУРНИРЫ СООБЩЕСТВА",
+ "One world. Up to 32 rivals.": "Один мир. До 32 соперников.",
+ "Every tournament round contains five mini-rounds. Everyone plays the same locations.": "Каждый раунд турнира состоит из пяти мини-раундов. Все играют в одних и тех же локациях.",
+ "Create tournament": "Создать турнир",
+ "Log in to create or join": "Войдите, чтобы создать турнир или участвовать",
+ "Server time": "Время сервера",
+ "Tournament name": "Название турнира",
+ "Saturday expedition": "Субботняя экспедиция",
+ "Choose a map": "Выберите карту",
+ "Player limit": "Максимум игроков",
+ "Tournament rounds": "Раунды турнира",
+ "Seconds per mini-round": "Секунд на мини-раунд",
+ "Seconds per tournament round": "Секунд на раунд турнира",
+ "Timer mode": "Режим таймера",
+ "One budget for five mini-rounds": "Общее время на пять мини-раундов",
+ "Separate timer for each mini-round": "Отдельный таймер на каждый мини-раунд",
+ "Start time": "Время начала",
+ "Announce in Discord 15 minutes before starting": "Опубликовать анонс в Discord за 15 минут до начала",
+ "A shared budget starts with the first mini-round and keeps running between mini-rounds. Initial loading is excluded.": "Общий таймер запускается в первом мини-раунде и продолжает идти между мини-раундами. Первая загрузка не учитывается.",
+ "The organizer joins as a player. Registration closes at the scheduled start. At least two players are needed.": "Организатор участвует как игрок. Регистрация закрывается в назначенное время. Требуются хотя бы два игрока.",
+ "Creating…": "Создание…",
+ "scheduled": "Запланирован",
+ "finished": "Завершён",
+ "cancelled": "Отменён",
+ "running": "Идёт",
+ "pending": "Ожидает",
+ "sending": "Отправляется",
+ "sent": "Отправлен",
+ "skipped": "Пропущен",
+ "failed": "Не отправлен",
+ "Up to {count} players": "До {count} игроков",
+ "{count} tournament rounds": "Раундов турнира: {count}",
+ "Open tournament": "Открыть турнир",
+ "The next champion could be you.": "Следующим чемпионом можешь стать ты.",
+ "Create the first community tournament or check back for upcoming events.": "Создайте первый турнир сообщества или вернитесь позже за новыми событиями.",
+ "Loading tournament…": "Загрузка турнира…",
+ "Tournament round {round} of {total}": "Раунд турнира {round} из {total}",
+ "Copy the tournament link from your address bar": "Скопируйте ссылку на турнир из адресной строки",
+ "Copy invite": "Скопировать приглашение",
+ "mini-rounds": "мини-раундов",
+ "seconds per mini-round": "сек. на мини-раунд",
+ "seconds per tournament round": "сек. на раунд турнира",
+ "Discord announcement": "Анонс в Discord",
+ "You’re registered. Stay on this page when the tournament starts.": "Вы зарегистрированы. Оставайтесь на этой странице к началу турнира.",
+ "Leave tournament": "Покинуть турнир",
+ "Tournament is full": "Все места заняты",
+ "Join tournament": "Участвовать в турнире",
+ "Tournament settings": "Настройки турнира",
+ "Cancel tournament": "Отменить турнир",
+ "Already-sent announcements are not posted again.": "Уже отправленные анонсы повторно не публикуются.",
+ "Save settings": "Сохранить настройки",
+ "TOURNAMENT CHAMPION": "ЧЕМПИОН ТУРНИРА",
+ "The latest tournament champion holds first place on the global leaderboard for seven days.": "Победитель последнего турнира занимает первое место в глобальном рейтинге на семь дней.",
+ "The latest tournament champion holds first place on the global leaderboard for seven days. Solo scores remain unchanged.": "Победитель последнего турнира занимает первое место в глобальном рейтинге на семь дней. Очки одиночных игр не меняются.",
+ "Global leaderboard": "Глобальный рейтинг",
+ "This tournament was cancelled. Scheduled tournaments need at least two eligible players at the start.": "Этот турнир отменён. Для старта нужны хотя бы два допущенных игрока.",
+ "Tournament standings": "Рейтинг турнира",
+ "Points count after each mini-round closes. Ties use total distance, then registration order.": "Очки учитываются после завершения мини-раунда. При равенстве учитываются суммарное расстояние и порядок регистрации.",
+ "Champion spotlight until {date}": "Титул чемпиона до {date}",
+ "View tournament standings": "Посмотреть рейтинг турнира",
+ "Tournament game": "Игра турнира",
+ "Allow panorama panning": "Разрешить поворот панорамы",
+ "Show north indicator": "Показывать направление на север",
+ "Show guess coordinates": "Показывать координаты метки",
+ "Allow guess-map zoom": "Разрешить масштабирование карты",
+ "Scoring strictness": "Строгость подсчёта очков",
+ "Normal scoring": "Обычный подсчёт",
+ "Strict scoring": "Строгий подсчёт",
+ "Gentle scoring": "Мягкий подсчёт",
+ "Fixed view": "Фиксированный вид",
+ "Panning allowed": "Поворот разрешён",
+ "Fixed panorama view": "Фиксированная панорама",
+ "FIXED VIEW": "ФИКСИРОВАННЫЙ ВИД",
+ "PLACE YOUR GUESS": "ПОСТАВЬТЕ МЕТКУ",
+ "Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.": "Карта Minecraft. Нажмите для метки или перемещайтесь стрелками и нажмите Enter для метки в центре.",
+ "Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.": "Строгий подсчёт сильнее штрафует за расстояние, мягкий даёт больше очков за близкие метки. В игре по-прежнему пять раундов.",
+ "Guess selected": "Метка выбрана",
+ "Map": "Карта"
}
diff --git a/shared/protocol.ts b/shared/protocol.ts
index 61383fb..1651d18 100644
--- a/shared/protocol.ts
+++ b/shared/protocol.ts
@@ -155,7 +155,9 @@ export function parseUploadManifest(value: unknown) {
!manifest.contributionTo &&
new Set(manifest.viewpoints.map(panoramaLocationKey)).size < 5
)
- throw new Error("Capture at least five panoramas from different block positions");
+ throw new Error(
+ "Capture at least five panoramas from different block positions",
+ );
if (manifest.mediaFormat)
throw new Error("Use map import for rendered archives");
manifest.serverAddress =
@@ -183,11 +185,25 @@ export const difficulties = {
hard: { seconds: 30, zoom: false, label: "Hard" },
} as const;
export type Difficulty = keyof typeof difficulties;
+export const difficultyRuleDefaults = {
+ pan: true,
+ compass: true,
+ coordinates: true,
+ mapZoom: true,
+ scoring: "normal" as const,
+};
+export type ScoringMode = "normal" | "strict" | "gentle";
export type DifficultyPreset = {
id: string;
name: string;
seconds: number;
zoom: boolean;
+ // Optional for sessions created before expanded rules were introduced.
+ pan?: boolean;
+ compass?: boolean;
+ coordinates?: boolean;
+ mapZoom?: boolean;
+ scoring?: ScoringMode;
};
export const customDifficultySchema = z
.object({
@@ -200,9 +216,7 @@ export const customDifficultySchema = z
)
.refine(
(name) =>
- !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
- name,
- ),
+ !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(name),
"Difficulty names cannot contain hidden controls",
),
seconds: z
@@ -212,12 +226,18 @@ export const customDifficultySchema = z
.max(600)
.refine((seconds) => seconds % 15 === 0),
zoom: z.boolean(),
+ pan: z.boolean().default(true),
+ compass: z.boolean().default(true),
+ coordinates: z.boolean().default(true),
+ mapZoom: z.boolean().default(true),
+ scoring: z.enum(["normal", "strict", "gentle"]).default("normal"),
})
.strict();
export function scoreGuess(
a: Pick,
b: Pick,
bounds: Bounds,
+ scoring: ScoringMode = "normal",
) {
const distance = Math.hypot(a.x - b.x, a.z - b.z);
return {
@@ -228,7 +248,8 @@ export function scoreGuess(
: Math.round(
5000 *
Math.exp(
- (-5 * distance) /
+ (-(scoring === "strict" ? 8 : scoring === "gentle" ? 3 : 5) *
+ distance) /
Math.hypot(
bounds.maxX - bounds.minX,
bounds.maxZ - bounds.minZ,
diff --git a/shared/tournaments.ts b/shared/tournaments.ts
new file mode 100644
index 0000000..9945e79
--- /dev/null
+++ b/shared/tournaments.ts
@@ -0,0 +1,109 @@
+import { z } from "zod";
+import type { RoomView } from "./multiplayer";
+
+const safeName = z
+ .string()
+ .trim()
+ .min(3)
+ .max(80)
+ .refine(
+ (value) =>
+ !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value),
+ "Names cannot contain hidden controls",
+ );
+export const tournamentSettings = z
+ .object({
+ name: safeName,
+ datasetId: z.string().uuid(),
+ difficulty: z.union([
+ z.enum(["easy", "normal", "hard"]),
+ z.string().uuid(),
+ ]),
+ playerLimit: z.number().int().min(2).max(32),
+ roundCount: z.number().int().min(1).max(10),
+ roundTimeSeconds: z.number().int().min(15).max(3000).multipleOf(15),
+ timerMode: z.enum(["shared", "perMini"]).default("shared"),
+ startsAt: z.string().datetime({ offset: true }),
+ announceDiscord: z.boolean(),
+ })
+ .strict()
+ .refine(
+ (value) => value.timerMode !== "perMini" || value.roundTimeSeconds <= 600,
+ "Mini-round timers allow at most 600 seconds",
+ );
+export const tournamentEdit = z
+ .object({
+ startsAt: z.string().datetime({ offset: true }),
+ playerLimit: z.number().int().min(2).max(32),
+ announceDiscord: z.boolean(),
+ })
+ .strict();
+export type TournamentRecord = {
+ id: string;
+ name: string;
+ owner_id: string | null;
+ starts_at: string;
+ server_timezone: string;
+ player_limit: number;
+ round_count: number;
+ round_time_seconds: number;
+ timer_mode: "shared" | "perMini";
+ status: "scheduled" | "running" | "finished" | "cancelled";
+ announce_discord: boolean;
+ finished_at: string | null;
+ winner_id: string | null;
+};
+export type TournamentView = {
+ id: string;
+ name: string;
+ ownerId: string | null;
+ datasetId: string;
+ startsAt: string;
+ serverTimezone: string;
+ playerLimit: number;
+ roundCount: number;
+ roundTimeSeconds: number;
+ timerMode: "shared" | "perMini";
+ roundDeadline?: string | null;
+ announceDiscord: boolean;
+ status: TournamentRecord["status"];
+ currentRound: number;
+ finishedAt: string | null;
+ winnerId: string | null;
+ you: string | null;
+ roomId: string | null;
+ currentRoom?: RoomView | null;
+ announcementState?:
+ | "pending"
+ | "sending"
+ | "sent"
+ | "skipped"
+ | "failed"
+ | null;
+ serverNow: number;
+ players: { accountId: string; name: string; total: number; rank: number }[];
+};
+
+/** Interpret a datetime-local value in the advertised server zone, never the browser zone. */
+export function serverLocalToIso(value: string, zone: string) {
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value))
+ throw new Error("Choose a valid start time");
+ const target = Date.parse(value + ":00Z");
+ if (!Number.isFinite(target)) throw new Error("Choose a valid start time");
+ const formatter = new Intl.DateTimeFormat("sv-SE", {
+ timeZone: zone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hourCycle: "h23",
+ });
+ let instant = target;
+ for (let i = 0; i < 4; i++) {
+ const shown = formatter.format(new Date(instant)).replace(" ", "T");
+ if (shown === value) return new Date(instant).toISOString();
+ instant += target - Date.parse(shown + ":00Z");
+ }
+ throw new Error("That local time does not exist in the server timezone");
+}
diff --git a/supabase/migrations/20261018060642_extended_difficulty_rules.sql b/supabase/migrations/20261018060642_extended_difficulty_rules.sql
new file mode 100644
index 0000000..2c1b3ad
--- /dev/null
+++ b/supabase/migrations/20261018060642_extended_difficulty_rules.sql
@@ -0,0 +1,145 @@
+-- Preserve the original defaults and snapshot the expanded rules per game.
+alter table public.difficulty_presets
+ add column pan boolean not null default true,
+ add column compass boolean not null default true,
+ add column coordinates boolean not null default true,
+ add column map_zoom boolean not null default true,
+ add column scoring text not null default 'normal' check (scoring in ('normal','strict','gentle'));
+
+update public.game_sessions set difficulty_settings =
+ '{"pan":true,"compass":true,"coordinates":true,"mapZoom":true,"scoring":"normal"}'::jsonb || difficulty_settings;
+update public.multiplayer_rooms set difficulty_settings =
+ '{"pan":true,"compass":true,"coordinates":true,"mapZoom":true,"scoring":"normal"}'::jsonb || difficulty_settings;
+
+create or replace function public.resolve_difficulty_preset(p_id text)
+returns jsonb language plpgsql stable security invoker set search_path = '' as $$
+declare preset jsonb;
+begin
+ if p_id in ('easy','normal','hard') then
+ return jsonb_build_object('id',p_id,'name',initcap(p_id),
+ 'seconds',case p_id when 'easy' then 180 when 'hard' then 30 else 90 end,
+ 'zoom',p_id <> 'hard','pan',true,'compass',true,'coordinates',true,'mapZoom',true,'scoring','normal');
+ end if;
+ select jsonb_build_object('id',p.id::text,'name',p.name,'seconds',p.seconds,'zoom',p.zoom,
+ 'pan',p.pan,'compass',p.compass,'coordinates',p.coordinates,'mapZoom',p.map_zoom,'scoring',p.scoring)
+ into preset from public.difficulty_presets p where p.id::text=lower(p_id);
+ if not found then raise exception 'Choose a saved difficulty'; end if;
+ return preset;
+end; $$;
+
+-- Shared server-side curve used by solo, multiplayer, and tournament scoring.
+create function public.score_difficulty_guess(p_distance double precision,p_diagonal double precision,p_scoring text default 'normal')
+returns integer language plpgsql immutable security invoker set search_path = '' as $$
+begin
+ if p_scoring is null or p_scoring not in ('normal','strict','gentle') then raise exception 'Invalid scoring rules'; end if;
+ if p_distance is null or p_diagonal is null or p_distance < 0 or p_diagonal <= 0
+ or p_distance in ('NaN'::float8,'Infinity'::float8,'-Infinity'::float8)
+ or p_diagonal in ('NaN'::float8,'Infinity'::float8,'-Infinity'::float8)
+ then raise exception 'Invalid scoring distance'; end if;
+ if p_distance <= 1 then return 5000; end if;
+ return round(5000 * exp(-(case p_scoring when 'strict' then 8 when 'gentle' then 3 else 5 end) * p_distance / p_diagonal))::integer;
+end; $$;
+revoke all on function public.score_difficulty_guess(double precision,double precision,text) from public,anon,authenticated;
+grant execute on function public.score_difficulty_guess(double precision,double precision,text) to service_role;
+
+create or replace function public.submit_guess(p_session uuid,p_round integer,p_access_hash text,p_x double precision default null,p_z double precision default null) returns jsonb language plpgsql security invoker set search_path='' as $$
+declare s public.game_sessions; r public.game_rounds; manifest jsonb; b jsonb; d double precision; diagonal double precision; points integer:=0; timed_out boolean; answer jsonb;
+begin
+ select * into s from public.game_sessions where id=p_session and access_hash=p_access_hash for update;
+ if not found then raise exception 'Session not found'; end if;
+ select * into r from public.game_rounds where session_id=p_session and round_number=p_round for update;
+ if r.result is not null then return r.result; end if;
+ if s.current_round<>p_round or r.deadline is null then raise exception 'Round is not active'; end if;
+ select revisions.manifest into manifest from public.revisions where id=s.revision_id; b:=manifest->'bounds';
+ timed_out:=clock_timestamp()>=r.deadline;
+ if not timed_out and (p_x is null or p_z is null) then raise exception 'Place a guess first'; end if;
+ if not timed_out then
+ if p_x='NaN'::float8 or p_z='NaN'::float8 or p_x<(b->>'minX')::float8 or p_x>(b->>'maxX')::float8 or p_z<(b->>'minZ')::float8 or p_z>(b->>'maxZ')::float8 then raise exception 'Guess outside map'; end if;
+ d:=sqrt(power(p_x-(r.viewpoint->>'x')::float8,2)+power(p_z-(r.viewpoint->>'z')::float8,2));
+ diagonal:=sqrt(power((b->>'maxX')::float8-(b->>'minX')::float8,2)+power((b->>'maxZ')::float8-(b->>'minZ')::float8,2));
+ points:=public.score_difficulty_guess(d,diagonal,coalesce(s.difficulty_settings->>'scoring','normal'));
+ end if;
+ answer:=jsonb_build_object('score',points,'distance',d,'timedOut',timed_out,'actual',jsonb_build_object('x',r.viewpoint->'x','z',r.viewpoint->'z'),'guess',case when timed_out then null else jsonb_build_object('x',p_x,'z',p_z) end);
+ update public.game_rounds set result=answer where session_id=p_session and round_number=p_round;
+ update public.game_sessions set current_round=current_round+1 where id=p_session;
+ return answer;
+end; $$;
+
+create or replace 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 := public.score_difficulty_guess(distance,diagonal,coalesce(room.difficulty_settings->>'scoring','normal'));
+ answer := jsonb_build_object('score',points,'distance',distance,'timedOut',false,'guess',jsonb_build_object('x',px,'z',pz),'actual',jsonb_build_object('x',turn.viewpoint->'x','z',turn.viewpoint->'z'));
+ insert into public.multiplayer_guesses(room_id,player_id,round_number,result) values(p_room,player.id,n,answer);
+ elsif p_action='next' then
+ n := (p_payload->>'round')::integer;
+ if n'results' or n is distinct from room.current_round then raise exception 'Wait for the round to finish'; end if;
+ update public.multiplayer_rooms set current_round=current_round+1,status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room;
+ elsif p_action='kick' then
+ if room.status<>'lobby' then raise exception 'Players can only be removed before the match starts'; end if;
+ target := (p_payload->>'playerId')::uuid;
+ if target=room.host_player_id then raise exception 'The owner cannot remove themselves'; end if;
+ delete from public.multiplayer_players where room_id=p_room and id=target;
+ elsif p_action='leave' then
+ if room.status='lobby' then delete from public.multiplayer_players where id=player.id;
+ else update public.multiplayer_players set has_left=true where id=player.id; end if;
+ select id into target from public.multiplayer_players where room_id=p_room and not has_left order by joined_at,id limit 1;
+ if target is null then update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room;
+ elsif room.host_player_id=player.id then update public.multiplayer_rooms set host_player_id=target where id=p_room; end if;
+ elsif p_action='close' then
+ update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room;
+ elsif p_action<>'view' then raise exception 'Invalid room action';
+ end if;
+ perform public.multiplayer_tick(p_room);
+ return public.multiplayer_snapshot(p_room,p_access_hash);
+end; $$;
diff --git a/supabase/migrations/20261018060844_tournaments.sql b/supabase/migrations/20261018060844_tournaments.sql
new file mode 100644
index 0000000..2078980
--- /dev/null
+++ b/supabase/migrations/20261018060844_tournaments.sql
@@ -0,0 +1,394 @@
+-- Tournament writes and secrets are only available to the trusted server.
+create table public.tournaments (
+ id uuid primary key default gen_random_uuid(),
+ name text not null check(length(name) between 3 and 80 and name=btrim(name) and name !~ '[[:cntrl:]]' and name !~ U&'[\202A-\202E\2066-\2069]'),
+ owner_id uuid references public.profiles(id) on delete set null,
+ revision_id uuid not null references public.revisions(id),
+ starts_at timestamptz not null, server_timezone text not null,
+ player_limit integer not null check(player_limit between 2 and 32),
+ round_count integer not null check(round_count between 1 and 10),
+ round_time_seconds integer not null check(round_time_seconds between 15 and 3000 and round_time_seconds%15=0),
+ timer_mode text not null default 'shared' check(timer_mode in('shared','perMini')),
+ constraint tournaments_per_mini_time check(timer_mode<>'perMini' or round_time_seconds<=600),
+ difficulty text not null, difficulty_settings jsonb not null,
+ announce_discord boolean not null default false,
+ status text not null default 'scheduled' check(status in('scheduled','running','finished','cancelled')),
+ current_round integer not null default 0,
+ finished_at timestamptz, winner_id uuid references public.profiles(id) on delete set null,
+ created_at timestamptz not null default clock_timestamp()
+);
+create index tournaments_due on public.tournaments(status,starts_at) where status in('scheduled','running');
+create index tournaments_recent_winners on public.tournaments(finished_at desc,id) where status='finished' and winner_id is not null;
+create index tournaments_owner on public.tournaments(owner_id);
+create index tournaments_revision on public.tournaments(revision_id);
+create index tournaments_winner on public.tournaments(winner_id);
+create table public.tournament_members (
+ tournament_id uuid not null references public.tournaments(id) on delete cascade,
+ account_id uuid not null references public.profiles(id) on delete cascade,
+ player_id uuid not null default gen_random_uuid(),
+ access_hash text not null default(replace(gen_random_uuid()::text,'-','')||replace(gen_random_uuid()::text,'-','')) check(access_hash ~ '^[a-f0-9]{64}$'),
+ joined_at timestamptz not null default clock_timestamp(),
+ primary key(tournament_id,account_id)
+);
+create index tournament_members_account on public.tournament_members(account_id);
+create table public.tournament_rounds (
+ tournament_id uuid not null references public.tournaments(id) on delete cascade,
+ round_number integer not null check(round_number between 0 and 9),
+ room_id uuid not null unique references public.multiplayer_rooms(id),
+ deadline timestamptz,
+ primary key(tournament_id,round_number)
+);
+-- Regular rooms remain limited to 16; only linked tournament rooms allow 32.
+alter table public.multiplayer_rooms add column tournament_id uuid references public.tournaments(id);
+alter table public.multiplayer_rooms drop constraint multiplayer_rooms_player_limit_check;
+alter table public.multiplayer_rooms add constraint multiplayer_rooms_player_limit_check
+ check(player_limit between 2 and case when tournament_id is null then 16 else 32 end);
+create index multiplayer_rooms_tournament on public.multiplayer_rooms(tournament_id) where tournament_id is not null;
+create table public.tournament_announcements (
+ id uuid primary key default gen_random_uuid(), tournament_id uuid not null unique references public.tournaments(id) on delete cascade,
+ state text not null default 'pending' check(state in('pending','sending','sent','skipped','failed')),
+ attempts integer not null default 0 check(attempts>=0), next_attempt_at timestamptz not null,
+ lease_owner uuid, lease_until timestamptz, channel_id text, message_id text,
+ sent_at timestamptz, last_error text
+);
+create index tournament_announcements_due on public.tournament_announcements(next_attempt_at) where state in('pending','sending');
+alter table public.tournaments enable row level security;
+alter table public.tournament_members enable row level security;
+alter table public.tournament_rounds enable row level security;
+alter table public.tournament_announcements enable row level security;
+revoke all on public.tournaments,public.tournament_members,public.tournament_rounds,public.tournament_announcements from public,anon,authenticated;
+grant all on public.tournaments,public.tournament_members,public.tournament_rounds,public.tournament_announcements to service_role;
+
+create function public.sync_tournament_announcement() returns trigger
+language plpgsql security invoker set search_path='' as $$
+begin
+ if new.status='scheduled' and new.announce_discord then
+ insert into public.tournament_announcements(tournament_id,next_attempt_at)
+ values(new.id,new.starts_at-interval '15 minutes')
+ on conflict(tournament_id) do update set
+ next_attempt_at=excluded.next_attempt_at,state='pending',lease_owner=null,lease_until=null,attempts=0,last_error=null
+ where public.tournament_announcements.state<>'sent';
+ else
+ update public.tournament_announcements set state='skipped',lease_owner=null,lease_until=null
+ where tournament_id=new.id and state<>'sent';
+ end if;
+ return new;
+end; $$;
+create trigger sync_tournament_announcement after insert or update of starts_at,announce_discord,status on public.tournaments
+for each row execute function public.sync_tournament_announcement();
+
+create function public.guard_tournament_room_member() returns trigger
+language plpgsql security invoker set search_path='' as $$
+declare tournament uuid;
+begin
+ -- Preserve historical room results during the profile FK's SET NULL cleanup.
+ if tg_op='UPDATE' and old.account_id is not null and new.account_id is null
+ and new.room_id=old.room_id and new.id=old.id and new.access_hash=old.access_hash
+ and not exists(select 1 from public.profiles where id=old.account_id) then return new; end if;
+ select tournament_id into tournament from public.multiplayer_rooms where id=new.room_id;
+ if tournament is not null and not exists(select 1 from public.tournament_members m
+ join public.profiles p on p.id=m.account_id and p.account_status='active'
+ where m.tournament_id=tournament and m.account_id=new.account_id and m.player_id=new.id and m.access_hash=new.access_hash)
+ then raise exception 'Tournament membership required'; end if;
+ return new;
+end; $$;
+create trigger guard_tournament_room_member before insert or update of room_id,account_id,access_hash,id on public.multiplayer_players
+for each row execute function public.guard_tournament_room_member();
+
+create function public.create_tournament(p_owner uuid,p_payload jsonb) returns uuid
+language plpgsql security invoker set search_path='' as $$
+declare tid uuid; rev public.revisions; settings jsonb; start_time timestamptz; zone text;
+ seconds integer; cap integer; rounds integer; title text; mode text;
+begin
+ if not exists(select 1 from public.profiles where id=p_owner and account_status='active') then raise exception 'Active account required'; end if;
+ select r.* into rev from public.datasets d join public.revisions r on r.id=d.revision_id
+ where d.id=(p_payload->>'datasetId')::uuid and d.state='published';
+ if not found then raise exception 'Choose a published map'; end if;
+ if jsonb_array_length(public.game_viewpoint_pool(rev.manifest,rev.asset_root))<5 then raise exception 'Map needs at least five distinct panorama locations'; end if;
+ start_time:=(p_payload->>'startsAt')::timestamptz;
+ if start_time is null or start_time<=clock_timestamp() or start_time>clock_timestamp()+interval '90 days' then raise exception 'Choose a future start within 90 days'; end if;
+ zone:=p_payload->>'serverTimezone';
+ if zone is null or not exists(select 1 from pg_catalog.pg_timezone_names where name=zone) then raise exception 'Invalid server timezone'; end if;
+ title:=btrim(p_payload->>'name'); cap:=(p_payload->>'playerLimit')::integer;
+ rounds:=(p_payload->>'roundCount')::integer; seconds:=(p_payload->>'roundTimeSeconds')::integer;
+ if cap is null or cap not between 2 and 32 then raise exception 'Choose between 2 and 32 players'; end if;
+ if rounds is null or rounds not between 1 and 10 then raise exception 'Choose between 1 and 10 tournament rounds'; end if;
+ mode:=coalesce(p_payload->>'timerMode','shared');
+ if mode not in('shared','perMini') then raise exception 'Invalid tournament timer mode'; end if;
+ if seconds is null or seconds not between 15 and (case when mode='shared' then 3000 else 600 end) or seconds%15<>0 then raise exception 'Choose a valid tournament time in steps of 15 seconds'; end if;
+ settings:=public.resolve_difficulty_preset(p_payload->>'difficulty')||jsonb_build_object('seconds',least(seconds,600),'roundCount',5);
+ insert into public.tournaments(name,owner_id,revision_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,difficulty,difficulty_settings,announce_discord)
+ values(title,p_owner,rev.id,start_time,zone,cap,rounds,seconds,mode,p_payload->>'difficulty',settings,coalesce((p_payload->>'announceDiscord')::boolean,false)) returning id into tid;
+ insert into public.tournament_members(tournament_id,account_id) values(tid,p_owner);
+ return tid;
+end; $$;
+
+-- The shared clock starts when the first panorama becomes playable. It keeps
+-- running through subsequent loading/results screens and is enforced server-side.
+create function public.tournament_room_tick(p_tournament uuid,p_room uuid) returns void
+language plpgsql security invoker set search_path='' as $$
+declare t public.tournaments; room public.multiplayer_rooms; budget timestamptz; first_deadline timestamptz; i integer;
+begin
+ select * into t from public.tournaments where id=p_tournament;
+ if t.timer_mode='perMini' then perform public.multiplayer_tick(p_room); return; end if;
+ select deadline into budget from public.tournament_rounds where room_id=p_room;
+ if budget is null then
+ select deadline into first_deadline from public.multiplayer_rounds where room_id=p_room and round_number=0;
+ if first_deadline is not null then
+ budget:=first_deadline-make_interval(secs=>(t.difficulty_settings->>'seconds')::integer)+make_interval(secs=>t.round_time_seconds);
+ update public.tournament_rounds set deadline=budget where room_id=p_room;
+ end if;
+ end if;
+ if budget is not null and budget<=clock_timestamp() then
+ -- Finish every remaining mini round now, without results/loading delays.
+ for i in 0..4 loop
+ select * into room from public.multiplayer_rooms where id=p_room for update;
+ exit when room.status in('finished','closed');
+ if room.status='results' then
+ update public.multiplayer_rooms set current_round=current_round+1,status='playing',transition_at=null where id=p_room;
+ else update public.multiplayer_rooms set status='playing',transition_at=null where id=p_room; end if;
+ update public.multiplayer_rounds mr set deadline=budget where mr.room_id=p_room and mr.round_number=(select current_round from public.multiplayer_rooms where id=p_room);
+ perform public.multiplayer_tick(p_room);
+ end loop;
+ return;
+ end if;
+ if budget is not null then
+ update public.multiplayer_rounds mr set deadline=least(mr.deadline,budget)
+ where mr.room_id=p_room and mr.round_number=(select current_round from public.multiplayer_rooms where id=p_room) and mr.deadline is not null;
+ end if;
+ perform public.multiplayer_tick(p_room);
+ if budget is null then
+ select deadline into first_deadline from public.multiplayer_rounds where room_id=p_room and round_number=0;
+ if first_deadline is not null then
+ budget:=first_deadline-make_interval(secs=>(t.difficulty_settings->>'seconds')::integer)+make_interval(secs=>t.round_time_seconds);
+ update public.tournament_rounds set deadline=budget where room_id=p_room;
+ end if;
+ end if;
+ if budget is not null then
+ update public.multiplayer_rounds mr set deadline=least(mr.deadline,budget)
+ where mr.room_id=p_room and mr.round_number=(select current_round from public.multiplayer_rooms where id=p_room) and mr.deadline is not null;
+ end if;
+end; $$;
+
+create function public.tournament_tick(p_tournament uuid) returns void
+language plpgsql security invoker set search_path='' as $$
+declare t public.tournaments; rid uuid; r public.multiplayer_rooms; vp jsonb; n integer:=0; host uuid; winner uuid;
+begin
+ select * into t from public.tournaments where id=p_tournament for update;
+ if not found or t.status in('finished','cancelled') then return; end if;
+ if t.status='running' and not exists(select 1 from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id) then
+ update public.tournaments set status='cancelled' where id=t.id;
+ update public.multiplayer_rooms set status='closed',transition_at=null where tournament_id=t.id and status not in('finished','closed');
+ return;
+ end if;
+ if not exists(select 1 from public.revisions revision join public.datasets d on d.id=revision.dataset_id where revision.id=t.revision_id and d.state='published') then
+ update public.tournaments set status='cancelled' where id=t.id;
+ update public.multiplayer_rooms set status='closed',transition_at=null where tournament_id=t.id and status not in('finished','closed');
+ return;
+ end if;
+ if t.status='scheduled' then
+ if t.starts_at>clock_timestamp() then return; end if;
+ delete from public.tournament_members m where m.tournament_id=t.id and not exists(select 1 from public.profiles p where p.id=m.account_id and p.account_status='active');
+ if (select count(*) from public.tournament_members where tournament_id=t.id)<2 then
+ update public.tournaments set status='cancelled' where id=t.id; return;
+ end if;
+ update public.tournaments set status='running' where id=t.id returning * into t;
+ else
+ select room_id into rid from public.tournament_rounds where tournament_id=t.id and round_number=t.current_round;
+ -- Restricted accounts cannot submit or hold up a running event.
+ update public.multiplayer_players p set has_left=true where room_id=rid and not exists(select 1 from public.profiles a where a.id=p.account_id and a.account_status='active');
+ perform public.tournament_room_tick(t.id,rid);
+ select * into r from public.multiplayer_rooms where id=rid;
+ if r.status='closed' then update public.tournaments set status='cancelled' where id=t.id; return; end if;
+ if r.status<>'finished' then return; end if;
+ if t.current_round+1=t.round_count then
+ select p.account_id into winner from public.tournament_members m
+ join public.profiles a on a.id=m.account_id and a.account_status='active'
+ join public.multiplayer_players p on p.account_id=m.account_id
+ join public.tournament_rounds tr on tr.room_id=p.room_id and tr.tournament_id=t.id
+ join public.multiplayer_guesses g on g.room_id=p.room_id and g.player_id=p.id
+ join public.multiplayer_rounds mr on mr.room_id=g.room_id and mr.round_number=g.round_number and mr.closed_at is not null
+ where m.tournament_id=t.id group by p.account_id,m.joined_at
+ order by sum((g.result->>'score')::integer) desc,
+ sum(coalesce((g.result->>'distance')::float8,1e12)),m.joined_at,p.account_id limit 1;
+ update public.tournaments set status='finished',finished_at=clock_timestamp(),winner_id=winner where id=t.id;
+ return;
+ end if;
+ update public.tournaments set current_round=current_round+1 where id=t.id returning * into t;
+ end if;
+ -- A fresh private room per tournament round keeps the existing 5-mini-round engine.
+ if not exists(select 1 from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id) then
+ update public.tournaments set status='cancelled' where id=t.id; return;
+ end if;
+ update public.tournament_members set player_id=gen_random_uuid() where tournament_id=t.id;
+ select m.player_id into host from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id
+ order by (m.account_id=t.owner_id) desc,m.joined_at,m.account_id limit 1;
+ insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,difficulty_settings,player_limit,status,transition_at,tournament_id)
+ values(t.revision_id,host,t.difficulty,t.difficulty_settings,t.player_limit,'loading',clock_timestamp()+interval '30 seconds',t.id) returning id into rid;
+ insert into public.tournament_rounds(tournament_id,round_number,room_id) values(t.id,t.current_round,rid);
+ insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id,joined_at)
+ select m.player_id,rid,m.access_hash,left(p.display_name,26)||' #'||(row_number() over(order by m.joined_at,m.account_id))::text,m.account_id,m.joined_at
+ from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id;
+ for vp in select value from public.revisions rev,jsonb_array_elements(public.game_viewpoint_pool(rev.manifest,rev.asset_root))
+ where rev.id=t.revision_id 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;
+end; $$;
+
+create function public.tournament_snapshot(p_tournament uuid,p_account uuid) returns jsonb
+language sql security invoker set search_path='' as $$
+ with totals as (
+ select m.account_id,m.joined_at,p.display_name,
+ coalesce(sum((g.result->>'score')::integer) filter(where mr.closed_at is not null),0) total,
+ coalesce(sum(coalesce((g.result->>'distance')::float8,1e12)) filter(where mr.closed_at is not null),0) distance
+ from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active'
+ left join public.tournament_rounds tr on tr.tournament_id=m.tournament_id
+ left join public.multiplayer_players mp on mp.room_id=tr.room_id and mp.account_id=m.account_id
+ left join public.multiplayer_guesses g on g.room_id=mp.room_id and g.player_id=mp.id
+ left join public.multiplayer_rounds mr on mr.room_id=g.room_id and mr.round_number=g.round_number
+ where m.tournament_id=p_tournament group by m.account_id,m.joined_at,p.display_name
+ ), ranked as(select *,row_number() over(order by total desc,distance,joined_at,account_id) rank from totals)
+ select jsonb_build_object('id',t.id,'name',t.name,'ownerId',t.owner_id,'datasetId',rev.dataset_id,
+ 'startsAt',t.starts_at,'serverTimezone',t.server_timezone,'playerLimit',t.player_limit,'roundCount',t.round_count,
+ 'roundTimeSeconds',t.round_time_seconds,'difficulty',t.difficulty,'difficultySettings',t.difficulty_settings,
+ 'timerMode',t.timer_mode,'roundDeadline',tr.deadline,
+ 'announceDiscord',t.announce_discord,'status',t.status,'currentRound',t.current_round,'finishedAt',t.finished_at,'winnerId',t.winner_id,
+ 'announcementState',case when p_account=t.owner_id then (select state from public.tournament_announcements where tournament_id=t.id) else null end,
+ 'you',(select account_id from public.tournament_members where tournament_id=t.id and account_id=p_account),
+ 'roomId',tr.room_id,'currentRoom',case when m.account_id is not null and exists(select 1 from public.profiles where id=p_account and account_status='active') then public.multiplayer_snapshot(tr.room_id,m.access_hash) else null end,
+ 'players',coalesce((select jsonb_agg(jsonb_build_object('accountId',account_id,'name',display_name,'total',total,'rank',rank) order by rank) from ranked),'[]'::jsonb),
+ 'serverNow',floor(extract(epoch from clock_timestamp())*1000))
+ from public.tournaments t join public.revisions rev on rev.id=t.revision_id
+ left join public.tournament_rounds tr on tr.tournament_id=t.id and tr.round_number=t.current_round
+ left join public.tournament_members m on m.tournament_id=t.id and m.account_id=p_account where t.id=p_tournament;
+$$;
+
+create function public.tournament_action(p_tournament uuid,p_account uuid,p_action text,p_payload jsonb default '{}') returns jsonb
+language plpgsql security invoker set search_path='' as $$
+declare t public.tournaments; m public.tournament_members; rid uuid; start_time timestamptz; cap integer;
+begin
+ select * into t from public.tournaments where id=p_tournament for update;
+ if not found then raise exception 'Tournament not found'; end if;
+ if p_action<>'view' and not exists(select 1 from public.profiles where id=p_account and account_status='active') then raise exception 'Active account required'; end if;
+ perform public.tournament_tick(t.id);
+ select * into t from public.tournaments where id=t.id;
+ select * into m from public.tournament_members where tournament_id=t.id and account_id=p_account;
+ if p_action in('cancel','edit','start') and p_account is distinct from t.owner_id then raise exception 'Tournament owner permission required'; end if;
+ if p_action='join' then
+ if m.account_id is not null then return public.tournament_snapshot(t.id,p_account); end if;
+ if t.status<>'scheduled' then raise exception 'Registration is closed'; end if;
+ if (select count(*) from public.tournament_members where tournament_id=t.id)>=t.player_limit then raise exception 'Tournament is full'; end if;
+ insert into public.tournament_members(tournament_id,account_id) values(t.id,p_account);
+ elsif p_action='leave' then
+ if t.status<>'scheduled' then raise exception 'Registration is closed'; end if;
+ if p_account=t.owner_id then raise exception 'Owner must cancel the tournament'; end if;
+ delete from public.tournament_members where tournament_id=t.id and account_id=p_account;
+ elsif p_action='edit' then
+ if t.status<>'scheduled' then raise exception 'Only scheduled tournaments can be edited'; end if;
+ start_time:=coalesce((p_payload->>'startsAt')::timestamptz,t.starts_at);
+ if start_time<=clock_timestamp() or start_time>clock_timestamp()+interval '90 days' then raise exception 'Choose a future start within 90 days'; end if;
+ cap:=coalesce((p_payload->>'playerLimit')::integer,t.player_limit);
+ if cap not between 2 and 32 or cap<(select count(*) from public.tournament_members where tournament_id=t.id) then raise exception 'Player limit cannot exclude registered players'; end if;
+ update public.tournaments set starts_at=start_time,player_limit=cap,announce_discord=coalesce((p_payload->>'announceDiscord')::boolean,t.announce_discord) where id=t.id;
+ elsif p_action='cancel' then
+ if t.status not in('scheduled','running') then raise exception 'Tournament has ended'; end if;
+ update public.tournaments set status='cancelled' where id=t.id;
+ update public.multiplayer_rooms set status='closed',transition_at=null where tournament_id=t.id and status not in('finished','closed');
+ elsif p_action='start' then
+ if t.starts_at>clock_timestamp() then raise exception 'Wait for the scheduled start'; end if;
+ elsif p_action in('ready','guess','next') then
+ if t.status<>'running' or m.account_id is null then raise exception 'Tournament membership required'; end if;
+ if (p_payload->>'tournamentRound')::integer is distinct from t.current_round then raise exception 'Tournament round changed; refresh before submitting'; end if;
+ if p_action='next' and p_account<>t.owner_id then raise exception 'Tournament owner permission required'; end if;
+ select room_id into rid from public.tournament_rounds where tournament_id=t.id and round_number=t.current_round;
+ perform public.multiplayer_action(rid,m.access_hash,p_action,p_payload);
+ elsif p_action<>'view' then raise exception 'Invalid tournament action'; end if;
+ perform public.tournament_tick(t.id);
+ return public.tournament_snapshot(t.id,p_account);
+end; $$;
+
+create function public.tick_tournaments() returns void language plpgsql security invoker set search_path='' as $$
+declare target uuid;
+begin
+ for target in select id from public.tournaments where status='running' or (status='scheduled' and starts_at<=clock_timestamp()) order by starts_at,id for update skip locked
+ loop perform public.tournament_tick(target); end loop;
+end; $$;
+
+create function public.claim_tournament_announcements(p_worker uuid,p_target uuid default null)
+returns setof public.tournament_announcements language plpgsql security invoker set search_path='' as $$
+begin
+ if p_worker is null then raise exception 'Worker identity required'; end if;
+ update public.tournament_announcements set state='failed',lease_owner=null,lease_until=null,
+ last_error='Announcement retry limit reached after worker lease expired'
+ where state='sending' and attempts>=8 and lease_until<=clock_timestamp();
+ return query with due as (
+ select a.id from public.tournament_announcements a join public.tournaments t on t.id=a.tournament_id
+ where (p_target is null or t.id=p_target) and t.status='scheduled' and t.announce_discord
+ and t.starts_at>clock_timestamp() and t.starts_at-interval '15 minutes'<=clock_timestamp()
+ and a.attempts<8 and a.next_attempt_at<=clock_timestamp()
+ and (a.state='pending' or (a.state='sending' and a.lease_until<=clock_timestamp()))
+ order by a.next_attempt_at,a.id limit 10 for update of a skip locked
+ ) update public.tournament_announcements a set state='sending',attempts=attempts+1,lease_owner=p_worker,lease_until=clock_timestamp()+interval '2 minutes'
+ from due where a.id=due.id returning a.*;
+end; $$;
+
+revoke all on function public.sync_tournament_announcement(),public.guard_tournament_room_member(),public.create_tournament(uuid,jsonb),public.tournament_room_tick(uuid,uuid),public.tournament_tick(uuid),public.tournament_snapshot(uuid,uuid),public.tournament_action(uuid,uuid,text,jsonb),public.tick_tournaments(),public.claim_tournament_announcements(uuid,uuid) from public,anon,authenticated;
+grant execute on function public.sync_tournament_announcement(),public.guard_tournament_room_member(),public.create_tournament(uuid,jsonb),public.tournament_room_tick(uuid,uuid),public.tournament_tick(uuid),public.tournament_snapshot(uuid,uuid),public.tournament_action(uuid,uuid,text,jsonb),public.tick_tournaments(),public.claim_tournament_announcements(uuid,uuid) to service_role;
+-- Insert the award into the existing score query, preserving search/pagination.
+-- It changes ordering only: solo scores remain real, and the award expires dynamically.
+do $$
+declare body text;
+begin
+ select pg_get_functiondef('public.player_leaderboard(text,text,integer,integer)'::regprocedure) into body;
+ body:=replace(body,'), ranked as (', $patch$), champion as (
+ select t.winner_id player_id,jsonb_build_object('tournament_id',t.id,'name',t.name,'expires_at',t.finished_at+interval '7 days') award
+ from public.tournaments t join public.profiles p on p.id=t.winner_id and p.account_status='active'
+ where p_difficulty='all' and t.status='finished' and t.finished_at>now()-interval '7 days'
+ order by t.finished_at desc,t.id desc limit 1
+ ), eligible as (
+ select p.*,c.award tournament_champion from players p left join champion c on c.player_id=p.player_id
+ union all
+ select c.player_id,p.display_name,0::bigint,0::numeric,0::bigint,c.award
+ from champion c join public.profiles p on p.id=c.player_id
+ where not exists(select 1 from players where player_id=c.player_id)
+ ), ranked as ($patch$);
+ body:=replace(body,'order by best_score desc, average_score desc, games_played desc, player_id',
+ 'order by (tournament_champion is not null) desc, best_score desc, average_score desc, games_played desc, player_id');
+ body:=replace(body,'round(average_score)::integer as average_score, games_played','round(average_score)::integer as average_score, games_played, tournament_champion');
+ body:=replace(body,'from players'||chr(10)||' ), matched as (','from eligible'||chr(10)||' ), matched as (');
+ if strpos(body,'from eligible')=0 or strpos(body,'tournament_champion is not null')=0
+ or strpos(body,'tournament_champion from players')=0 then
+ raise exception 'Leaderboard migration could not locate the expected score-query anchors';
+ end if;
+ execute body;
+end; $$;
+-- Keep the current distinct-panorama pool and expanded difficulty snapshot,
+-- while retaining the account-only host identity introduced before presets.
+create or replace function public.create_map_room(
+ p_dataset uuid,p_difficulty text,p_name text,p_limit integer,
+ p_access_hash text,p_owner uuid
+) returns uuid language plpgsql security invoker set search_path='' as $$
+declare rid uuid; revision uuid; pid uuid:=gen_random_uuid(); rev public.revisions;
+ vp jsonb; locations jsonb; n integer:=0; settings jsonb; account_name text;
+begin
+ select display_name into account_name from public.profiles where id=p_owner and account_status='active';
+ if not found then raise exception 'Log in to create a multiplayer room with an active account'; end if;
+ if p_limit is null or p_limit not between 2 and 16 then raise exception 'Choose between 2 and 16 players'; end if;
+ settings:=public.resolve_difficulty_preset(p_difficulty);
+ revision:=public.playable_map_revision(p_dataset,p_owner);
+ select * into rev from public.revisions where id=revision;
+ if not found then raise exception 'Map unavailable'; end if;
+ locations:=public.game_viewpoint_pool(rev.manifest,rev.asset_root);
+ if jsonb_array_length(locations)<5 then raise exception 'Map needs at least five distinct panorama locations'; end if;
+ insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,difficulty_settings,player_limit)
+ values(rev.id,pid,p_difficulty,settings,p_limit) returning id into rid;
+ insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id)
+ values(pid,rid,p_access_hash,account_name,p_owner);
+ for vp in select value from jsonb_array_elements(locations) 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; $$;
+revoke all on function public.create_map_room(uuid,text,text,integer,text,uuid) from public,anon,authenticated;
+grant execute on function public.create_map_room(uuid,text,text,integer,text,uuid) to service_role;
+notify pgrst,'reload schema';
diff --git a/tests/cinematic-motion.test.ts b/tests/cinematic-motion.test.ts
new file mode 100644
index 0000000..779307e
--- /dev/null
+++ b/tests/cinematic-motion.test.ts
@@ -0,0 +1,48 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ easeOut,
+ easeInOut,
+ enter,
+ visibility,
+ shots,
+} from "../cinematics/motion.mjs";
+
+test("cinematic timeline preserves all real site captures and covers exactly thirty seconds", () => {
+ assert.equal(shots[0].start, 0);
+ assert.equal(shots.at(-1)?.end, 30);
+ assert.deepEqual(
+ shots.flatMap((s) => (s.image ? [s.image] : [])),
+ ["home", "catalog", "game", "map"],
+ );
+ for (let i = 1; i < shots.length; i++)
+ assert.equal(shots[i].start, shots[i - 1].end);
+});
+
+test("canonical motion curves clamp endpoints and have no overshoot", () => {
+ for (const curve of [easeOut, easeInOut]) {
+ assert.equal(curve(-1), 0);
+ assert.equal(curve(0), 0);
+ assert.equal(curve(1), 1);
+ assert.equal(curve(2), 1);
+ let previous = 0;
+ for (let i = 0; i <= 100; i++) {
+ const n = curve(i / 100);
+ assert.ok(n >= previous && n <= 1);
+ previous = n;
+ }
+ }
+ assert.ok(
+ easeOut(0.25) > 0.6,
+ "the entry must respond promptly rather than ease in",
+ );
+});
+
+test("screenshots settle quickly and stay fully readable throughout the hold", () => {
+ for (const shot of shots.filter((s) => s.image)) {
+ assert.equal(enter(shot.start + 0.25, shot.start), 1);
+ assert.equal(visibility(shot.start + 1, shot.start, shot.end), 1);
+ assert.equal(visibility(shot.end - 1, shot.start, shot.end), 1);
+ assert.equal(visibility(shot.end, shot.start, shot.end), 0);
+ }
+});
diff --git a/tests/difficulty-presets.test.ts b/tests/difficulty-presets.test.ts
index 2fb967d..335f070 100644
--- a/tests/difficulty-presets.test.ts
+++ b/tests/difficulty-presets.test.ts
@@ -1,9 +1,63 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { customDifficultySchema } from "../shared/protocol";
+import { customDifficultySchema, scoreGuess } from "../shared/protocol";
import { mergeDifficultyPresets } from "../shared/difficulty-presets";
const rules = { seconds: 90, zoom: true };
+test("expanded difficulty rules preserve defaults and validate controls", () => {
+ const preset = customDifficultySchema.parse({ name: "Classic", ...rules });
+ assert.deepEqual(
+ {
+ pan: preset.pan,
+ compass: preset.compass,
+ coordinates: preset.coordinates,
+ mapZoom: preset.mapZoom,
+ scoring: preset.scoring,
+ },
+ {
+ pan: true,
+ compass: true,
+ coordinates: true,
+ mapZoom: true,
+ scoring: "normal",
+ },
+ );
+ for (const key of ["pan", "compass", "coordinates", "mapZoom"])
+ assert.equal(
+ customDifficultySchema.safeParse({
+ name: "Fixture",
+ ...rules,
+ [key]: "false",
+ }).success,
+ false,
+ );
+ assert.equal(
+ customDifficultySchema.safeParse({
+ name: "Fixture",
+ ...rules,
+ scoring: "extreme",
+ }).success,
+ false,
+ );
+ assert.equal(
+ customDifficultySchema.safeParse({ name: "Fixture", ...rules, rounds: 10 })
+ .success,
+ false,
+ );
+});
+test("scoring strictness changes distance decay while perfect guesses remain 5000", () => {
+ const bounds = { minX: 0, minZ: 0, maxX: 100, maxZ: 100 };
+ const actual = { x: 10, z: 10 },
+ guess = { x: 40, z: 40 };
+ const scores = ["strict", "normal", "gentle"].map(
+ (mode) =>
+ scoreGuess(actual, guess, bounds, mode as "strict" | "normal" | "gentle")
+ .score,
+ );
+ assert.ok(scores[0] < scores[1] && scores[1] < scores[2]);
+ for (const mode of ["strict", "normal", "gentle"] as const)
+ assert.equal(scoreGuess(actual, actual, bounds, mode).score, 5000);
+});
test("difficulty names accept normal text and count Unicode characters consistently with PostgreSQL", () => {
for (const name of [
"Normal",
diff --git a/tests/tournament-discord.test.ts b/tests/tournament-discord.test.ts
new file mode 100644
index 0000000..6697397
--- /dev/null
+++ b/tests/tournament-discord.test.ts
@@ -0,0 +1,216 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { DiscordClient, DiscordDeliveryError } from "../lib/discord-client";
+import {
+ deliverTournamentAnnouncement,
+ tournamentAnnouncementMessage,
+ TOURNAMENT_NEWS_CHANNEL,
+ TOURNAMENT_EVENT_ROLES,
+ type TournamentAnnouncementJob,
+ type TournamentAnnouncementSource,
+} from "../lib/tournament-discord";
+
+const now = Date.parse("2026-10-18T10:00:00Z");
+const id = "00000000-0000-4000-8000-000000000001";
+const source: TournamentAnnouncementSource = {
+ id,
+ name: "Cave cup",
+ owner: "Pavel",
+ starts_at: new Date(now + 15 * 60000).toISOString(),
+ server_timezone: "UTC",
+ player_limit: 32,
+ round_count: 3,
+ round_time_seconds: 300,
+ timer_mode: "shared",
+ status: "scheduled",
+ announce_discord: true,
+};
+const job: TournamentAnnouncementJob = {
+ id,
+ tournament_id: id,
+ state: "sending",
+ attempts: 1,
+ next_attempt_at: new Date(now).toISOString(),
+ lease_owner: id,
+ lease_until: new Date(now + 120000).toISOString(),
+ channel_id: null,
+ message_id: null,
+ sent_at: null,
+ last_error: null,
+};
+
+test("tournament Discord announcements pin their channel, public URL, explicit role mentions and stable nonce", async () => {
+ const unsafe = {
+ ...source,
+ name: "Cup @everyone <@123> [bad](https://evil.example)",
+ owner: "Owner <@&999>",
+ };
+ const message = tournamentAnnouncementMessage(id, unsafe);
+ assert.deepEqual(message.allowed_mentions, {
+ roles: [...TOURNAMENT_EVENT_ROLES],
+ users: [],
+ replied_user: false,
+ });
+ assert.ok(
+ message.content.startsWith(
+ TOURNAMENT_EVENT_ROLES.map((role) => `<@&${role}>`).join(", "),
+ ),
+ );
+ assert.ok(message.content.includes(`https://minegeo.site/tournaments/${id}`));
+ assert.ok(!message.content.includes("Cup @everyone <@123>"));
+ assert.match(
+ message.embeds[0].fields.find(
+ (field) => field.name === "Starts · server time",
+ )!.value,
+ /UTC/,
+ );
+ assert.match(
+ message.embeds[0].fields.find((field) => field.name === "Format")!.value,
+ /3 tournament rounds · 5 mini-rounds each/,
+ );
+ assert.equal(message.nonce, tournamentAnnouncementMessage(id, source).nonce);
+ assert.notEqual(
+ message.nonce,
+ tournamentAnnouncementMessage("another-event", source).nonce,
+ );
+ assert.throws(
+ () =>
+ tournamentAnnouncementMessage(id, { ...source, id: "../../elsewhere" }),
+ /Invalid tournament link/,
+ );
+ const client = new DiscordClient("test-token", async (url, init) => {
+ assert.equal(
+ url,
+ `https://discord.com/api/v10/channels/${TOURNAMENT_NEWS_CHANNEL}/messages`,
+ );
+ assert.deepEqual(
+ JSON.parse(String(init?.body)).allowed_mentions,
+ message.allowed_mentions,
+ );
+ return Response.json({
+ id: "223456789012345678",
+ channel_id: TOURNAMENT_NEWS_CHANNEL,
+ });
+ });
+ assert.equal(
+ await client.send(TOURNAMENT_NEWS_CHANNEL, message),
+ "223456789012345678",
+ );
+});
+
+test("tournament announcement timer describes shared and per-mini-round budgets", () => {
+ const timer = (value: TournamentAnnouncementSource) =>
+ tournamentAnnouncementMessage(id, value).embeds[0].fields.find(
+ (field) => field.name === "Round timer",
+ )!.value;
+ assert.equal(timer(source), "300 seconds shared across all 5 mini-rounds");
+ assert.equal(
+ timer({ ...source, timer_mode: "perMini", round_time_seconds: 90 }),
+ "90 seconds per mini-round",
+ );
+ assert.equal(
+ timer({ ...source, round_time_seconds: 3000 }),
+ "3000 seconds shared across all 5 mini-rounds",
+ );
+});
+
+async function deliver(
+ value: TournamentAnnouncementSource | null,
+ failure?: Error,
+ attempts = 1,
+) {
+ const patches: Partial[] = [];
+ const sends: string[] = [];
+ await deliverTournamentAnnouncement(
+ { ...job, attempts },
+ {
+ now: () => now,
+ load: async () => value,
+ client: {
+ send: async (channel) => {
+ sends.push(channel);
+ if (failure) throw failure;
+ return "223456789012345678";
+ },
+ },
+ finish: async (patch) => {
+ patches.push(patch);
+ },
+ },
+ );
+ return { patches, sends };
+}
+
+test("only opted-in scheduled tournaments are announced during their last15minutes", async () => {
+ for (const value of [
+ null,
+ { ...source, announce_discord: false },
+ { ...source, status: "cancelled" },
+ { ...source, status: "running" },
+ { ...source, starts_at: new Date(now).toISOString() },
+ { ...source, id: "different" },
+ ]) {
+ const result = await deliver(value);
+ assert.equal(result.sends.length, 0);
+ assert.equal(result.patches[0].state, "skipped");
+ assert.equal(result.patches[0].lease_owner, null);
+ }
+ const sent = await deliver(source);
+ assert.deepEqual(sent.sends, [TOURNAMENT_NEWS_CHANNEL]);
+ assert.equal(sent.patches[0].state, "sent");
+ assert.equal(sent.patches[0].sent_at, new Date(now).toISOString());
+ assert.equal(
+ (
+ await deliver({
+ ...source,
+ starts_at: new Date(now + 60000).toISOString(),
+ })
+ ).patches[0].state,
+ "sent",
+ );
+});
+
+test("a rescheduled tournament releases its old lease until15minutes before its new start", async () => {
+ const result = await deliver({
+ ...source,
+ starts_at: new Date(now + 2 * 3600000).toISOString(),
+ });
+ assert.equal(result.sends.length, 0);
+ assert.equal(result.patches[0].state, "pending");
+ assert.equal(
+ result.patches[0].next_attempt_at,
+ new Date(now + 105 * 60000).toISOString(),
+ );
+});
+
+test("Discord rate limits retry durably and configuration failures stop after safe errors", async () => {
+ const limited = await deliver(
+ source,
+ new DiscordDeliveryError("Discord rate limit.", 180),
+ );
+ assert.equal(limited.patches[0].state, "pending");
+ assert.equal(
+ limited.patches[0].next_attempt_at,
+ new Date(now + 180000).toISOString(),
+ );
+ assert.equal(limited.patches[0].lease_until, null);
+ assert.equal(
+ (
+ await deliver(
+ source,
+ new DiscordDeliveryError("Missing permissions.", 60, true),
+ )
+ ).patches[0].state,
+ "failed",
+ );
+ assert.equal(
+ (await deliver(source, new Error("secret request body"))).patches[0]
+ .last_error,
+ "Tournament announcement could not be confirmed. Delivery will retry.",
+ );
+ assert.equal(
+ (await deliver(source, new DiscordDeliveryError("Retry."), 8)).patches[0]
+ .state,
+ "failed",
+ );
+});
diff --git a/tests/tournaments.test.ts b/tests/tournaments.test.ts
new file mode 100644
index 0000000..a46c3a0
--- /dev/null
+++ b/tests/tournaments.test.ts
@@ -0,0 +1,452 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { NextRequest } from "next/server";
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type { Database } from "../shared/database";
+import { tournamentApi } from "../lib/tournament-api";
+import {
+ serverLocalToIso,
+ tournamentSettings,
+ tournamentEdit,
+} from "../shared/tournaments";
+import { ServiceError } from "../lib/service-error";
+
+const tournament = "11111111-1111-4111-8111-111111111111";
+const member = "22222222-2222-4222-8222-222222222222";
+const map = "33333333-3333-4333-8333-333333333333";
+const room = "44444444-4444-4444-8444-444444444444";
+const revision = "55555555-5555-4555-8555-555555555555";
+const now = Date.parse("2026-10-18T12:00:00.000Z");
+const valid = {
+ name: "Sunday expedition",
+ datasetId: map,
+ difficulty: "normal",
+ playerLimit: 32,
+ roundCount: 10,
+ roundTimeSeconds: 300,
+ startsAt: "2026-10-19T17:00:00+05:00",
+ announceDiscord: true,
+};
+type Result = {
+ data: unknown;
+ error: { message: string; code?: string } | null;
+};
+function fixture({ signedIn = true, inactive = false } = {}) {
+ const calls: { table: string; method: string; args: unknown[] }[] = [];
+ const replies = new Map();
+ const rates: string[] = [],
+ announcements: (string | undefined)[] = [];
+ const signatures: (string | null)[][] = [];
+ let bodyReads = 0;
+ const take = (table: string) =>
+ replies.get(table)?.shift() ?? { data: null, error: null };
+ const db = {
+ from(table: string) {
+ const query: Record = {};
+ for (const method of ["select", "eq", "order", "limit", "single"])
+ query[method] = (...args: unknown[]) => {
+ calls.push({ table, method, args });
+ return query;
+ };
+ query.then = (resolve: (value: Result) => unknown) =>
+ Promise.resolve(take(table)).then(resolve);
+ return query;
+ },
+ rpc(table: string, args: unknown) {
+ calls.push({ table, method: "rpc", args: [args] });
+ return Promise.resolve(take(table));
+ },
+ } as unknown as SupabaseClient;
+ return {
+ calls,
+ rates,
+ announcements,
+ signatures,
+ get bodyReads() {
+ return bodyReads;
+ },
+ reply(table: string, data: unknown, error: Result["error"] = null) {
+ const list = replies.get(table) ?? [];
+ list.push({ data, error });
+ replies.set(table, list);
+ },
+ run(path: string, method = "GET", body?: unknown) {
+ return tournamentApi(
+ new NextRequest("https://minegeo.test/api/v1/" + path, { method }),
+ path.split("?")[0].split("/"),
+ {
+ db,
+ account: async () => {
+ if (inactive) throw new ServiceError("Account banned", 403);
+ return signedIn ? { id: member } : null;
+ },
+ jsonBody: async () => {
+ bodyReads++;
+ return body;
+ },
+ limit: async (key) => {
+ rates.push(key);
+ },
+ mediaUrls: async (paths) => {
+ signatures.push(paths);
+ return paths.map((path) =>
+ path ? "https://media.test/" + path : null,
+ );
+ },
+ announce: (target) => {
+ announcements.push(target);
+ },
+ serverTimezone: "Asia/Yekaterinburg",
+ },
+ );
+ },
+ };
+}
+function playingView(overrides: Record = {}) {
+ return {
+ id: tournament,
+ currentRound: 2,
+ you: member,
+ currentRoom: { id: room, you: member, status: "playing", round: 3 },
+ ...overrides,
+ };
+}
+const manifest = {
+ format: "minegeo-dataset/v1",
+ minecraftVersion: "1.21.11",
+ dimension: "minecraft:overworld",
+ source: "plugin",
+ name: "Fixture map",
+ serverName: "Fixture server",
+ north: "-z",
+ bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
+ viewpoints: Array.from({ length: 5 }, (_, i) => ({
+ id: `p${i}`,
+ x: i,
+ y: 64,
+ z: i,
+ })),
+ assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 }],
+};
+function mediaReplies(f: ReturnType, view = playingView()) {
+ f.reply("tournament_action", view);
+ f.reply("multiplayer_rooms", { revision_id: revision });
+ f.reply("multiplayer_rounds", { viewpoint: manifest.viewpoints[3] });
+ f.reply("revisions", { asset_root: map, manifest });
+}
+
+test("tournament limits include the creator, bound both round tiers, and keep timer modes explicit", () => {
+ assert.equal(tournamentSettings.parse(valid).timerMode, "shared");
+ for (const playerLimit of [2, 32])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, playerLimit }).success,
+ true,
+ );
+ for (const playerLimit of [1, 33, 2.5, "32", null])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, playerLimit }).success,
+ false,
+ );
+ for (const roundCount of [1, 10])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, roundCount }).success,
+ true,
+ );
+ for (const roundCount of [0, 11, 1.5])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, roundCount }).success,
+ false,
+ );
+ for (const roundTimeSeconds of [15, 600, 3000])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, roundTimeSeconds }).success,
+ true,
+ );
+ for (const roundTimeSeconds of [0, 14, 16, 3001, 15.5])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, roundTimeSeconds }).success,
+ false,
+ );
+ assert.equal(
+ tournamentSettings.safeParse({
+ ...valid,
+ timerMode: "perMini",
+ roundTimeSeconds: 600,
+ }).success,
+ true,
+ );
+ assert.equal(
+ tournamentSettings.safeParse({
+ ...valid,
+ timerMode: "perMini",
+ roundTimeSeconds: 615,
+ }).success,
+ false,
+ );
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, timerMode: "unknown" }).success,
+ false,
+ );
+ for (const extra of [
+ { ownerId: map },
+ { owner_id: map },
+ { score: 25000 },
+ { serverTimezone: "UTC" },
+ { rounds: 99 },
+ ])
+ assert.equal(
+ tournamentSettings.safeParse({ ...valid, ...extra }).success,
+ false,
+ );
+ assert.equal(
+ tournamentEdit.safeParse({
+ startsAt: valid.startsAt,
+ playerLimit: 32,
+ announceDiscord: true,
+ ownerId: map,
+ }).success,
+ false,
+ );
+});
+
+test("server local start times depend only on the server zone and represent the same instant", () => {
+ assert.equal(
+ serverLocalToIso("2026-10-18T17:00", "Asia/Yekaterinburg"),
+ "2026-10-18T12:00:00.000Z",
+ );
+ assert.equal(
+ serverLocalToIso("2026-10-18T12:00", "UTC"),
+ "2026-10-18T12:00:00.000Z",
+ );
+ assert.equal(
+ serverLocalToIso("2026-10-18T17:45", "Asia/Kathmandu"),
+ "2026-10-18T12:00:00.000Z",
+ );
+ assert.equal(
+ serverLocalToIso("2026-07-18T08:00", "America/New_York"),
+ "2026-07-18T12:00:00.000Z",
+ );
+ assert.equal(
+ serverLocalToIso("2026-01-18T07:00", "America/New_York"),
+ "2026-01-18T12:00:00.000Z",
+ );
+ for (const value of [
+ "2026-02-30T12:00",
+ "2026-02-29T12:00",
+ "2026-13-01T12:00",
+ "2026-10-18T24:00",
+ "not-a-date",
+ "2026-10-18",
+ ])
+ assert.throws(() => serverLocalToIso(value, "UTC"));
+ assert.throws(
+ () => serverLocalToIso("2026-03-08T02:30", "America/New_York"),
+ /does not exist/,
+ );
+ assert.throws(() => serverLocalToIso("2026-10-18T12:00", "Invalid/Timezone"));
+});
+
+test("guests cannot create, join, or play and banned account errors propagate before writes", async () => {
+ for (const [path, method, body] of [
+ ["tournaments", "POST", valid],
+ [`tournaments/${tournament}/join`, "POST", {}],
+ [
+ `tournaments/${tournament}/ready`,
+ "POST",
+ { round: 0, tournamentRound: 0 },
+ ],
+ [
+ `tournaments/${tournament}/guess`,
+ "POST",
+ { round: 0, tournamentRound: 0, x: 1, z: 1 },
+ ],
+ [`tournaments/${tournament}/media?tournamentRound=2`, "GET", undefined],
+ ] as const) {
+ const f = fixture({ signedIn: false });
+ await assert.rejects(
+ f.run(path, method, body),
+ (e) => e instanceof ServiceError && e.status === 401,
+ );
+ assert.equal(f.calls.length, 0);
+ assert.equal(f.bodyReads, 0);
+ }
+ const inactive = fixture({ inactive: true });
+ await assert.rejects(
+ inactive.run("tournaments", "POST", valid),
+ /Account banned/,
+ );
+ assert.equal(inactive.calls.length, 0);
+});
+
+test("creation derives the owner and timezone from trusted dependencies and rate-limits that account", async (context) => {
+ context.mock.method(Date, "now", () => now);
+ const f = fixture();
+ f.reply("create_tournament", tournament);
+ const response = await f.run("tournaments", "POST", valid);
+ assert.deepEqual(await response.json(), { id: tournament });
+ const args = f.calls.find((call) => call.table === "create_tournament")!
+ .args[0] as { p_owner: string; p_payload: Record };
+ assert.equal(args.p_owner, member);
+ assert.equal(args.p_payload.serverTimezone, "Asia/Yekaterinburg");
+ assert.equal(args.p_payload.timerMode, "shared");
+ assert.equal(
+ Date.parse(args.p_payload.startsAt as string),
+ Date.parse("2026-10-19T12:00:00Z"),
+ );
+ assert.deepEqual(f.rates, ["tournaments:create:" + member]);
+ assert.deepEqual(f.announcements, [tournament]);
+ assert.equal(response.headers.get("Cache-Control"), "no-store");
+ for (const extra of [
+ { ownerId: map },
+ { owner_id: map },
+ { score: 5000 },
+ { serverTimezone: "UTC" },
+ ]) {
+ const rejected = fixture();
+ await assert.rejects(
+ rejected.run("tournaments", "POST", { ...valid, ...extra }),
+ );
+ assert.equal(rejected.calls.length, 0);
+ }
+});
+
+test("schedules must be future instants no further than ninety days away", async (context) => {
+ context.mock.method(Date, "now", () => now);
+ for (const start of [now - 1, now, now + 90 * 86400000 + 1]) {
+ const f = fixture();
+ await assert.rejects(
+ f.run("tournaments", "POST", {
+ ...valid,
+ startsAt: new Date(start).toISOString(),
+ }),
+ (e) => e instanceof ServiceError && e.status === 400,
+ );
+ assert.equal(f.calls.length, 0);
+ }
+ const edge = fixture();
+ edge.reply("create_tournament", tournament);
+ assert.equal(
+ (
+ await edge.run("tournaments", "POST", {
+ ...valid,
+ startsAt: new Date(now + 90 * 86400000).toISOString(),
+ })
+ ).status,
+ 200,
+ );
+});
+
+test("game actions require both round indices and reject forged scores and unknown fields", async () => {
+ for (const action of ["ready", "guess", "next"]) {
+ const base = {
+ round: 4,
+ tournamentRound: 9,
+ ...(action === "guess" ? { x: 12, z: 21 } : {}),
+ };
+ const f = fixture();
+ f.reply("tournament_action", playingView());
+ await f.run(`tournaments/${tournament}/${action}`, "POST", base);
+ const args = f.calls.find((call) => call.table === "tournament_action")!
+ .args[0] as { p_account: string; p_action: string; p_payload: unknown };
+ assert.equal(args.p_account, member);
+ assert.equal(args.p_action, action);
+ assert.deepEqual(args.p_payload, base);
+ const { tournamentRound: _omitted, ...missing } = base;
+ for (const body of [
+ missing,
+ { ...base, tournamentRound: -1 },
+ { ...base, tournamentRound: 10 },
+ { ...base, tournamentRound: "2" },
+ { ...base, round: 5 },
+ { ...base, score: 5000 },
+ { ...base, playerId: map },
+ ...(action === "guess"
+ ? [
+ { ...base, x: Infinity },
+ { ...base, z: NaN },
+ ]
+ : []),
+ ]) {
+ const rejected = fixture();
+ await assert.rejects(
+ rejected.run(`tournaments/${tournament}/${action}`, "POST", body),
+ );
+ assert.equal(rejected.calls.length, 0);
+ }
+ }
+ for (const action of ["join", "leave", "cancel", "start"]) {
+ const f = fixture();
+ await assert.rejects(
+ f.run(`tournaments/${tournament}/${action}`, "POST", { ownerId: map }),
+ );
+ assert.equal(f.calls.length, 0);
+ }
+});
+
+test("spectators and unavailable rooms cannot sign panorama media", async () => {
+ for (const currentRoom of [
+ null,
+ { id: room, you: null, status: "playing", round: 0 },
+ { id: room, you: member, status: "lobby", round: 0 },
+ ]) {
+ const f = fixture();
+ f.reply("tournament_action", playingView({ you: null, currentRoom }));
+ await assert.rejects(
+ f.run(`tournaments/${tournament}/media?tournamentRound=2`),
+ (e) => e instanceof ServiceError && e.status === 409,
+ );
+ assert.equal(f.signatures.length, 0);
+ assert.equal(
+ f.calls.filter((call) => call.table !== "tournament_action").length,
+ 0,
+ );
+ }
+});
+
+test("media requires the current tournament round and signs only the current mini-round", async () => {
+ for (const query of [
+ "",
+ "?tournamentRound=1",
+ "?tournamentRound=3",
+ "?tournamentRound=-1",
+ "?tournamentRound=10",
+ "?tournamentRound=2.5",
+ ]) {
+ const f = fixture();
+ mediaReplies(f);
+ await assert.rejects(f.run(`tournaments/${tournament}/media${query}`));
+ assert.equal(f.signatures.length, 0);
+ }
+ const f = fixture();
+ mediaReplies(f);
+ const response = await f.run(
+ `tournaments/${tournament}/media?tournamentRound=2`,
+ );
+ const body = await response.json();
+ assert.equal(body.round, 3);
+ assert.equal(body.faces.length, 6);
+ assert.equal(f.signatures.length, 1);
+ assert.ok(
+ f.signatures[0].slice(1).every((path) => path?.startsWith(map + "/p3/")),
+ );
+ assert.ok(
+ f.calls.some(
+ (call) =>
+ call.table === "multiplayer_rounds" &&
+ call.method === "eq" &&
+ call.args[0] === "round_number" &&
+ call.args[1] === 3,
+ ),
+ );
+ assert.equal(JSON.stringify(body).includes("viewpoint"), false);
+ assert.equal(response.headers.get("Cache-Control"), "no-store");
+});
+
+test("a blank media round cannot be coerced into tournament round zero", async () => {
+ const f = fixture();
+ mediaReplies(f, playingView({ currentRound: 0 }));
+ await assert.rejects(
+ f.run(`tournaments/${tournament}/media?tournamentRound=`),
+ );
+ assert.equal(f.signatures.length, 0);
+});
diff --git a/worker/main.ts b/worker/main.ts
index 43755c6..3d63dae 100644
--- a/worker/main.ts
+++ b/worker/main.ts
@@ -8,6 +8,7 @@ import { datasetSchema, faces, type TerrainSnapshot } from "../shared/protocol";
import { processFabricImages } from "../lib/fabric-images";
import { renderSnapshot } from "../renderer/render.mjs";
import type { Database } from "../shared/database";
+import { dispatchTournamentAnnouncements } from "../lib/tournament-discord";
if (existsSync(".env.local")) process.loadEnvFile(".env.local");
const client = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
@@ -17,8 +18,31 @@ const client = createClient(
const worker = "worker-" + randomUUID();
let stopping = false;
let activeAbort: AbortController | null = null;
+let tournamentTickRunning = false;
+let tournamentWarningAt = 0;
+async function tickTournaments() {
+ if (stopping || tournamentTickRunning) return;
+ tournamentTickRunning = true;
+ try {
+ check(await client.rpc("tick_tournaments"));
+ await dispatchTournamentAnnouncements(client);
+ } catch {
+ if (Date.now() - tournamentWarningAt >= 300000) {
+ tournamentWarningAt = Date.now();
+ console.error(
+ "Tournament scheduling paused; check database migrations and connectivity. The worker will retry.",
+ );
+ }
+ } finally {
+ tournamentTickRunning = false;
+ }
+}
+// This remains independent of long panorama render jobs and idle website traffic.
+const tournamentTimer = setInterval(() => void tickTournaments(), 30000);
+tournamentTimer.unref();
function stopWorker() {
stopping = true;
+ clearInterval(tournamentTimer);
activeAbort?.abort();
}
process.on("SIGINT", stopWorker);
@@ -364,6 +388,7 @@ async function processJob(job: {
}
}
let housekeeping = 0;
+void tickTournaments();
console.log(`MineGeo ${worker} ready; PostgreSQL leases through Supabase API.`);
while (!stopping) {
try {