Add Void difficulty and publish changelogs
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s

This commit is contained in:
2026-10-20 14:05:01 +05:00
parent 3ba398a838
commit de0e891b62
37 changed files with 995 additions and 65 deletions

View File

@@ -17,6 +17,10 @@ MINEGEO_FORUM_URL=
DISCORD_BOT_TOKEN=
DISCORD_MAPS_CHANNEL_ID=
DISCORD_PARTIES_CHANNEL_ID=
# Optional overrides for the read-only changelog (defaults to MineGeo news and release webhook).
# The existing bot requires View Channel and Read Message History there.
DISCORD_NEWS_CHANNEL_ID=
DISCORD_RELEASE_WEBHOOK_ID=
# Optional bearer secret for scheduled GET /api/integrations/discord/dispatch retries.
CRON_SECRET=
# Development testing only; production rejects this bypass of CSRF protection.

View File

@@ -33,7 +33,7 @@ export default async function About() {
<h2>{t("Five chances to find your way")}</h2>
<p>
{t(
"Easy gives you 180 seconds, Normal 90, and Hard 30. Hard disables zoom. A timed-out round scores zero; you can still finish the expedition.",
"Easy gives you 180 seconds, Normal 90, and Hard 30. Void is the hardest: just 10 seconds, a fixed panorama and map, no zoom, no compass or coordinates, and strict scoring. A timed-out round scores zero; you can still finish the expedition.",
)}
</p>
<h2>{t("Closer means better")}</h2>

View File

@@ -384,7 +384,10 @@ html[data-theme="light"] .site-cursor-dot span {
background: #273b21;
box-shadow: 0 0 1px #fff;
}
@media (max-width: 1200px) {
.header-inner {
max-width: 1600px;
}
@media (max-width: 1500px) {
.site-header {
height: auto;
}
@@ -458,3 +461,31 @@ html[data-theme="light"] .site-cursor-dot span {
grid-row: 3;
}
}
.difficulty-options button.difficulty-void {
border-color: #a855f7;
background: linear-gradient(145deg, #21122f, #100c18);
color: #e9d5ff;
box-shadow: 0 0 16px #a855f738, inset 0 0 20px #a855f710;
}
.difficulty-options button.difficulty-void strong {
color: #e9d5ff;
text-shadow: 0 0 10px #c084fc99, 0 0 22px #a855f780;
}
.difficulty-options button.difficulty-void.selected,
html[data-theme="light"] .difficulty-options button.difficulty-void.selected {
color: #f3e8ff;
background: linear-gradient(145deg, #482363, #21122f);
border-color: #d8b4fe;
box-shadow: 0 0 24px #a855f760, inset 0 0 20px #c084fc18;
}
html[data-theme="light"] .difficulty-options button.difficulty-void {
background: #f3e8ff;
border-color: #a855f7;
color: #581c87;
}
html[data-theme="light"] .difficulty-options button.difficulty-void strong {
color: #7e22ce;
}
html[data-theme="light"] .difficulty-options button.difficulty-void.selected strong {
color: #f3e8ff;
}

77
app/changelogs/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
import Link from "next/link";
import type { Metadata } from "next";
import { Header, Footer } from "@/components/Shell";
import { ReleaseNotes } from "@/components/ReleaseNotes";
import { ChangelogRefresh } from "@/components/ChangelogRefresh";
import { getChangelog } from "@/lib/changelog";
import { getI18n } from "@/lib/i18n";
export const metadata: Metadata = {
title: "Changelogs",
description: "The latest MineGeo releases, improvements, and bug fixes.",
};
export default async function ChangelogsPage({
searchParams,
}: {
searchParams: Promise<{ before?: string }>;
}) {
const params = await searchParams;
const before =
typeof params.before === "string" && /^\d{17,20}$/.test(params.before)
? params.before
: undefined;
const [{ t, formatDate }, feed] = await Promise.all([
getI18n(),
getChangelog(before),
]);
return (
<>
<Header />
<main className="section page-content changelog-page">
{!before && <ChangelogRefresh />}
<span className="eyebrow">{t("WHAT'S NEW")}</span>
<h1>{t("Changelogs")}</h1>
<p className="lead">
{t("New releases, improvements, and fixes from MineGeo.")}
</p>
{before && <Link href="/changelogs">{t("Latest releases")}</Link>}
{feed.unavailable ? (
<div className="empty-state">
<h2>{t("Release notes are temporarily unavailable")}</h2>
<p>{t("Please check back soon.")}</p>
</div>
) : feed.entries.length === 0 ? (
<p className="muted">{t("No releases on this page yet.")}</p>
) : (
<div className="changelog-list">
{feed.entries.map((entry) => (
<article
className="changelog-entry"
key={entry.id}
id={`release-${entry.id}`}
>
<div className="changelog-meta">
<span className="tag">{t(entry.kind)}</span>
<time dateTime={entry.publishedAt}>
{formatDate(entry.publishedAt)}
</time>
</div>
<h2>{entry.title}</h2>
<div className="release-notes">
<ReleaseNotes body={entry.body} />
</div>
</article>
))}
</div>
)}
{!feed.unavailable && "before" in feed && feed.before && (
<Link className="button" href={`/changelogs?before=${feed.before}`}>
{t("Older posts")}
</Link>
)}
</main>
<Footer />
</>
);
}

View File

@@ -56,7 +56,7 @@ export default async function DashboardPage() {
typeof s.difficulty_settings === "object" &&
!Array.isArray(s.difficulty_settings) &&
typeof s.difficulty_settings.name === "string"
? ["easy", "normal", "hard"].includes(s.difficulty)
? ["easy", "normal", "hard", "void"].includes(s.difficulty)
? t(s.difficulty_settings.name)
: s.difficulty_settings.name
: t(

View File

@@ -1745,3 +1745,13 @@ pre {
display: block;
position: relative;
}
.changelog-page { max-width: 960px; }
.changelog-list { margin: 36px 0; }
.changelog-entry { padding: 32px 0; border-top: 1px solid var(--line); scroll-margin-top: 100px; }
.changelog-entry h2 { margin: 14px 0 20px; font-size: clamp(1.4rem, 3vw, 2rem); }
.changelog-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; color: var(--muted); font-size: .85rem; }
.release-notes { line-height: 1.8; overflow-wrap: anywhere; }
.release-notes ul { padding-left: 24px; }
.release-notes li + li { margin-top: 12px; }
.release-notes p { white-space: normal; }

View File

@@ -23,7 +23,7 @@ export async function generateMetadata(): Promise<Metadata> {
return {
title: t("Leaderboard"),
description: t(
"Meet MineGeo’s top explorers. Compare the best five-round scores across easy, normal, and hard Minecraft expeditions.",
"Meet MineGeo’s top explorers. Compare the best five-round scores across Easy, Normal, Hard, and Void Minecraft expeditions.",
),
};
}

View File

@@ -0,0 +1,19 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export function ChangelogRefresh() {
const router = useRouter();
useEffect(() => {
const refresh = () => {
if (document.visibilityState === "visible") router.refresh();
};
const timer = window.setInterval(refresh, 65_000);
document.addEventListener("visibilitychange", refresh);
return () => {
window.clearInterval(timer);
document.removeEventListener("visibilitychange", refresh);
};
}, [router]);
return null;
}

View File

@@ -217,13 +217,15 @@ export function Game({ id }: { id: string }) {
<small>{t("ROUND")}</small>
<strong>{session ? session.round + 1 : "—"} / 5</strong>
</div>
<div>
<small>{t("TIME LEFT")}</small>
<strong className={remaining <= 10 ? "urgent" : ""}>
{Math.floor(remaining / 60)}:
{String(remaining % 60).padStart(2, "0")}
</strong>
</div>
{session?.difficultySettings.timerVisible !== false && (
<div>
<small>{t("TIME LEFT")}</small>
<strong className={remaining <= 10 ? "urgent" : ""}>
{Math.floor(remaining / 60)}:
{String(remaining % 60).padStart(2, "0")}
</strong>
</div>
)}
<div>
<small>{t("POINTS")}</small>
<strong>{formatNumber(total)}</strong>
@@ -273,6 +275,7 @@ export function Game({ id }: { id: string }) {
onError={onError}
disabled={!!result || busy}
zoom={session.difficultySettings.mapZoom}
pan={session.difficultySettings.mapPan}
compass={session.difficultySettings.compass}
/>
<div className="map-actions">

View File

@@ -16,6 +16,7 @@ export function GuessMap({
onError,
disabled = false,
zoom = true,
pan = true,
compass = true,
}: {
bounds: Bounds;
@@ -27,6 +28,7 @@ export function GuessMap({
onError?: (message: string) => void;
disabled?: boolean;
zoom?: boolean;
pan?: boolean;
compass?: boolean;
}) {
const { t } = useI18n();
@@ -68,11 +70,16 @@ export function GuessMap({
touchZoom: zoom,
doubleClickZoom: zoom,
boxZoom: zoom,
dragging: pan,
keyboard: false,
});
if (zoom)
L.control
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
.addTo(m);
// Leaflet's keyboard handler couples panning and +/- zoom. Gate them
// separately so a fixed map cannot be moved or zoomed by keyboard.
if (pan && zoom) m.keyboard.enable();
// Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined;
cleanup = () => {
@@ -119,6 +126,29 @@ export function GuessMap({
callbacks.current.onPin?.({ x, z });
});
const keydown = (event: KeyboardEvent) => {
if (
event.target === m.getContainer() &&
pan &&
!zoom &&
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
event.key,
)
) {
event.preventDefault();
m.panBy([
event.key === "ArrowLeft"
? -80
: event.key === "ArrowRight"
? 80
: 0,
event.key === "ArrowUp"
? -80
: event.key === "ArrowDown"
? 80
: 0,
]);
return;
}
if (
event.key !== "Enter" ||
event.target !== m.getContainer() ||
@@ -171,6 +201,7 @@ export function GuessMap({
image,
attempt,
zoom,
pan,
]);
useEffect(() => {
const L = lib.current,
@@ -204,15 +235,19 @@ export function GuessMap({
style={{ height: "100%", width: "100%" }}
role="application"
aria-label={
!compass
!pan
? t(
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
"Minecraft map. Click to place a guess, or press Enter to pin the center.",
)
: disabled
? t("Minecraft map. North is up.")
: t(
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
: !compass
? t(
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
: disabled
? t("Minecraft map. North is up.")
: t(
"Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
)
}
/>
{error && (

View File

@@ -125,18 +125,20 @@ export function MultiplayerGame({
<small>{t("ROUND")}</small>
<strong>{room.round + 1} / 5</strong>
</div>
<div>
<small>{t("TIME LEFT")}</small>
<strong
className={
room.status === "playing" && seconds <= 10 ? "urgent" : ""
}
>
{room.status === "loading"
? t("Loading…")
: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`}
</strong>
</div>
{room.difficultySettings.timerVisible !== false && (
<div>
<small>{t("TIME LEFT")}</small>
<strong
className={
room.status === "playing" && seconds <= 10 ? "urgent" : ""
}
>
{room.status === "loading"
? t("Loading…")
: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`}
</strong>
</div>
)}
<div>
<small>{t("POINTS")}</small>
<strong>{formatNumber(me?.total ?? 0)}</strong>
@@ -214,6 +216,7 @@ export function MultiplayerGame({
onReady={mapReady}
onError={mediaError}
zoom={room.difficultySettings.mapZoom}
pan={room.difficultySettings.mapPan}
compass={room.difficultySettings.compass}
disabled={
room.status !== "playing" || submitted || busy || seconds === 0

View File

@@ -0,0 +1,45 @@
import { Fragment } from "react";
// Render a small, safe subset of Discord Markdown; React escapes raw HTML.
function inline(text: string) {
return text
.split(/(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\(https?:\/\/[^\s)]+\))/g)
.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**"))
return <strong key={i}>{part.slice(2, -2)}</strong>;
if (part.startsWith("`") && part.endsWith("`"))
return <code key={i}>{part.slice(1, -1)}</code>;
const link = /^\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)$/.exec(part);
if (link)
return (
<a key={i} href={link[2]} target="_blank" rel="noopener noreferrer">
{link[1]}
</a>
);
return <Fragment key={i}>{part}</Fragment>;
});
}
export function ReleaseNotes({ body }: { body: string }) {
return body.split(/\r?\n\s*\r?\n/).map((block, i) => {
const lines = block.split(/\r?\n/);
if (lines.every((line) => /^\s*[-*]\s+/.test(line)))
return (
<ul key={i}>
{lines.map((line, j) => (
<li key={j}>{inline(line.replace(/^\s*[-*]\s+/, ""))}</li>
))}
</ul>
);
return (
<p key={i}>
{lines.map((line, j) => (
<Fragment key={j}>
{j > 0 && <br />}
{inline(line)}
</Fragment>
))}
</p>
);
});
}

View File

@@ -59,6 +59,7 @@ export async function Footer() {
<LanguageSwitcher />
<Link href="/downloads">{t("Tools & downloads")}</Link>
<Link href="/docs">{t("Docs")}</Link>
<Link href="/changelogs">{t("Changelogs")}</Link>
{forum && (
<a href={forum} target="_blank" rel="noopener noreferrer">
{t("Forum")}

View File

@@ -8,6 +8,7 @@ const links = [
["/servers", "Play & maps"],
["/leaderboard", "Leaderboard"],
["/tournaments", "Tournaments"],
["/changelogs", "Changelogs"],
["/downloads", "Downloads"],
["/about", "Guide"],
["/docs", "Docs"],

View File

@@ -34,6 +34,8 @@ export function StartGame({
compass: boolean;
coordinates: boolean;
mapZoom: boolean;
mapPan: boolean;
timerVisible: boolean;
scoring: ScoringMode;
}>(difficultyRuleDefaults),
[savingDifficulty, setSavingDifficulty] = useState(false),
@@ -63,6 +65,8 @@ export function StartGame({
}, []);
const options: DifficultyPreset[] = [
...Object.entries(difficulties).map(([id, preset]) => ({
...difficultyRuleDefaults,
...preset,
id,
name: preset.label,
seconds: preset.seconds,
@@ -169,7 +173,10 @@ export function StartGame({
<button
key={option.id}
aria-pressed={difficulty === option.id}
className={difficulty === option.id ? "selected" : ""}
className={[
difficulty === option.id ? "selected" : "",
option.id === "void" ? "difficulty-void" : "",
].join(" ")}
disabled={loading}
onClick={() => setDifficulty(option.id)}
>
@@ -185,7 +192,7 @@ export function StartGame({
<ZoomIn size={13} />
{option.zoom ? t("Zoom allowed") : t("No zoom")}
</span>
{!(option.id in difficulties) && (
{(!(option.id in difficulties) || option.id === "void") && (
<span>
{t(
option.scoring === "strict"
@@ -197,6 +204,9 @@ export function StartGame({
· {t(option.pan === false ? "Fixed view" : "Panning allowed")}
</span>
)}
{option.id === "void" && (
<span>{t("Hardest · No compass · Fixed map")}</span>
)}
</button>
))}
</div>
@@ -238,9 +248,9 @@ export function StartGame({
<input
id="custom-difficulty-seconds"
type="number"
min={15}
min={5}
max={600}
step={15}
step={5}
value={difficultySeconds}
onChange={(event) =>
setDifficultySeconds(Number(event.target.value))
@@ -265,6 +275,8 @@ export function StartGame({
["compass", "Show north indicator"],
["coordinates", "Show guess coordinates"],
["mapZoom", "Allow guess-map zoom"],
["mapPan", "Allow guess-map panning"],
["timerVisible", "Show round countdown"],
] as const
).map(([key, label]) => (
<label className="difficulty-builder-zoom" key={key}>

View File

@@ -56,6 +56,7 @@ export function TournamentIndex({
const [clock, setClock] = useState(0);
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
const [roundSeconds, setRoundSeconds] = useState(450);
const [difficulty, setDifficulty] = useState("normal");
const offset = useRef(0);
const load = useCallback(async () => {
try {
@@ -115,8 +116,8 @@ export function TournamentIndex({
difficulty: form.get("difficulty"),
playerLimit: Number(form.get("limit")),
roundCount: Number(form.get("rounds")),
roundTimeSeconds: Number(form.get("seconds")),
timerMode: form.get("timerMode"),
roundTimeSeconds: difficulty === "void" ? 10 : roundSeconds,
timerMode: difficulty === "void" ? "perMini" : timerMode,
startsAt: serverLocalToIso(
String(form.get("start")),
events.serverTimezone,
@@ -211,10 +212,15 @@ export function TournamentIndex({
</label>
<label>
{t("Difficulty")}
<select name="difficulty" defaultValue="normal">
<select
name="difficulty"
value={difficulty}
onChange={(event) => setDifficulty(event.target.value)}
>
<option value="easy">{t("Easy")}</option>
<option value="normal">{t("Normal")}</option>
<option value="hard">{t("Hard")}</option>
<option value="void">{t("Void")}</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
@@ -248,7 +254,8 @@ export function TournamentIndex({
{t("Timer mode")}
<select
name="timerMode"
value={timerMode}
value={difficulty === "void" ? "perMini" : timerMode}
disabled={difficulty === "void"}
onChange={(e) => {
const mode = e.target.value as "shared" | "perMini";
setTimerMode(mode);
@@ -265,17 +272,18 @@ export function TournamentIndex({
</label>
<label>
{t(
timerMode === "shared"
difficulty !== "void" && timerMode === "shared"
? "Seconds per tournament round"
: "Seconds per mini-round",
)}
<input
name="seconds"
type="number"
min={15}
min={difficulty === "void" ? 10 : 15}
max={timerMode === "shared" ? 3000 : 600}
step={15}
value={roundSeconds}
step={difficulty === "void" ? 5 : 15}
value={difficulty === "void" ? 10 : roundSeconds}
disabled={difficulty === "void"}
onChange={(e) => setRoundSeconds(Number(e.target.value))}
required
/>

23
docs/changelogs.md Normal file
View File

@@ -0,0 +1,23 @@
# Release changelogs
`/changelogs` reads messages from MineGeo's `___news___` channel (`1558048830770122772`).
Only release posts from webhook ID `1561021045211660401` appear. The bot only reads
message history using GET; this feature never sends, edits, or deletes Discord messages.
The webhook URL and its secret token are not needed or stored by the reader.
Use the existing server-only `DISCORD_BOT_TOKEN` with View Channel and Read Message
History in news. Enable Message Content Intent in Discord Developer Portal → Bot;
otherwise Discord redacts the webhook messages' text. Optional `DISCORD_NEWS_CHANNEL_ID` and `DISCORD_RELEASE_WEBHOOK_ID`
override the defaults. Never prefix these variables with `NEXT_PUBLIC_`.
The feed accepts `## MineGeo 1.0.4 Alpha`, `Beta`, `Production`, and four-part
`Bug fix` headings. It removes role mentions, leading separators, and the trailing
`PLAY THE MINEGEO AT:` footer. React safely renders paragraphs, bullet lists, bold,
inline code, and HTTP(S) links; raw HTML cannot execute.
Discord history is cached for 60 seconds. The latest page refreshes every 65 seconds
while visible. Each page scans 100 Discord messages; Older posts continues through
history even when a page contains only tournament announcements. Edited and deleted
release posts are reflected after revalidation. If credentials or history permissions
are unavailable, the page displays a temporary-unavailability state, without exposing
Discord errors or credentials. There is no database migration for changelogs.

View File

@@ -2,7 +2,7 @@
Signed-in users with active accounts can create a tournament at `/tournaments` and share its `/tournaments/{id}` page. The organizer chooses a published map with at least five distinct panorama locations, a difficulty, a scheduled start, a player cap from 2 to 32, and 1 to 10 tournament rounds. Every tournament round contains exactly five mini-rounds.
The organizer selects either a shared time budget across the five mini-rounds (the default), or a separate timer for each mini-round. Shared budgets accept 15–3000 seconds; per-mini-round timers accept 15–600 seconds. Both use 15-second increments. The shared clock starts when the first mini-round becomes playable, excludes initial loading, and keeps running through later loading and result screens. When it expires, all unplayed mini-rounds close with zero points. Difficulty settings are saved with the tournament, so later preset changes do not alter a running competition.
The organizer selects either a shared time budget across the five mini-rounds (the default), or a separate timer for each mini-round. Shared budgets accept 15–3000 seconds; per-mini-round timers accept 15–600 seconds. Both use 15-second increments. Void always uses a separate 10-second timer per mini-round. The shared clock starts when the first mini-round becomes playable, excludes initial loading, and keeps running through later loading and result screens. When it expires, all unplayed mini-rounds close with zero points. Difficulty settings are saved with the tournament, so later preset changes do not alter a running competition.
The schedule form displays the application server's timezone. Its local date/time input is interpreted in that zone, even when the organizer's browser uses another zone. The server validates future starts within 90 days; browser clocks do not decide when gameplay begins. Discord announcements include the server date/time and zone, plus Discord timestamps displayed in each reader's local time.

View File

@@ -188,14 +188,16 @@ export async function handle(request: NextRequest, path: string[]) {
await client
.from("difficulty_presets")
.select(
"id,name,seconds,zoom,pan,compass,coordinates,map_zoom,scoring",
"id,name,seconds,zoom,pan,compass,coordinates,map_zoom,map_pan,timer_visible,scoring",
)
.order("name", { ascending: true }),
);
return NextResponse.json({
presets: presets.map(({ map_zoom, ...preset }) => ({
presets: presets.map(({ map_zoom, map_pan, timer_visible, ...preset }) => ({
...preset,
mapZoom: map_zoom,
mapPan: map_pan,
timerVisible: timer_visible,
})),
});
}
@@ -205,17 +207,17 @@ export async function handle(request: NextRequest, path: string[]) {
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
await limit("difficulty-presets:" + hash(address), 8, 3600);
const { mapZoom, ...fields } = body;
const { mapZoom, mapPan, timerVisible, ...fields } = body;
const { data, error } = await client
.from("difficulty_presets")
.insert({ ...fields, map_zoom: mapZoom })
.insert({ ...fields, map_zoom: mapZoom, map_pan: mapPan, timer_visible: timerVisible })
.select(
"id,name,seconds,zoom,pan,compass,coordinates,map_zoom,scoring",
"id,name,seconds,zoom,pan,compass,coordinates,map_zoom,map_pan,timer_visible,scoring",
)
.single();
checkDifficultyPresetError(error);
return NextResponse.json({
preset: data ? { ...data, mapZoom: data.map_zoom } : null,
preset: data ? { ...data, mapZoom: data.map_zoom, mapPan: data.map_pan, timerVisible: data.timer_visible } : null,
});
}
}
@@ -247,7 +249,7 @@ export async function handle(request: NextRequest, path: string[]) {
const body = z
.object({
datasetId: idSchema,
difficulty: z.union([z.enum(["easy", "normal", "hard"]), idSchema]),
difficulty: z.union([z.enum(["easy", "normal", "hard", "void"]), idSchema]),
})
.parse(await jsonBody(request));
const secret = cookieSecret(request) || randomBytes(32).toString("hex");

76
lib/changelog-feed.ts Normal file
View File

@@ -0,0 +1,76 @@
import {
parseReleasePost,
RELEASE_NEWS_CHANNEL,
RELEASE_WEBHOOK_ID,
type ReleasePost,
} from "../shared/changelog";
export async function readChangelogFeed({
token,
channel = RELEASE_NEWS_CHANNEL,
webhookId = RELEASE_WEBHOOK_ID,
before,
request = fetch,
}: {
token: string;
channel?: string;
webhookId?: string;
before?: string;
request?: typeof fetch;
}) {
if (!/^\d{17,20}$/.test(channel) || (before && !/^\d{17,20}$/.test(before)))
throw new Error("Invalid news channel or cursor");
const url = new URL(
`https://discord.com/api/v10/channels/${channel}/messages`,
);
url.searchParams.set("limit", "100");
if (before) url.searchParams.set("before", before);
// Reading history only. No Discord write endpoint is used by this feature.
const response = await request(url.toString(), {
method: "GET",
headers: { Authorization: `Bot ${token}` },
next: { revalidate: 60 },
signal: AbortSignal.timeout(10_000),
redirect: "error",
});
if (!response.ok)
throw new Error(`News feed unavailable (${response.status})`);
const posts: ReleasePost[] = await response.json();
if (
!Array.isArray(posts) ||
posts.some(
(post) =>
typeof post.content !== "string" ||
post.channel_id !== channel ||
!/^\d{17,20}$/.test(post.id),
)
)
throw new Error("Invalid news response");
if (
posts.some((post) => post.webhook_id === webhookId) &&
posts
.filter((post) => post.webhook_id === webhookId)
.every((post) => !post.content.trim())
)
throw new Error(
"Release text is unavailable; check Discord Message Content access",
);
const entries = posts
.flatMap((post) => {
const entry = parseReleasePost(post, webhookId);
return entry ? [entry] : [];
})
.sort(
(a, b) =>
b.publishedAt.localeCompare(a.publishedAt) ||
(BigInt(a.id) > BigInt(b.id) ? -1 : 1),
);
const oldest = posts.reduce<string | undefined>(
(id, post) => (!id || BigInt(post.id) < BigInt(id) ? post.id : id),
undefined,
);
return {
entries,
before: posts.length === 100 && oldest !== before ? oldest : undefined,
};
}

21
lib/changelog.ts Normal file
View File

@@ -0,0 +1,21 @@
import "server-only";
import { readChangelogFeed } from "./changelog-feed";
export async function getChangelog(before?: string) {
const token = process.env.DISCORD_BOT_TOKEN?.trim();
if (!token) return { entries: [], unavailable: true };
try {
return {
...(await readChangelogFeed({
token,
before,
channel: process.env.DISCORD_NEWS_CHANNEL_ID?.trim() || undefined,
webhookId: process.env.DISCORD_RELEASE_WEBHOOK_ID?.trim() || undefined,
})),
unavailable: false,
};
} catch {
// Do not expose Discord details or credentials to the public page.
return { entries: [], unavailable: true };
}
}

View File

@@ -14,7 +14,7 @@ export function checkDifficultyPresetError(
throw new ServiceError("A difficulty with that name already exists", 409);
if (error?.code === "23514")
throw new ServiceError(
"Use a name with 2–40 characters and a round time of 15–600 seconds in 15-second steps",
"Use a name with 2–40 characters and a round time of 5–600 seconds in 5-second steps",
400,
);
checkServiceError(error);

View File

@@ -59,7 +59,7 @@ try {
),
(error) => error.code === "23514",
);
for (const seconds of [0, 14, 16, 601])
for (const seconds of [0, 4, 14, 16, 601])
await assert.rejects(
db.query(
"insert into public.difficulty_presets(name,seconds,zoom) values('Invalid clock',$1,true)",
@@ -83,6 +83,7 @@ try {
["easy", 180],
["normal", 90],
["hard", 30],
["void", 10],
])
assert.equal(
(
@@ -111,6 +112,10 @@ try {
"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,'Difficulty host') on conflict(id) do update set display_name=excluded.display_name",
[owner],
);
const map = (
await db.query(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
@@ -137,6 +142,85 @@ try {
revision,
map,
]);
const voidRules = {
id: "void",
name: "Void",
seconds: 10,
zoom: false,
pan: false,
compass: false,
coordinates: false,
mapZoom: false,
mapPan: false,
timerVisible: true,
scoring: "strict",
};
const voidSolo = (
await db.query("select public.create_game($1,'void',$2,'void-solo') id", [
map,
owner,
])
).rows[0].id;
const voidRoom = (
await db.query(
"select public.create_map_room($1,'void','Void host',2,$2,$3) id",
[map, "d".repeat(64), owner],
)
).rows[0].id;
for (const [table, id] of [
["game_sessions", voidSolo],
["multiplayer_rooms", voidRoom],
])
assert.deepEqual(
(
await db.query(
`select difficulty_settings from public.${table} where id=$1`,
[id],
)
).rows[0].difficulty_settings,
voidRules,
);
await db.query("select public.activate_round($1,0,'void-solo')", [voidSolo]);
const voidClock = (
await db.query(
"select extract(epoch from (deadline-clock_timestamp()))::float8 seconds from public.game_rounds where session_id=$1 and round_number=0",
[voidSolo],
)
).rows[0].seconds;
assert.ok(voidClock > 9 && voidClock <= 10);
await db.query(
"select public.multiplayer_action($1,$2,'join','{\"name\":\"Void guest\"}')",
[voidRoom, "e".repeat(64)],
);
await db.query("select public.multiplayer_action($1,$2,'start')", [
voidRoom,
"d".repeat(64),
]);
for (const token of ["d".repeat(64), "e".repeat(64)])
await db.query(
"select public.multiplayer_action($1,$2,'ready','{\"round\":0}')",
[voidRoom, token],
);
const voidMultiClock = (
await db.query(
"select extract(epoch from (deadline-clock_timestamp()))::float8 seconds from public.multiplayer_rounds where room_id=$1 and round_number=0",
[voidRoom],
)
).rows[0].seconds;
assert.ok(voidMultiClock > 9 && voidMultiClock <= 10);
const fiveSeconds = (
await db.query(
"insert into public.difficulty_presets(name,seconds,zoom,map_pan,timer_visible) values('Five second blind clock',5,false,false,false) returning id",
)
).rows[0].id;
const extraRules = (
await db.query("select public.resolve_difficulty_preset($1) rules", [
fiveSeconds,
])
).rows[0].rules;
assert.equal(extraRules.seconds, 5);
assert.equal(extraRules.mapPan, false);
assert.equal(extraRules.timerVisible, false);
const solo = (
await db.query("select public.create_game($1,$2,$3,$4) id", [
map,
@@ -173,6 +257,8 @@ try {
compass: true,
coordinates: true,
mapZoom: true,
mapPan: true,
timerVisible: true,
scoring: "normal",
},
);
@@ -201,6 +287,8 @@ try {
compass: false,
coordinates: false,
mapZoom: false,
mapPan: true,
timerVisible: true,
scoring: "strict",
});
// Existing sessions retain the original curve even after the library changes.

View File

@@ -68,6 +68,31 @@ try {
JSON.stringify({ ...base, ...payload }),
])
)[0].id;
const voidEvent = await create({
difficulty: "void",
roundTimeSeconds: 10,
timerMode: "perMini",
});
const voidRules = (
await q(
"select difficulty_settings,round_time_seconds,timer_mode from public.tournaments where id=$1",
[voidEvent],
)
)[0];
assert.equal(voidRules.difficulty_settings.seconds, 10);
assert.equal(voidRules.difficulty_settings.mapPan, false);
assert.equal(voidRules.difficulty_settings.scoring, "strict");
assert.equal(voidRules.round_time_seconds, 10);
assert.equal(voidRules.timer_mode, "perMini");
for (const settings of [
{ roundTimeSeconds: 90, timerMode: "perMini" },
{ roundTimeSeconds: 10, timerMode: "shared" },
])
await assert.rejects(
create({ difficulty: "void", ...settings }),
/Void requires a separate 10-second timer/,
);
await q("delete from public.tournaments where id=$1", [voidEvent]);
const act = async (id, user, action = "view", payload = {}) =>
(
await q("select public.tournament_action($1,$2,$3,$4) value", [

51
shared/changelog.ts Normal file
View File

@@ -0,0 +1,51 @@
export const RELEASE_NEWS_CHANNEL = "1558048830770122772";
// A webhook ID is public; its secret URL/token must never be stored here.
export const RELEASE_WEBHOOK_ID = "1561021045211660401";
export type ReleasePost = {
id: string;
channel_id: string;
webhook_id?: string;
content: string;
timestamp: string;
};
export type ChangelogEntry = {
id: string;
title: string;
version: string;
kind: "Alpha" | "Beta" | "Production" | "Bug fix";
body: string;
publishedAt: string;
};
export function parseReleasePost(
post: ReleasePost,
webhookId = RELEASE_WEBHOOK_ID,
): ChangelogEntry | null {
if (post.webhook_id !== webhookId || !/^\d{17,20}$/.test(post.id))
return null;
const clean = post.content
.replace(/<@&\d+>/g, "")
.replace(/(?:^|\n)\s*PLAY THE MINEGEO AT\s*:[\s\S]*$/i, "")
.replace(/^[\s,.]+/, "")
.trim();
const header =
/^#{1,3}[ \t]+MineGeo[ \t]+(\d+\.\d+\.\d+(?:\.\d+)?)[ \t]+(Alpha|Beta|Production|Bug[ -]fix)([ \t]+[—–:-][^\r\n]*)?[ \t]*\r?\n/i.exec(
clean,
);
if (!header || !Number.isFinite(Date.parse(post.timestamp))) return null;
const body = clean.slice(header[0].length).trim();
if (!body) return null;
const kind = /^bug/i.test(header[2])
? "Bug fix"
: ((header[2][0].toUpperCase() +
header[2].slice(1).toLowerCase()) as ChangelogEntry["kind"]);
return {
id: post.id,
title: `MineGeo ${header[1]} ${kind}${header[3]?.trimEnd() ?? ""}`,
version: header[1],
kind,
body,
publishedAt: post.timestamp,
};
}

View File

@@ -238,6 +238,8 @@ export type Database = {
compass: boolean;
coordinates: boolean;
map_zoom: boolean;
map_pan: boolean;
timer_visible: boolean;
scoring: "normal" | "strict" | "gentle";
created_at: string;
id: string;
@@ -250,6 +252,8 @@ export type Database = {
compass?: boolean;
coordinates?: boolean;
map_zoom?: boolean;
map_pan?: boolean;
timer_visible?: boolean;
scoring?: "normal" | "strict" | "gentle";
created_at?: string;
id?: string;
@@ -262,6 +266,8 @@ export type Database = {
compass?: boolean;
coordinates?: boolean;
map_zoom?: boolean;
map_pan?: boolean;
timer_visible?: boolean;
scoring?: "normal" | "strict" | "gentle";
name?: string;
seconds?: number;

View File

@@ -190,7 +190,10 @@ export const developerOpenApi = {
type: "object",
properties: {
id,
difficulty: { type: "string", enum: ["easy", "normal", "hard"] },
difficulty: {
type: "string",
enum: ["easy", "normal", "hard", "void"],
},
createdAt: date,
currentRound: { type: "integer", minimum: 0, maximum: 5 },
complete: { type: "boolean" },

View File

@@ -3,6 +3,7 @@ export const leaderboardDifficulties = [
"easy",
"normal",
"hard",
"void",
] as const;
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
export type LeaderboardEntry =

View File

@@ -1046,5 +1046,24 @@
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.": "Карта Minecraft. Нажмите для метки или перемещайтесь стрелками и нажмите Enter для метки в центре.",
"Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.": "Строгий подсчёт сильнее штрафует за расстояние, мягкий даёт больше очков за близкие метки. В игре по-прежнему пять раундов.",
"Guess selected": "Метка выбрана",
"Map": "Карта"
"Map": "Карта",
"Void": "Пустота",
"Hardest · No compass · Fixed map": "Самая сложная · Без компаса · Фиксированная карта",
"Allow guess-map panning": "Разрешить перемещение карты",
"Show round countdown": "Показывать обратный отсчёт",
"Minecraft map. Click to place a guess, or press Enter to pin the center.": "Карта Minecraft. Нажмите для метки или нажмите Enter, чтобы поставить метку в центре.",
"Easy gives you 180 seconds, Normal 90, and Hard 30. Void is the hardest: just 10 seconds, a fixed panorama and map, no zoom, no compass or coordinates, and strict scoring. A timed-out round scores zero; you can still finish the expedition.": "Лёгкая сложность даёт 180 секунд, обычная — 90, высокая — 30. Пустота — самая сложная: 10 секунд, фиксированные панорама и карта, без масштабирования, компаса и координат, со строгим подсчётом очков. За истёкший раунд начисляется ноль очков; экспедицию можно продолжить.",
"Meet MineGeo’s top explorers. Compare the best five-round scores across Easy, Normal, Hard, and Void Minecraft expeditions.": "Лучшие исследователи MineGeo. Сравнивайте результаты пяти раундов на лёгкой, обычной, высокой сложности и в Пустоте.",
"Changelogs": "История обновлений",
"WHAT'S NEW": "ЧТО НОВОГО",
"New releases, improvements, and fixes from MineGeo.": "Новые версии, улучшения и исправления MineGeo.",
"Latest releases": "Последние обновления",
"Release notes are temporarily unavailable": "История обновлений временно недоступна",
"Please check back soon.": "Пожалуйста, загляните позже.",
"No releases on this page yet.": "На этой странице пока нет обновлений.",
"Older posts": "Более ранние записи",
"Alpha": "Альфа",
"Beta": "Бета",
"Production": "Релиз",
"Bug fix": "Исправления ошибок"
}

View File

@@ -6,7 +6,7 @@ export const createRoomSchema = z
.object({
datasetId: z.string().uuid(),
difficulty: z.union([
z.enum(["easy", "normal", "hard"]),
z.enum(["easy", "normal", "hard", "void"]),
z.string().uuid(),
]),
playerLimit: z.number().int().min(2).max(16),

View File

@@ -65,7 +65,7 @@ export const metricDefinitions = {
minegeo_games: {
help: "Retained solo games by difficulty and completion state.",
labels: {
difficulty: ["easy", "normal", "hard"],
difficulty: ["easy", "normal", "hard", "void"],
status: ["incomplete", "complete"],
},
},

View File

@@ -183,6 +183,18 @@ export const difficulties = {
easy: { seconds: 180, zoom: true, label: "Easy" },
normal: { seconds: 90, zoom: true, label: "Normal" },
hard: { seconds: 30, zoom: false, label: "Hard" },
void: {
seconds: 10,
zoom: false,
label: "Void",
pan: false,
compass: false,
coordinates: false,
mapZoom: false,
mapPan: false,
timerVisible: true,
scoring: "strict",
},
} as const;
export type Difficulty = keyof typeof difficulties;
export const difficultyRuleDefaults = {
@@ -190,6 +202,8 @@ export const difficultyRuleDefaults = {
compass: true,
coordinates: true,
mapZoom: true,
mapPan: true,
timerVisible: true,
scoring: "normal" as const,
};
export type ScoringMode = "normal" | "strict" | "gentle";
@@ -203,6 +217,8 @@ export type DifficultyPreset = {
compass?: boolean;
coordinates?: boolean;
mapZoom?: boolean;
mapPan?: boolean;
timerVisible?: boolean;
scoring?: ScoringMode;
};
export const customDifficultySchema = z
@@ -222,14 +238,16 @@ export const customDifficultySchema = z
seconds: z
.number()
.int()
.min(15)
.min(5)
.max(600)
.refine((seconds) => seconds % 15 === 0),
.refine((seconds) => seconds % 5 === 0),
zoom: z.boolean(),
pan: z.boolean().default(true),
compass: z.boolean().default(true),
coordinates: z.boolean().default(true),
mapZoom: z.boolean().default(true),
mapPan: z.boolean().default(true),
timerVisible: z.boolean().default(true),
scoring: z.enum(["normal", "strict", "gentle"]).default("normal"),
})
.strict();

View File

@@ -16,17 +16,24 @@ export const tournamentSettings = z
name: safeName,
datasetId: z.string().uuid(),
difficulty: z.union([
z.enum(["easy", "normal", "hard"]),
z.enum(["easy", "normal", "hard", "void"]),
z.string().uuid(),
]),
playerLimit: z.number().int().min(2).max(32),
roundCount: z.number().int().min(1).max(10),
roundTimeSeconds: z.number().int().min(15).max(3000).multipleOf(15),
roundTimeSeconds: z.number().int().min(10).max(3000),
timerMode: z.enum(["shared", "perMini"]).default("shared"),
startsAt: z.string().datetime({ offset: true }),
announceDiscord: z.boolean(),
})
.strict()
.refine(
(value) =>
value.difficulty === "void"
? value.timerMode === "perMini" && value.roundTimeSeconds === 10
: value.roundTimeSeconds >= 15 && value.roundTimeSeconds % 15 === 0,
"Void requires a separate 10-second timer; other tournament timers use 15-second steps",
)
.refine(
(value) => value.timerMode !== "perMini" || value.roundTimeSeconds <= 600,
"Mini-round timers allow at most 600 seconds",

View File

@@ -0,0 +1,135 @@
-- Keep old snapshots unchanged; missing new switches retain their client defaults.
alter table public.difficulty_presets
add column map_pan boolean not null default true,
add column timer_visible boolean not null default true;
alter table public.difficulty_presets drop constraint difficulty_presets_seconds_check;
alter table public.difficulty_presets add constraint difficulty_presets_seconds_check
check (seconds between 5 and 600 and seconds % 5 = 0);
alter table public.game_sessions drop constraint game_sessions_difficulty_check;
alter table public.game_sessions add constraint game_sessions_difficulty_check check (
difficulty in ('easy','normal','hard','void')
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
);
alter table public.multiplayer_rooms drop constraint multiplayer_rooms_difficulty_check;
alter table public.multiplayer_rooms add constraint multiplayer_rooms_difficulty_check check (
difficulty in ('easy','normal','hard','void')
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
);
create or replace function public.resolve_difficulty_preset(p_id text)
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
declare preset jsonb;
begin
if p_id = 'void' then
return '{"id":"void","name":"Void","seconds":10,"zoom":false,"pan":false,"compass":false,"coordinates":false,"mapZoom":false,"mapPan":false,"timerVisible":true,"scoring":"strict"}'::jsonb;
end if;
if p_id in ('easy','normal','hard') then
return jsonb_build_object('id',p_id,'name',initcap(p_id),
'seconds',case p_id when 'easy' then 180 when 'hard' then 30 else 90 end,
'zoom',p_id <> 'hard','pan',true,'compass',true,'coordinates',true,
'mapZoom',true,'mapPan',true,'timerVisible',true,'scoring','normal');
end if;
select jsonb_build_object('id',p.id::text,'name',p.name,'seconds',p.seconds,'zoom',p.zoom,
'pan',p.pan,'compass',p.compass,'coordinates',p.coordinates,'mapZoom',p.map_zoom,
'mapPan',p.map_pan,'timerVisible',p.timer_visible,'scoring',p.scoring)
into preset from public.difficulty_presets p where p.id::text=lower(p_id);
if not found then raise exception 'Choose a saved difficulty'; end if;
return preset;
end; $$;
-- A Void tournament must keep the same ten-second limit for every mini-round.
alter table public.tournaments drop constraint tournaments_round_time_seconds_check;
alter table public.tournaments add constraint tournaments_round_time_seconds_check check (
(difficulty='void' and timer_mode='perMini' and round_time_seconds=10)
or (difficulty<>'void' and round_time_seconds between 15 and 3000 and round_time_seconds%15=0)
);
create or replace function public.create_tournament(p_owner uuid,p_payload jsonb) returns uuid
language plpgsql security invoker set search_path='' as $$
declare tid uuid; rev public.revisions; settings jsonb; start_time timestamptz; zone text;
seconds integer; cap integer; rounds integer; title text; mode text;
begin
if not exists(select 1 from public.profiles where id=p_owner and account_status='active') then raise exception 'Active account required'; end if;
select r.* into rev from public.datasets d join public.revisions r on r.id=d.revision_id
where d.id=(p_payload->>'datasetId')::uuid and d.state='published';
if not found then raise exception 'Choose a published map'; end if;
if jsonb_array_length(public.game_viewpoint_pool(rev.manifest,rev.asset_root))<5 then raise exception 'Map needs at least five distinct panorama locations'; end if;
start_time:=(p_payload->>'startsAt')::timestamptz;
if start_time is null or start_time<=clock_timestamp() or start_time>clock_timestamp()+interval '90 days' then raise exception 'Choose a future start within 90 days'; end if;
zone:=p_payload->>'serverTimezone';
if zone is null or not exists(select 1 from pg_catalog.pg_timezone_names where name=zone) then raise exception 'Invalid server timezone'; end if;
title:=btrim(p_payload->>'name'); cap:=(p_payload->>'playerLimit')::integer;
rounds:=(p_payload->>'roundCount')::integer; seconds:=(p_payload->>'roundTimeSeconds')::integer;
if cap is null or cap not between 2 and 32 then raise exception 'Choose between 2 and 32 players'; end if;
if rounds is null or rounds not between 1 and 10 then raise exception 'Choose between 1 and 10 tournament rounds'; end if;
mode:=coalesce(p_payload->>'timerMode','shared');
if mode not in('shared','perMini') then raise exception 'Invalid tournament timer mode'; end if;
if p_payload->>'difficulty'='void' then
if mode<>'perMini' or seconds is distinct from 10 then raise exception 'Void requires a separate 10-second timer'; end if;
elsif seconds is null or seconds not between 15 and (case when mode='shared' then 3000 else 600 end) or seconds%15<>0 then raise exception 'Choose a valid tournament time in steps of 15 seconds'; end if;
settings:=public.resolve_difficulty_preset(p_payload->>'difficulty')||jsonb_build_object('seconds',least(seconds,600),'roundCount',5);
insert into public.tournaments(name,owner_id,revision_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,difficulty,difficulty_settings,announce_discord)
values(title,p_owner,rev.id,start_time,zone,cap,rounds,seconds,mode,p_payload->>'difficulty',settings,coalesce((p_payload->>'announceDiscord')::boolean,false)) returning id into tid;
insert into public.tournament_members(tournament_id,account_id) values(tid,p_owner);
return tid;
end; $$;
create or replace function public.read_prometheus_metrics(p_token_hash text) returns jsonb
language plpgsql security invoker set search_path = '' set statement_timeout = '15s' as $$
declare settings public.prometheus_settings; collected_at timestamptz := clock_timestamp(); result jsonb;
begin
-- The token grants only access to aggregate metrics. Demoting/deleting its
-- issuing Admin also invalidates it. Lock prevents concurrent scrape stampedes.
select s.* into settings from public.prometheus_settings s
where s.id and s.token_hash=p_token_hash
and exists(select 1 from public.profiles p where p.id=s.updated_by and p.role='admin')
for update;
if not found then return null; end if;
if settings.snapshot_at > collected_at - interval '30 seconds' then
update public.prometheus_settings set last_scraped_at=collected_at where id;
return settings.snapshot;
end if;
with samples(metric, labels, value) as (
select 'minegeo_accounts', '{}'::jsonb, count(*)::double precision from public.profiles
union all select 'minegeo_published_panoramas', '{}'::jsonb, coalesce(sum(panorama_count),0)::double precision from public.datasets where state='published'
union all select 'minegeo_games_started_24h', '{}'::jsonb, count(*)::double precision from public.game_sessions where created_at >= collected_at - interval '24 hours'
union all select 'minegeo_rooms_created_24h', '{}'::jsonb, count(*)::double precision from public.multiplayer_rooms where created_at >= collected_at - interval '24 hours'
union all select 'minegeo_snapshot_timestamp_seconds', '{}'::jsonb, extract(epoch from collected_at)::double precision
union all
select 'minegeo_maps', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
from (values('uploading'),('processing'),('draft'),('review'),('published'),('rejected'),('unpublished'),('failed')) v(state)
left join (select state,count(*) n from public.datasets group by state) c using(state)
union all
select 'minegeo_games', jsonb_build_object('difficulty',d.difficulty,'status',v.status), coalesce(c.n,0)::double precision
from (values('easy'),('normal'),('hard'),('void')) d(difficulty)
cross join (values('incomplete'),('complete')) v(status)
left join (select difficulty,case when current_round=5 then 'complete' else 'incomplete' end status,count(*) n
from public.game_sessions group by 1,2) c using(difficulty,status)
union all
select 'minegeo_rooms', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
from (values('lobby'),('loading'),('playing'),('results'),('finished'),('closed')) v(state)
left join (select status state,count(*) n from public.multiplayer_rooms group by status) c using(state)
union all
select 'minegeo_upload_jobs', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
from (values('queued'),('running'),('complete'),('failed'),('cancelled')) v(state)
left join (select state,count(*) n from public.upload_jobs group by state) c using(state)
union all
select 'minegeo_bug_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
from (values('draft'),('pending'),('open'),('done')) v(state)
left join (select state,count(*) n from public.bug_reports group by state) c using(state)
union all
select 'minegeo_content_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
from (values('open'),('resolved'),('confirmed'),('dismissed')) v(state)
left join (select state,count(*) n from public.reports group by state) c using(state)
union all
select 'minegeo_discord_announcements', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
from (values('pending'),('sending'),('sent'),('skipped'),('failed')) v(state)
left join (select state,count(*) n from public.discord_announcements group by state) c using(state)
) select jsonb_agg(jsonb_build_object('metric',metric,'labels',labels,'value',value) order by metric,labels::text) into result from samples;
update public.prometheus_settings set last_scraped_at=collected_at, snapshot_at=collected_at, snapshot=result where id;
return result;
end; $$;
-- Discard the cached metrics shape when adding the Void dimension.
update public.prometheus_settings set snapshot=null,snapshot_at=null;

136
tests/changelog.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
import {
parseReleasePost,
RELEASE_NEWS_CHANNEL,
RELEASE_WEBHOOK_ID,
} from "../shared/changelog";
import { readChangelogFeed } from "../lib/changelog-feed";
import { ReleaseNotes } from "../components/ReleaseNotes";
const post = (content: string) => ({
id: "1561021045211660402",
channel_id: RELEASE_NEWS_CHANNEL,
webhook_id: RELEASE_WEBHOOK_ID,
timestamp: "2026-10-20T08:00:00Z",
content,
});
test("release posts remove roles, punctuation and each published footer format", () => {
for (const kind of ["Alpha", "Beta", "Production", "Bug fix"]) {
for (const footer of [
"<https://minegeo.site>",
"<https://minegeo.site/>",
'[https://minegeo.site/](https://minegeo.site/ "https://minegeo.site/")',
]) {
const entry = parseReleasePost(
post(
`<@&1558052962817933322>, <@&1558053208008433705>.\n## MineGeo 1.0.4.1 ${kind}\n\nA new release. 😄\n\n- **Fixed:** Shared timers.\n\nPLAY THE MINEGEO AT: ${footer}`,
),
);
assert.equal(entry?.kind, kind);
assert.equal(
entry?.body,
"A new release. 😄\n\n- **Fixed:** Shared timers.",
);
assert.equal(entry?.version, "1.0.4.1");
}
}
});
test("only the release webhook's complete release posts appear", () => {
const valid = post("## MineGeo 1.0.4 Beta\nHello");
assert.equal(
parseReleasePost({ ...valid, webhook_id: "111111111111111111" }),
null,
);
assert.equal(parseReleasePost({ ...valid, webhook_id: undefined }), null);
assert.equal(parseReleasePost({ ...valid, timestamp: "invalid" }), null);
assert.equal(
parseReleasePost(post("## Community tournament\nStarts soon")),
null,
);
assert.equal(parseReleasePost(post("## MineGeo 1.0.4 Beta\n")), null);
});
test("release headings can include a descriptive subtitle", () => {
const entry = parseReleasePost(
post(
"## MineGeo 1.0.5 Beta — Changelog Update\n\nA new release.\n\nPLAY THE MINEGEO AT: <https://minegeo.site/>",
),
);
assert.equal(entry?.title, "MineGeo 1.0.5 Beta — Changelog Update");
assert.equal(entry?.body, "A new release.");
});
test("feed uses only GET, preserves older pagination through non-release posts", async () => {
let calls = 0;
const request: typeof fetch = async (url, options) => {
calls++;
assert.equal(options?.method, "GET");
assert.equal(
new URL(String(url)).searchParams.get("before"),
"1661021045211660402",
);
assert.equal(
new Headers(options?.headers).get("Authorization"),
"Bot test-token",
);
const posts = Array.from({ length: 100 }, (_, i) => ({
...post("Tournament news"),
id: String(1561021045211660500n - BigInt(i)),
}));
posts[0].content = "## MineGeo 1.0.4 Beta\n- **New:** Void";
return Response.json(posts);
};
const feed = await readChangelogFeed({
token: "test-token",
request,
before: "1661021045211660402",
});
assert.equal(calls, 1);
assert.equal(feed.entries.length, 1);
assert.equal(feed.before, "1561021045211660401");
});
test("feed fails cleanly on denied access, rate limits and wrong channel data", async () => {
for (const status of [401, 403, 429, 500]) {
await assert.rejects(
readChangelogFeed({
token: "test",
request: async () => new Response("", { status }),
}),
);
}
await assert.rejects(
readChangelogFeed({
token: "test",
request: async () =>
Response.json([{ ...post("test"), channel_id: "other" }]),
}),
);
});
test("redacted webhook messages are unavailable rather than an empty release history", async () => {
await assert.rejects(
readChangelogFeed({
token: "test",
request: async () => Response.json([post("")]),
}),
/Message Content/,
);
});
test("release Markdown renders emphasis and safe links, escaping executable HTML", () => {
const html = renderToStaticMarkup(
createElement(ReleaseNotes, {
body: "- **New:** Void\n- <script>alert(1)</script>\n\n[Guide](https://minegeo.site/docs) [bad](javascript:alert)",
}),
);
assert.match(html, /<strong>New:<\/strong>/);
assert.match(html, /&lt;script&gt;/);
assert.match(html, /href="https:\/\/minegeo.site\/docs"/);
assert.doesNotMatch(html, /href="javascript:/);
});

View File

@@ -1,6 +1,12 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { customDifficultySchema, scoreGuess } from "../shared/protocol";
import {
customDifficultySchema,
difficulties,
scoreGuess,
} from "../shared/protocol";
import { createRoomSchema } from "../shared/multiplayer";
import { tournamentSettings } from "../shared/tournaments";
import { mergeDifficultyPresets } from "../shared/difficulty-presets";
const rules = { seconds: 90, zoom: true };
@@ -12,6 +18,8 @@ test("expanded difficulty rules preserve defaults and validate controls", () =>
compass: preset.compass,
coordinates: preset.coordinates,
mapZoom: preset.mapZoom,
mapPan: preset.mapPan,
timerVisible: preset.timerVisible,
scoring: preset.scoring,
},
{
@@ -19,10 +27,19 @@ test("expanded difficulty rules preserve defaults and validate controls", () =>
compass: true,
coordinates: true,
mapZoom: true,
mapPan: true,
timerVisible: true,
scoring: "normal",
},
);
for (const key of ["pan", "compass", "coordinates", "mapZoom"])
for (const key of [
"pan",
"compass",
"coordinates",
"mapZoom",
"mapPan",
"timerVisible",
])
assert.equal(
customDifficultySchema.safeParse({
name: "Fixture",
@@ -96,13 +113,13 @@ test("difficulty names accept normal text and count Unicode characters consisten
});
test("difficulty rules enforce their supported range and strict request shape", () => {
for (const seconds of [15, 90, 600])
for (const seconds of [5, 10, 15, 20, 90, 600])
assert.equal(
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
.success,
true,
);
for (const seconds of [0, 14, 16, 601, 15.5, NaN, Infinity, "90"])
for (const seconds of [0, 4, 14, 16, 601, 15.5, NaN, Infinity, "90"])
assert.equal(
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
.success,
@@ -118,6 +135,59 @@ test("difficulty rules enforce their supported range and strict request shape",
);
});
test("Void is the shortest built-in challenge and disables navigation aids", () => {
assert.ok(
Object.entries(difficulties).every(
([id, rule]) => id === "void" || difficulties.void.seconds < rule.seconds,
),
);
for (const rule of [
"zoom",
"pan",
"compass",
"coordinates",
"mapZoom",
"mapPan",
] as const)
assert.equal(difficulties.void[rule], false);
assert.equal(difficulties.void.scoring, "strict");
assert.equal(
createRoomSchema.safeParse({
datasetId: "11111111-1111-4111-8111-111111111111",
difficulty: "void",
playerLimit: 4,
}).success,
true,
);
});
test("Void tournaments preserve ten seconds per mini-round", () => {
const settings = {
name: "Void event",
datasetId: "11111111-1111-4111-8111-111111111111",
difficulty: "void",
playerLimit: 4,
roundCount: 3,
roundTimeSeconds: 10,
timerMode: "perMini",
startsAt: "2026-10-25T12:00:00Z",
announceDiscord: false,
};
assert.equal(tournamentSettings.safeParse(settings).success, true);
assert.equal(
tournamentSettings.safeParse({ ...settings, roundTimeSeconds: 90 }).success,
false,
);
assert.equal(
tournamentSettings.safeParse({ ...settings, timerMode: "shared" }).success,
false,
);
assert.equal(
tournamentSettings.safeParse({ ...settings, difficulty: "normal" }).success,
false,
);
});
test("a delayed preset listing cannot erase a newly saved preset or duplicate it", async () => {
const old = { id: "old", name: "Old", ...rules };
const saved = { id: "saved", name: "New", ...rules };

View File

@@ -27,10 +27,10 @@ const samples = Object.entries(metricDefinitions).flatMap(
test("metrics exposition is bounded, complete and contains only fixed aggregate dimensions", () => {
const text = formatMetrics(samples);
assert.equal(samples.length, 43);
assert.equal(samples.length, 45);
assert.equal(
text.split("\n").filter((line) => line && !line.startsWith("#")).length,
43,
45,
);
assert.ok(text.endsWith("\n"));
assert.match(text, /# TYPE minegeo_games gauge/);