diff --git a/components/Shell.tsx b/components/Shell.tsx
index 6702954..c442b0e 100644
--- a/components/Shell.tsx
+++ b/components/Shell.tsx
@@ -3,17 +3,14 @@ import Link from "next/link";
import { Compass, ArrowUpRight, Globe2 } from "lucide-react";
import { user } from "@/lib/supabase";
import { Brand } from "./Brand";
+import { SiteNav } from "./SiteNav";
export async function Header() {
const account = await user();
return (
-
- Play & maps
- Downloads
- Guide
-
+
English
Tools & downloads
+ Leaderboard
Moderation
diff --git a/components/SiteNav.tsx b/components/SiteNav.tsx
new file mode 100644
index 0000000..4a0b81e
--- /dev/null
+++ b/components/SiteNav.tsx
@@ -0,0 +1,31 @@
+"use client";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const links = [
+ ["/servers", "Play & maps"],
+ ["/leaderboard", "Leaderboard"],
+ ["/downloads", "Downloads"],
+ ["/about", "Guide"],
+] as const;
+
+export function SiteNav() {
+ const pathname = usePathname();
+ return (
+
+ {links.map(([href, label]) => (
+
+ {label}
+
+ ))}
+
+ );
+}
diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts
new file mode 100644
index 0000000..3544c46
--- /dev/null
+++ b/lib/leaderboard.ts
@@ -0,0 +1,36 @@
+import "server-only";
+import { admin, required } from "./supabase";
+import type {
+ LeaderboardData,
+ LeaderboardDifficulty,
+} from "@/shared/leaderboard";
+
+export async function leaderboard({
+ difficulty = "all",
+ search = "",
+ page = 1,
+ limit = 25,
+}: {
+ difficulty?: LeaderboardDifficulty;
+ search?: string;
+ page?: number;
+ limit?: number;
+} = {}): Promise {
+ try {
+ return required(
+ await admin().rpc("player_leaderboard", {
+ p_difficulty: difficulty,
+ p_search: search,
+ p_page: page,
+ p_limit: limit,
+ }),
+ ) as unknown as LeaderboardData;
+ } catch (error) {
+ // A leaderboard outage must not take the map catalog or homepage offline.
+ console.error(
+ "Leaderboard query failed",
+ error instanceof Error ? error.message : "Unknown error",
+ );
+ return null;
+ }
+}
diff --git a/next-env.d.ts b/next-env.d.ts
index ce4e94a..a419cbe 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import "./.next/dev/types/routes.d.ts";
+import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/package.json b/package.json
index a896771..502aeaa 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"repair:panoramas": "node scripts/repair-panoramas.mjs",
"worker": "tsx worker/main.ts",
"test": "tsx --test tests/*.test.ts",
+ "test:leaderboard": "node scripts/test-leaderboard-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/cursors/orbit-link.svg b/public/cursors/orbit-link.svg
new file mode 100644
index 0000000..21964f3
--- /dev/null
+++ b/public/cursors/orbit-link.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/cursors/orbit.svg b/public/cursors/orbit.svg
new file mode 100644
index 0000000..0f715f3
--- /dev/null
+++ b/public/cursors/orbit.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/public/images/minecraft-map-background.png b/public/images/minecraft-map-background.png
new file mode 100644
index 0000000..e5fb6e3
Binary files /dev/null and b/public/images/minecraft-map-background.png differ
diff --git a/scripts/test-leaderboard-sql.mjs b/scripts/test-leaderboard-sql.mjs
new file mode 100644
index 0000000..54abc26
--- /dev/null
+++ b/scripts/test-leaderboard-sql.mjs
@@ -0,0 +1,137 @@
+// Isolated database only; no fixture accounts or scores enter the real board.
+import assert from "node:assert/strict";
+import { readFile, readdir } from "node:fs/promises";
+import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
+
+const db = new PGlite();
+try {
+ await db.exec(`create role anon; create role authenticated; create role service_role;
+ create schema auth; create table auth.users(id uuid primary key);
+ create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
+ create schema storage; create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint);`);
+ for (const file of (await readdir("supabase/migrations"))
+ .filter((file) => file.endsWith(".sql"))
+ .sort()) {
+ await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
+ }
+ async function board(difficulty = "all", search = "", page = 1, limit = 25) {
+ return (
+ await db.query("select public.player_leaderboard($1,$2,$3,$4) as board", [
+ difficulty,
+ search,
+ page,
+ limit,
+ ])
+ ).rows[0].board;
+ }
+ assert.deepEqual((await board()).entries, []);
+ assert.equal((await board()).page, 1);
+ assert.equal((await board()).pages, 1);
+
+ const ids = [];
+ for (const name of ["Alice", "Bob", "Carol", "%_Miner"]) {
+ const id = (
+ await db.query(
+ "insert into auth.users values(gen_random_uuid()) returning id",
+ )
+ ).rows[0].id;
+ await db.query(
+ "insert into public.profiles(id,display_name) values($1,$2)",
+ [id, name],
+ );
+ ids.push(id);
+ }
+ const dataset = (
+ await db.query(
+ `insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
+ values($1,'Leaderboard fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
+ [ids[0]],
+ )
+ ).rows[0].id;
+ const revision = (
+ await db.query(
+ "insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture','{}') returning id",
+ [dataset],
+ )
+ ).rows[0].id;
+ async function game(
+ owner,
+ difficulty,
+ score,
+ current = 5,
+ missingResult = false,
+ ) {
+ 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",
+ [revision, owner, difficulty, current],
+ )
+ ).rows[0].id;
+ for (let round = 0; round < 5; round++) {
+ await db.query(
+ "insert into public.game_rounds(session_id,round_number,viewpoint,result) values($1,$2,'{}',$3)",
+ [
+ session,
+ round,
+ missingResult && round === 4
+ ? null
+ : JSON.stringify({ score: score / 5 }),
+ ],
+ );
+ }
+ }
+ await game(ids[0], "easy", 19000);
+ await game(ids[0], "hard", 23000);
+ await game(ids[1], "easy", 21000);
+ await game(ids[1], "hard", 23000);
+ await game(ids[2], "easy", 20000);
+ await game(ids[2], "hard", 23000);
+ await game(ids[2], "hard", 23000);
+ await game(ids[3], "easy", 5000);
+ await game(null, "easy", 25000);
+ await game(ids[0], "normal", 25000, 4);
+ await game(ids[0], "normal", 25000, 5, true);
+
+ const all = await board();
+ assert.equal(all.total_players, 4);
+ assert.equal(all.total_games, 8);
+ assert.deepEqual(
+ all.entries.map((player) => player.display_name),
+ ["Carol", "Bob", "Alice", "%_Miner"],
+ );
+ assert.equal(all.entries[0].average_score, 22000);
+ assert.equal(all.entries[0].games_played, 3);
+ assert.deepEqual(
+ (await board("easy")).leaders.map((player) => player.display_name),
+ ["Bob", "Carol", "Alice"],
+ );
+ assert.equal((await board("normal")).total_players, 0);
+ assert.equal((await board("all", "alice")).entries[0].rank, 3);
+ assert.equal((await board("all", "%_")).matching_players, 1);
+ assert.equal((await board("all", "' OR 1=1--")).matching_players, 0);
+ assert.equal((await board("all", "missing")).pages, 1);
+ const last = await board("all", "", 999, 2);
+ assert.equal(last.page, 2);
+ assert.equal(last.pages, 2);
+ assert.deepEqual(
+ last.entries.map((player) => player.rank),
+ [3, 4],
+ );
+ assert.equal((await board("all", "", -1, 2)).page, 1);
+ assert.equal((await board("all", "", 1, 0)).entries.length, 1);
+ assert.equal((await board(null, null, null, null)).page, 1);
+ assert.equal(JSON.stringify(all).includes("private-fixture-secret"), false);
+ assert.equal(JSON.stringify(all).includes("viewpoint"), false);
+ for (const role of ["anon", "authenticated", "service_role"]) {
+ const result = await db.query(
+ "select has_function_privilege($1,'public.player_leaderboard(text,text,integer,integer)','execute') as allowed",
+ [role],
+ );
+ assert.equal(result.rows[0].allowed, role === "service_role");
+ }
+ console.log(
+ "Leaderboard SQL passed: empty board, completed games, guests, ranking ties, difficulty, literal search, pagination, and server-only permissions.",
+ );
+} finally {
+ await db.close();
+}
diff --git a/shared/database.ts b/shared/database.ts
index b648458..18c0bca 100644
--- a/shared/database.ts
+++ b/shared/database.ts
@@ -520,6 +520,15 @@ export type Database = {
[_ in never]: never;
};
Functions: {
+ player_leaderboard: {
+ Args: {
+ p_difficulty?: string;
+ p_search?: string;
+ p_page?: number;
+ p_limit?: number;
+ };
+ Returns: Json;
+ };
submit_content_report: {
Args: {
p_dataset: string;
diff --git a/shared/leaderboard.ts b/shared/leaderboard.ts
new file mode 100644
index 0000000..574ce04
--- /dev/null
+++ b/shared/leaderboard.ts
@@ -0,0 +1,54 @@
+export const leaderboardDifficulties = [
+ "all",
+ "easy",
+ "normal",
+ "hard",
+] as const;
+export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
+export type LeaderboardEntry = {
+ rank: number;
+ player_id: string;
+ display_name: string;
+ best_score: number;
+ average_score: number;
+ games_played: number;
+};
+export type LeaderboardData = {
+ entries: LeaderboardEntry[];
+ leaders: LeaderboardEntry[];
+ total_players: number;
+ total_games: number;
+ matching_players: number;
+ page: number;
+ pages: number;
+};
+
+export function leaderboardFilters(
+ params: Record,
+) {
+ const difficulty =
+ leaderboardDifficulties.find((value) => value === params.difficulty) ??
+ "all";
+ const search =
+ typeof params.q === "string" ? params.q.trim().slice(0, 64) : "";
+ const rawPage = typeof params.page === "string" ? Number(params.page) : 1;
+ const page =
+ Number.isSafeInteger(rawPage) && rawPage > 0
+ ? Math.min(rawPage, 1_000_000)
+ : 1;
+ return { difficulty, search, page };
+}
+
+export function leaderboardHref(
+ difficulty: LeaderboardDifficulty,
+ search = "",
+ page = 1,
+) {
+ const params = new URLSearchParams();
+ if (difficulty !== "all") params.set("difficulty", difficulty);
+ if (search) params.set("q", search);
+ if (page > 1) params.set("page", String(page));
+ return `/leaderboard${params.size ? `?${params}` : ""}`;
+}
+
+export const formatScore = (score: number) => score.toLocaleString("en-US");
diff --git a/supabase/migrations/20261006093345_player_leaderboard.sql b/supabase/migrations/20261006093345_player_leaderboard.sql
new file mode 100644
index 0000000..2ca895f
--- /dev/null
+++ b/supabase/migrations/20261006093345_player_leaderboard.sql
@@ -0,0 +1,61 @@
+-- Read through the server only: never expose session secrets or round answers.
+create index game_sessions_leaderboard_idx
+ on public.game_sessions (difficulty, owner_id)
+ where current_round = 5 and owner_id is not null;
+
+create function public.player_leaderboard(
+ p_difficulty text default 'all',
+ p_search text default '',
+ p_page integer default 1,
+ p_limit integer default 25
+) returns jsonb
+language sql stable security invoker set search_path = '' as $$
+ with completed as (
+ select s.id, s.owner_id, sum((r.result->>'score')::integer) as score
+ from public.game_sessions s
+ join public.game_rounds r on r.session_id = s.id
+ where s.current_round = 5 and s.owner_id is not null
+ and (p_difficulty = 'all' or s.difficulty = p_difficulty)
+ group by s.id, s.owner_id
+ having count(*) = 5 and count(r.result) = 5
+ ), players as (
+ select p.id as player_id, p.display_name,
+ max(c.score) as best_score, avg(c.score) as average_score,
+ count(*) as games_played
+ from completed c join public.profiles p on p.id = c.owner_id
+ group by p.id, p.display_name
+ ), ranked as (
+ select row_number() over (
+ order by best_score desc, average_score desc, games_played desc, player_id
+ ) as rank, player_id, display_name, best_score,
+ round(average_score)::integer as average_score, games_played
+ from players
+ ), matched as (
+ select * from ranked
+ where strpos(lower(display_name), lower(left(trim(coalesce(p_search, '')), 64))) > 0
+ ), settings as (
+ select greatest(1, least(coalesce(p_limit, 25), 100)) as page_size
+ ), counts as (
+ select (select count(*) from matched) as matching_players,
+ greatest(1, ceil((select count(*) from matched)::numeric / settings.page_size)::integer) as pages
+ from settings
+ ), pagination as (
+ select *, greatest(1, least(coalesce(p_page, 1), pages)) as page
+ from counts cross join settings
+ ), entries as (
+ select * from matched order by rank
+ limit (select page_size from pagination)
+ offset (select (page - 1) * page_size from pagination)
+ )
+ select jsonb_build_object(
+ 'entries', coalesce((select jsonb_agg(to_jsonb(e) order by rank) from entries e), '[]'::jsonb),
+ 'leaders', coalesce((select jsonb_agg(to_jsonb(r) order by rank) from
+ (select * from ranked order by rank limit 3) r), '[]'::jsonb),
+ 'total_players', (select count(*) from ranked),
+ 'total_games', (select count(*) from completed),
+ 'matching_players', matching_players, 'page', page, 'pages', pages
+ ) from pagination;
+$$;
+
+revoke all on function public.player_leaderboard(text, text, integer, integer) from public, anon, authenticated;
+grant execute on function public.player_leaderboard(text, text, integer, integer) to service_role;
diff --git a/tests/leaderboard.test.ts b/tests/leaderboard.test.ts
new file mode 100644
index 0000000..5e9d748
--- /dev/null
+++ b/tests/leaderboard.test.ts
@@ -0,0 +1,41 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { leaderboardFilters, leaderboardHref } from "../shared/leaderboard";
+
+test("leaderboard filters constrain malformed and repeated query parameters", () => {
+ assert.deepEqual(
+ leaderboardFilters({ difficulty: "hard", q: " Alex ", page: "3" }),
+ { difficulty: "hard", search: "Alex", page: 3 },
+ );
+ for (const page of ["-4", "0", "Infinity", "2.5", "no", ["2", "3"]]) {
+ assert.equal(leaderboardFilters({ page }).page, 1);
+ }
+ assert.equal(
+ leaderboardFilters({ difficulty: ["hard", "easy"] }).difficulty,
+ "all",
+ );
+ assert.equal(
+ leaderboardFilters({ difficulty: "creative" }).difficulty,
+ "all",
+ );
+ assert.equal(
+ leaderboardFilters({ q: "x".repeat(100), page: "2000000" }).search.length,
+ 64,
+ );
+ assert.equal(leaderboardFilters({ page: "2000000" }).page, 1_000_000);
+});
+
+test("leaderboard navigation preserves and encodes player searches", () => {
+ const link = new URL(
+ leaderboardHref("normal", "Alex & %_", 3),
+ "https://minegeo.test",
+ );
+ assert.equal(link.searchParams.get("difficulty"), "normal");
+ assert.equal(link.searchParams.get("q"), "Alex & %_");
+ assert.equal(link.searchParams.get("page"), "3");
+ assert.equal(leaderboardHref("all"), "/leaderboard");
+ assert.equal(
+ new URL(leaderboardHref("easy", "Alex"), link).searchParams.has("page"),
+ false,
+ );
+});