Add player leaderboard and homepage preview

This commit is contained in:
2026-10-06 15:21:49 +05:00
parent 2bbf5ae2c0
commit 949aa6627a
25 changed files with 2363 additions and 28 deletions

View File

@@ -58,6 +58,17 @@ The build copies both JARs to their Git-visible download folders. Include those
## Checks
The public `/leaderboard` and homepage preview rank signed-in players by their
best completed five-round expedition. Ties use average score, then games played.
Difficulty filters, literal player-name search, and pagination run in Postgres;
guest and incomplete games are excluded. Apply the `player_leaderboard` migration
when updating an existing installation. The score query is server-only and does
not expose session tokens, round locations, or contact information.
`npm run test:leaderboard` verifies ranking and permissions in an isolated
Postgres runtime. It uses the optional PGlite package already used for SQL checks
(`npm install --prefix .data/sql-verification --no-save --package-lock=false @electric-sql/pglite@0.3.14`).
Accounts support display names, email/password login and password recovery. See [account settings and Supabase configuration](docs/account-settings.md).
```sh
@@ -77,3 +88,5 @@ The renderer regression exercises the product's headless terrain worker with gra
Plugin exports include generated Overworld surface terrain (up to 24 blocks below each column's top), vanilla block states, and biome identifiers. Buried opaque blocks are omitted. Each export is limited to 5 million retained blocks, 240 MiB of raw terrain JSON, and 50 MiB compressed. Start with a 128 × 128 area, check the preview, then increase coverage. Lighting is simplified; custom packs, entities, and dynamic block-entity visuals are not captured by the server exporter.
The pixel heading font is VT323 by Peter Hull, distributed under the SIL Open Font License. Its license is in `public/fonts/OFL-VT323.txt`.
The homepage uses Minecraft 1.21.11’s original `assets/minecraft/textures/map/map_background.png`, extracted unchanged from the cached vanilla client to `public/images/minecraft-map-background.png`. The texture belongs to Mojang; its pixel detail and transparent parchment edges are retained.

69
app/home-map.css Normal file
View File

@@ -0,0 +1,69 @@
/* Vanilla 1.21.11 map_background.png, rendered with its original alpha edges. */
.card-map-content {
position: absolute;
inset: 0;
}
.home-page .minecraft-paper {
--map-paper-inset: 9.375%;
border: 0;
outline: 0;
clip-path: none;
background: url("/images/minecraft-map-background.png") center / 100% 100%
no-repeat;
box-shadow: none;
image-rendering: pixelated;
filter: drop-shadow(4px 6px 0 #04080c99);
}
.home-page .map-art-frame {
width: 320px;
height: 320px;
transform: none;
padding: 0;
}
.home-page .map-art-frame img {
position: absolute;
inset: var(--map-paper-inset);
width: 81.25%;
height: 81.25%;
display: block;
object-fit: contain;
image-rendering: pixelated;
}
.home-page .card-image .card-map-content {
inset: var(--map-paper-inset);
}
.home-page .minecraft-paper.card-image .card-map-content img {
object-fit: contain;
}
.home-page .map-card:hover .minecraft-paper img {
transform: none;
}
.home-page .minecraft-paper .card-type {
top: -16px;
left: 0;
}
.home-page .empty-map-art {
aspect-ratio: 1;
padding: 0;
transform: none;
}
.home-page .empty-map-art img {
position: absolute;
inset: var(--map-paper-inset);
width: 81.25%;
height: 81.25%;
object-fit: contain;
filter: none;
}
@media (max-width: 1000px) {
.home-page .map-art-frame {
width: 288px;
height: 288px;
}
}
@media (max-width: 700px) {
.home-page .map-art-frame {
width: 256px;
height: 256px;
}
}

View File

@@ -4,6 +4,9 @@ import "./block-theme.css";
import "./account.css";
import "./map-theme.css";
import "./workshop.css";
import "./leaderboard.css";
import "./home-map.css";
import "./rounded-theme.css";
export const metadata: Metadata = {
title: {
default: "MineGeo — Get lost in a familiar world",

1080
app/leaderboard.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
export default function LoadingLeaderboard() {
return (
<main
className="section page-content leaderboard-page"
aria-busy="true"
aria-label="Loading leaderboard"
>
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
<h1>Every block counts.</h1>
<p className="lead" role="status">
Fetching the explorers’ scores…
</p>
<div className="leaderboard-skeleton" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => (
<div key={index}>
<span />
<span />
<span />
</div>
))}
</div>
</main>
);
}

217
app/leaderboard/page.tsx Normal file
View File

@@ -0,0 +1,217 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Search } from "lucide-react";
import { Header, Footer } from "@/components/Shell";
import {
LeaderboardEmpty,
LeaderboardTable,
WinnersPodium,
} from "@/components/Leaderboard";
import { PixelTrophy } from "@/components/PixelArt";
import { leaderboard } from "@/lib/leaderboard";
import { user } from "@/lib/supabase";
import {
formatScore,
leaderboardDifficulties,
leaderboardFilters,
leaderboardHref,
} from "@/shared/leaderboard";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Leaderboard",
description:
"Meet MineGeo’s top explorers. Compare the best five-round scores across easy, normal, and hard Minecraft expeditions.",
};
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { difficulty, search, page } = leaderboardFilters(await searchParams);
const [data, account] = await Promise.all([
leaderboard({ difficulty, search, page }),
user(),
]);
return (
<>
<Header />
<main className="section page-content leaderboard-page" id="main-content">
<Link href="/" className="text-link leaderboard-back">
<ArrowLeft size={14} /> Back to home
</Link>
<header className="leaderboard-intro">
<div>
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
<h1>Every block counts.</h1>
<p className="lead">
The sharpest eyes in the Overworld.
<br />
Five rounds. 25,000 possible points. Where do you stand?
</p>
</div>
<PixelTrophy className="intro-trophy" />
</header>
<div className="leaderboard-toolbar">
<nav
className="leaderboard-filters"
aria-label="Filter leaderboard by difficulty"
>
{leaderboardDifficulties.map((value) => (
<Link
key={value}
href={leaderboardHref(value, search)}
aria-current={difficulty === value ? "page" : undefined}
>
{value === "all"
? "All difficulties"
: value[0].toUpperCase() + value.slice(1)}
</Link>
))}
</nav>
<span className="leaderboard-period">All time</span>
</div>
{data ? (
<>
{data.leaders.length > 0 && !search && data.page === 1 && (
<WinnersPodium leaders={data.leaders} />
)}
<section
className="rankings-board"
aria-labelledby="rankings-heading"
>
<div className="rankings-heading">
<div>
<h2 id="rankings-heading">The rankings</h2>
<p>
{formatScore(data.total_players)}{" "}
{data.total_players === 1 ? "explorer" : "explorers"} ·{" "}
{formatScore(data.total_games)} completed{" "}
{data.total_games === 1 ? "expedition" : "expeditions"}
</p>
</div>
<form
action="/leaderboard"
className="leaderboard-search"
role="search"
>
{difficulty !== "all" && (
<input type="hidden" name="difficulty" value={difficulty} />
)}
<label className="sr-only" htmlFor="player-search">
Search players
</label>
<input
key={`${difficulty}-${search}`}
id="player-search"
type="search"
name="q"
defaultValue={search}
placeholder="Find an explorer…"
maxLength={64}
/>
<button type="submit" aria-label="Search players">
<Search size={17} />
</button>
</form>
</div>
{search && (
<div className="search-summary">
<span>
{formatScore(data.matching_players)}{" "}
{data.matching_players === 1 ? "result" : "results"} for “
{search}”
</span>
<Link href={leaderboardHref(difficulty)}>Clear search ×</Link>
</div>
)}
{data.entries.length ? (
<LeaderboardTable
entries={data.entries}
playerId={account?.id}
/>
) : (
<LeaderboardEmpty search={!!search} />
)}
{data.matching_players > 0 && (
<div className="leaderboard-pagination">
<span>
Showing {formatScore((data.page - 1) * 25 + 1)}–
{formatScore(
Math.min(data.page * 25, data.matching_players),
)}{" "}
of {formatScore(data.matching_players)}
</span>
<nav aria-label="Leaderboard pages">
{data.page > 1 ? (
<Link
aria-label="Previous page"
href={leaderboardHref(
difficulty,
search,
data.page - 1,
)}
>
<ArrowLeft size={15} /> Previous
</Link>
) : (
<span aria-disabled="true">
<ArrowLeft size={15} /> Previous
</span>
)}
<span className="page-number">
{data.page} / {data.pages}
</span>
{data.page < data.pages ? (
<Link
aria-label="Next page"
href={leaderboardHref(
difficulty,
search,
data.page + 1,
)}
>
Next <ArrowRight size={15} />
</Link>
) : (
<span aria-disabled="true">
Next <ArrowRight size={15} />
</span>
)}
</nav>
</div>
)}
</section>
</>
) : (
<LeaderboardEmpty unavailable />
)}
<aside className="leaderboard-rules">
<div>
<span className="eyebrow">HOW THE BOARD WORKS</span>
<h2>A place earned, one guess at a time.</h2>
</div>
<div>
<p>
Your best completed expedition sets your rank. Ties are broken by
average score, then completed games. Difficulty filters rank only
games in that mode.
</p>
<p>
Log in before you play. All five rounds must be finished to count;
guest games stay off the board.
</p>
<Link href={account ? "/servers" : "/log-in"} className="text-link">
{account
? "Start your next expedition"
: "Log in to join the board"}{" "}
<ArrowRight size={15} />
</Link>
</div>
</aside>
</main>
<Footer />
</>
);
}

View File

@@ -11,20 +11,23 @@ import {
import { Header, Footer } from "@/components/Shell";
import { MapCard } from "@/components/MapCard";
import { catalog } from "@/lib/catalog";
import { leaderboard } from "@/lib/leaderboard";
import { LeaderboardPreview } from "@/components/Leaderboard";
export const dynamic = "force-dynamic";
export default async function Home() {
const maps = await catalog();
const [maps, rankings] = await Promise.all([
catalog(),
leaderboard({ limit: 5 }),
]);
const servers = new Set(maps.map((map) => map.server_id)).size;
const locations = maps.reduce((total, map) => total + map.panorama_count, 0);
return (
<>
<Header />
<main className="home-page">
<main className="home-page" id="main-content">
<section className="hero">
<div className="hero-copy">
<div className="status-label">
<span className="live-dot" /> MINECRAFT · EXPLORE · GUESS
</div>
<div className="status-label">A MINECRAFT GUESSING GAME</div>
<h1>
Guess where you are
<br />
@@ -40,10 +43,13 @@ export default async function Home() {
<Link href="/servers" className="button primary">
Start exploring <ArrowRight size={18} />
</Link>
<span className="hero-mode-note">3 difficulty levels</span>
<Link href="/leaderboard" className="text-link">
Meet the top explorers <ArrowRight size={14} />
</Link>
</div>
<div className="hero-notes">
<span className="mode-dots" aria-hidden="true">
<span className="round-blocks" aria-hidden="true">
<i />
<i />
<i />
<i />
@@ -53,10 +59,10 @@ export default async function Home() {
</div>
</div>
<div className="hero-art" aria-label="Minecraft surface map">
<div className="map-art-frame">
<div className="map-art-frame minecraft-paper">
<Image
src={maps[0]?.mapImage || "/images/example-map.webp"}
alt="A Minecraft surface map framed in parchment"
alt="A Minecraft surface map on the original Minecraft parchment texture"
width={1024}
height={1024}
loading="eager"
@@ -123,12 +129,12 @@ export default async function Home() {
{maps.length ? (
<div className="cards-grid">
{maps.slice(0, 4).map((map, index) => (
<MapCard map={map} index={index} key={map.id} />
<MapCard map={map} index={index} key={map.id} minecraftPaper />
))}
</div>
) : (
<div className="first-map-panel">
<div className="empty-map-art" aria-hidden="true">
<div className="empty-map-art minecraft-paper" aria-hidden="true">
<Image
src="/images/example-map.webp"
alt=""
@@ -155,6 +161,7 @@ export default async function Home() {
</div>
)}
</section>
<LeaderboardPreview data={rankings} />
<section className="section home-panels">
<article className="home-panel">
<span className="eyebrow">HOW TO PLAY</span>

272
app/rounded-theme.css Normal file
View File

@@ -0,0 +1,272 @@
/* Soft charcoal panels around the original pixel artwork and Minecraft maps. */
:root {
--bg: #080b10;
--panel: #1a222c;
--panel2: #222c37;
--line: #35404c;
--green: #8dce66;
--muted: #a1acb8;
--text: #eef1f4;
--board-stone: #1a222c;
--board-edge: #35404c;
--radius-panel: 16px;
--radius-control: 9px;
}
body {
background-image: radial-gradient(
ellipse at 80% 12%,
#25303d55,
transparent 55%
);
}
.site-header {
background: #0c1017;
border-bottom: 1px solid #2c3540;
box-shadow: none;
}
.hero {
grid-template-columns: 1.35fr 1fr;
padding-top: 52px;
padding-bottom: 52px;
}
.hero h1 {
font-size: clamp(48px, 4.4vw, 64px);
line-height: 1.05;
}
.hero h1 span {
color: var(--green);
}
.button {
border-radius: var(--radius-control);
background: #293542;
border: 1px solid #526071;
box-shadow:
inset 0 1px #ffffff14,
0 3px 8px #04070b55;
padding: 10px 20px;
}
.button.primary {
background: linear-gradient(#9ada72, #77b852);
border: 1px solid #a6df80;
color: #14200e;
box-shadow:
inset 0 1px #ddffc080,
0 3px 8px #04070b55;
}
.button.primary:hover {
background: linear-gradient(#ace783, #88c962);
}
.button:hover {
transform: translateY(-1px);
}
.button:active {
transform: translateY(1px);
}
/* .header-account deliberately retains its existing square, beveled styling. */
.compass-art {
border-radius: 50%;
border: 1px solid #536170;
background: #202b36;
width: 62px;
height: 62px;
box-shadow:
inset 0 1px #ffffff15,
0 5px 14px #04070b66;
}
.floating-tag {
border-radius: var(--radius-control);
background: #171f29;
border-color: #35404c;
box-shadow: 0 4px 12px #04070b55;
}
.world-stats {
position: relative;
z-index: 1;
width: calc(100% - 56px);
max-width: 1104px;
margin: -9px auto 0;
padding: 23px 24px;
border: 1px solid #35404c;
border-radius: var(--radius-panel);
background: #1a222c;
box-shadow: 0 8px 24px #03060a55;
}
.world-stats > div {
border-color: #35404c;
}
.world-stats .stat-cube.gold-cube {
background: #cba950;
border-color: #edca74;
}
.world-stats .stat-cube.cyan-cube {
background: #60b8bb;
border-color: #94dcde;
}
.world-stats .stat-cube.red-cube {
background: #c16067;
border-color: #e98a91;
}
.map-card,
.home-page .map-card {
border: 1px solid #35404c;
border-radius: var(--radius-panel);
background: #1a222c;
box-shadow: 0 4px 16px #03060a40;
overflow: hidden;
}
.map-card:hover {
border-color: #6c8b59;
transform: translateY(-3px);
}
.card-body,
.home-page .card-body {
background: #1a222c;
}
.card-type,
.card-number,
.badge {
border-radius: 4px;
}
.leaderboard-preview-board,
.rankings-board,
.home-panel,
.tool-card,
.start-panel,
.auth-card,
.settings-card,
.editor-panel,
.report-dialog,
.result-summary,
.round-result,
.empty-state,
.first-map-panel {
border-radius: var(--radius-panel);
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 5px 18px #03060a40;
}
.leaderboard-preview-board,
.rankings-board {
overflow: hidden;
}
.leaderboard-table thead {
background: #131b24;
}
.home-panels {
gap: 24px;
}
.home-panel,
.home-panel + .home-panel {
padding: 28px;
border: 1px solid var(--line);
}
.mode-row {
border-radius: var(--radius-control);
background: #202b36;
border-color: #3b4856;
box-shadow: none;
}
.mode-row:hover {
background: #293642;
border-color: #6c8b59;
}
.contribute-banner {
border-radius: var(--radius-panel);
background: #19251f;
border: 1px solid #435942;
box-shadow: none;
}
.leaderboard-filters a,
.leaderboard-pagination a,
.difficulty-options button,
.filter-tabs button,
.icon-button,
.auth-tabs a {
border-radius: var(--radius-control);
}
.field input,
.field textarea,
.field select,
.search-field,
.leaderboard-search,
.preview-toolbar select {
border-radius: var(--radius-control);
}
.draft-row,
.token-box,
.preview-tile,
.leaderboard-rules article {
border-radius: var(--radius-control);
}
.player-avatar {
border-radius: 5px;
overflow: hidden;
}
/* Native vector cursors track directly, with normal text/drag cursors retained. */
@media (hover: hover) and (pointer: fine) {
body {
cursor:
url("/cursors/orbit.svg") 14 14,
auto;
}
a,
button,
select,
summary,
label[for],
input[type="checkbox"],
input[type="radio"],
input[type="file"],
[role="button"] {
cursor:
url("/cursors/orbit-link.svg") 18 18,
pointer;
}
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
textarea,
[contenteditable="true"] {
cursor: text;
}
button:disabled,
[aria-disabled="true"] {
cursor: not-allowed;
}
}
@media (max-width: 1000px) {
.hero {
grid-template-columns: 1.15fr 1fr;
}
.hero h1 {
font-size: 54px;
}
}
@media (max-width: 700px) {
.hero {
grid-template-columns: 1fr;
padding: 36px 22px 32px;
}
.hero h1 {
font-size: clamp(43px, 10.8vw, 58px);
}
.world-stats {
width: calc(100% - 40px);
padding: 22px 18px;
gap: 24px 12px;
}
.world-stats strong {
font-size: 28px;
}
.home-panel,
.home-panel + .home-panel {
padding: 23px 20px;
}
.home-panels {
gap: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
a,
button {
transition: none;
}
}

203
components/Leaderboard.tsx Normal file
View File

@@ -0,0 +1,203 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { PixelTrophy, PlayerAvatar } from "./PixelArt";
import {
formatScore,
type LeaderboardData,
type LeaderboardEntry,
} from "@/shared/leaderboard";
export function LeaderboardEmpty({
unavailable = false,
search = false,
}: {
unavailable?: boolean;
search?: boolean;
}) {
return (
<div
className="leaderboard-empty"
role={unavailable ? "status" : undefined}
>
<PixelTrophy />
<h2>
{unavailable
? "The scoreboard is taking a break."
: search
? "No explorers found."
: "The first place is still open."}
</h2>
<p>
{unavailable
? "We couldn’t load the scores. Try again in a moment."
: search
? "Try another player name or clear your search."
: "Log in and finish five rounds to put your name on the board."}
</p>
{!search && (
<Link
href={unavailable ? "/leaderboard" : "/servers"}
className="button primary"
>
{unavailable ? "Reload scores" : "Play an expedition"}{" "}
<ArrowRight size={16} />
</Link>
)}
</div>
);
}
export function LeaderboardTable({
entries,
compact = false,
playerId,
}: {
entries: LeaderboardEntry[];
compact?: boolean;
playerId?: string;
}) {
return (
<div className={`leaderboard-table-wrap${compact ? " compact" : ""}`}>
<table className="leaderboard-table">
<caption className="sr-only">
Players ranked by best five-round score. Ties are broken by average
score, then games played.
</caption>
<thead>
<tr>
<th scope="col" className="rank-column">
Rank
</th>
<th scope="col">Explorer</th>
{!compact && (
<th scope="col" className="games-column">
Games
</th>
)}
{!compact && (
<th scope="col" className="average-column">
Average
</th>
)}
<th scope="col" className="score-column">
Best score
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.player_id}
className={entry.player_id === playerId ? "your-row" : undefined}
>
<td className="rank-column">
<span
className={`rank-number${entry.rank <= 3 ? ` place-${entry.rank}` : ""}`}
>
{String(entry.rank).padStart(2, "0")}
</span>
</td>
<th scope="row">
<div className="leaderboard-player">
<PlayerAvatar id={entry.player_id} />
<span className="player-name">
{entry.display_name}
{entry.player_id === playerId && (
<span className="you-label">You</span>
)}
</span>
</div>
</th>
{!compact && (
<td className="games-column">
{formatScore(entry.games_played)}
</td>
)}
{!compact && (
<td className="average-column">
{formatScore(entry.average_score)}
</td>
)}
<td className="score-column">
<strong>{formatScore(entry.best_score)}</strong>
<span className="score-unit"> pts</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
return (
<section className="winners-podium" aria-label="Top three explorers">
{leaders.map((entry, index) => (
<article
className={`podium-place podium-${index + 1}`}
key={entry.player_id}
>
<div className="podium-player">
<span className="podium-label">
{index === 0 ? "TOP EXPLORER" : `PLACE 0${index + 1}`}
</span>
<PlayerAvatar id={entry.player_id} />
<h2>{entry.display_name}</h2>
<p>
{formatScore(entry.best_score)} <span>/ 25,000</span>
</p>
</div>
<div className="podium-block" aria-hidden="true">
<span>0{index + 1}</span>
{index === 0 && <PixelTrophy />}
</div>
</article>
))}
</section>
);
}
export function LeaderboardPreview({ data }: { data: LeaderboardData | null }) {
return (
<section
className="section home-leaderboard"
aria-labelledby="home-leaderboard-heading"
>
<div className="leaderboard-preview-copy">
<div className="leaderboard-kicker">
<PixelTrophy />
<span className="eyebrow">THE EXPLORERS’ BOARD</span>
</div>
<h2 id="home-leaderboard-heading">
Good eye.
<br /> <span>Great guess.</span>
</h2>
<p>
Five rounds to find your way.
<br /> One score to make your mark.
</p>
<Link className="text-link" href="/leaderboard">
See the full leaderboard <ArrowRight size={16} />
</Link>
</div>
<div className="leaderboard-preview-board">
<div className="board-heading">
<span>Top explorers</span>
<span>All difficulties · All time</span>
</div>
{data?.entries.length ? (
<LeaderboardTable entries={data.entries} compact />
) : (
<LeaderboardEmpty unavailable={!data} />
)}
<div className="board-footnote">
Best expedition score ·{" "}
{data
? `${formatScore(data.total_players)} ranked ${data.total_players === 1 ? "explorer" : "explorers"}`
: "Scores temporarily unavailable"}
</div>
</div>
</section>
);
}

View File

@@ -12,18 +12,28 @@ export type CardMap = {
server?: { name: string } | undefined;
bounds: { minX: number; minZ: number; maxX: number; maxZ: number };
};
export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
export function MapCard({
map,
index = 0,
minecraftPaper = false,
}: {
map: CardMap;
index?: number;
minecraftPaper?: boolean;
}) {
return (
<Link className="map-card" href={`/servers/${map.id}`}>
<div className="card-image">
<Image
src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`}
fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized
sizes="(max-width: 640px) 100vw, 33vw"
/>
<div className={`card-image${minecraftPaper ? " minecraft-paper" : ""}`}>
<div className="card-map-content">
<Image
src={map.image || "/images/example-map.webp"}
alt={`${map.name} terrain`}
fill
loading={index < 3 ? "eager" : "lazy"}
unoptimized
sizes="(max-width: 640px) 100vw, 33vw"
/>
</div>
<span className="card-type">
{map.source === "plugin" ? "SERVER EXPORT" : "PLAYER CAPTURE"}
</span>

56
components/PixelArt.tsx Normal file
View File

@@ -0,0 +1,56 @@
export function PixelTrophy({ className = "" }: { className?: string }) {
return (
<svg
className={`pixel-trophy ${className}`}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
shapeRendering="crispEdges"
>
<path
fill="#735523"
d="M2 5h4V3h12v2h4v7h-2v2h-4v2h-2v3h4v3H6v-3h4v-3H8v-2H4v-2H2z"
/>
<path
fill="#d6b665"
d="M6 3h12v9h-2v2h-2v2h-4v-2H8v-2H6zM10 16h4v3h-4zM6 19h12v2H6zM2 5h4v2H4v4h2v2H4v-2H2zM18 5h4v6h-2v2h-2v-2h2V7h-2z"
/>
<path fill="#f0d891" d="M6 3h12v2H8v6H6zM6 19h12v1H6z" />
<path fill="#a1803c" d="M16 5h2v7h-2zM14 12h2v2h-2zM12 16h2v3h-2z" />
</svg>
);
}
// Generated markers, not player skins. No third-party avatar requests.
export function PlayerAvatar({ id }: { id: string }) {
const seed = [...id].reduce(
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
0,
);
const hair = ["#654b39", "#333e44", "#95723e", "#694f59"][seed % 4];
const skin = ["#c99a77", "#a67b60", "#dab28d", "#875f4d"][(seed >>> 3) % 4];
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
return (
<svg
className="player-avatar"
viewBox="0 0 8 8"
aria-hidden="true"
shapeRendering="crispEdges"
>
<path fill={shirt} d="M0 0h8v8H0z" />
<path fill={skin} d="M1 1h6v6H1z" />
<path
fill={hair}
d={
seed % 2
? "M1 0h6v2H3v1H1zM6 2h1v2H6z"
: "M1 0h6v2H1zM1 2h1v2H1zM6 2h1v2H6z"
}
/>
<path fill="#e6ded0" d="M2 3h1v1H2zM5 3h1v1H5z" />
<path fill="#3b4140" d="M3 3h1v1H3zM4 3h1v1H4z" />
<path fill="#825b49" d="M3 5h2v1H3z" />
<path fill={shirt} d="M0 7h8v1H0z" />
</svg>
);
}

View File

@@ -98,6 +98,9 @@ export function Results({ id }: { id: string }) {
<Link className="button" href="/dashboard">
My maps
</Link>
<Link className="text-link" href="/leaderboard">
View leaderboard →
</Link>
</div>
</div>
</div>

View File

@@ -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 (
<header className="site-header">
<div className="header-inner">
<Brand />
<nav aria-label="Main navigation">
<Link href="/servers">Play & maps</Link>
<Link href="/downloads">Downloads</Link>
<Link href="/about">Guide</Link>
</nav>
<SiteNav />
<Link
href={account ? "/dashboard" : "/log-in"}
className="header-account"
@@ -36,6 +33,7 @@ export function Footer() {
<Globe2 size={14} /> English
</span>
<Link href="/downloads">Tools & downloads</Link>
<Link href="/leaderboard">Leaderboard</Link>
<Link href="/moderation">Moderation</Link>
</div>
<p className="disclaimer">

31
components/SiteNav.tsx Normal file
View File

@@ -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 (
<nav aria-label="Main navigation">
{links.map(([href, label]) => (
<Link
key={href}
href={href}
aria-current={
pathname === href || pathname.startsWith(`${href}/`)
? "page"
: undefined
}
>
{label}
</Link>
))}
</nav>
);
}

36
lib/leaderboard.ts Normal file
View File

@@ -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<LeaderboardData | null> {
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;
}
}

4
next-env.d.ts vendored
View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@@ -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",

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="#8dce66" fill-opacity=".18" stroke="#080b10" stroke-width="4"/>
<circle cx="18" cy="18" r="14" fill="none" stroke="#b1e78b" stroke-width="2"/>
<circle cx="18" cy="18" r="3" fill="#b1e78b" stroke="#080b10" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

5
public/cursors/orbit.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
<circle cx="14" cy="14" r="10" fill="#080b10" fill-opacity=".22" stroke="#080b10" stroke-width="4"/>
<circle cx="14" cy="14" r="10" fill="none" stroke="#b1e78b" stroke-width="1.5"/>
<circle cx="14" cy="14" r="2" fill="#eef1f4" stroke="#080b10" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

View File

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

View File

@@ -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;

54
shared/leaderboard.ts Normal file
View File

@@ -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<string, string | string[] | undefined>,
) {
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");

View File

@@ -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;

41
tests/leaderboard.test.ts Normal file
View File

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