Add player leaderboard and homepage preview
This commit is contained in:
13
README.md
13
README.md
@@ -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
69
app/home-map.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
1080
app/leaderboard.css
Normal file
File diff suppressed because it is too large
Load Diff
24
app/leaderboard/loading.tsx
Normal file
24
app/leaderboard/loading.tsx
Normal 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
217
app/leaderboard/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
29
app/page.tsx
29
app/page.tsx
@@ -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
272
app/rounded-theme.css
Normal 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
203
components/Leaderboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,19 @@ 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">
|
||||
<div className={`card-image${minecraftPaper ? " minecraft-paper" : ""}`}>
|
||||
<div className="card-map-content">
|
||||
<Image
|
||||
src={map.image || "/images/example-map.webp"}
|
||||
alt={`${map.name} terrain`}
|
||||
@@ -24,6 +33,7 @@ export function MapCard({ map, index = 0 }: { map: CardMap; index?: number }) {
|
||||
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
56
components/PixelArt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
31
components/SiteNav.tsx
Normal 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
36
lib/leaderboard.ts
Normal 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
4
next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
public/cursors/orbit-link.svg
Normal file
5
public/cursors/orbit-link.svg
Normal 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
5
public/cursors/orbit.svg
Normal 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 |
BIN
public/images/minecraft-map-background.png
Normal file
BIN
public/images/minecraft-map-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 B |
137
scripts/test-leaderboard-sql.mjs
Normal file
137
scripts/test-leaderboard-sql.mjs
Normal 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();
|
||||
}
|
||||
@@ -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
54
shared/leaderboard.ts
Normal 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");
|
||||
61
supabase/migrations/20261006093345_player_leaderboard.sql
Normal file
61
supabase/migrations/20261006093345_player_leaderboard.sql
Normal 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
41
tests/leaderboard.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user