Add Void difficulty and publish changelogs
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m40s
This commit is contained in:
@@ -17,6 +17,10 @@ MINEGEO_FORUM_URL=
|
|||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
DISCORD_MAPS_CHANNEL_ID=
|
DISCORD_MAPS_CHANNEL_ID=
|
||||||
DISCORD_PARTIES_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.
|
# Optional bearer secret for scheduled GET /api/integrations/discord/dispatch retries.
|
||||||
CRON_SECRET=
|
CRON_SECRET=
|
||||||
# Development testing only; production rejects this bypass of CSRF protection.
|
# Development testing only; production rejects this bypass of CSRF protection.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default async function About() {
|
|||||||
<h2>{t("Five chances to find your way")}</h2>
|
<h2>{t("Five chances to find your way")}</h2>
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{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>
|
</p>
|
||||||
<h2>{t("Closer means better")}</h2>
|
<h2>{t("Closer means better")}</h2>
|
||||||
|
|||||||
@@ -384,7 +384,10 @@ html[data-theme="light"] .site-cursor-dot span {
|
|||||||
background: #273b21;
|
background: #273b21;
|
||||||
box-shadow: 0 0 1px #fff;
|
box-shadow: 0 0 1px #fff;
|
||||||
}
|
}
|
||||||
@media (max-width: 1200px) {
|
.header-inner {
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1500px) {
|
||||||
.site-header {
|
.site-header {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -458,3 +461,31 @@ html[data-theme="light"] .site-cursor-dot span {
|
|||||||
grid-row: 3;
|
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
77
app/changelogs/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ export default async function DashboardPage() {
|
|||||||
typeof s.difficulty_settings === "object" &&
|
typeof s.difficulty_settings === "object" &&
|
||||||
!Array.isArray(s.difficulty_settings) &&
|
!Array.isArray(s.difficulty_settings) &&
|
||||||
typeof s.difficulty_settings.name === "string"
|
typeof s.difficulty_settings.name === "string"
|
||||||
? ["easy", "normal", "hard"].includes(s.difficulty)
|
? ["easy", "normal", "hard", "void"].includes(s.difficulty)
|
||||||
? t(s.difficulty_settings.name)
|
? t(s.difficulty_settings.name)
|
||||||
: s.difficulty_settings.name
|
: s.difficulty_settings.name
|
||||||
: t(
|
: t(
|
||||||
|
|||||||
@@ -1745,3 +1745,13 @@ pre {
|
|||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
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; }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
return {
|
return {
|
||||||
title: t("Leaderboard"),
|
title: t("Leaderboard"),
|
||||||
description: t(
|
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.",
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
19
components/ChangelogRefresh.tsx
Normal file
19
components/ChangelogRefresh.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -217,13 +217,15 @@ export function Game({ id }: { id: string }) {
|
|||||||
<small>{t("ROUND")}</small>
|
<small>{t("ROUND")}</small>
|
||||||
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{session?.difficultySettings.timerVisible !== false && (
|
||||||
<small>{t("TIME LEFT")}</small>
|
<div>
|
||||||
<strong className={remaining <= 10 ? "urgent" : ""}>
|
<small>{t("TIME LEFT")}</small>
|
||||||
{Math.floor(remaining / 60)}:
|
<strong className={remaining <= 10 ? "urgent" : ""}>
|
||||||
{String(remaining % 60).padStart(2, "0")}
|
{Math.floor(remaining / 60)}:
|
||||||
</strong>
|
{String(remaining % 60).padStart(2, "0")}
|
||||||
</div>
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<small>{t("POINTS")}</small>
|
<small>{t("POINTS")}</small>
|
||||||
<strong>{formatNumber(total)}</strong>
|
<strong>{formatNumber(total)}</strong>
|
||||||
@@ -273,6 +275,7 @@ export function Game({ id }: { id: string }) {
|
|||||||
onError={onError}
|
onError={onError}
|
||||||
disabled={!!result || busy}
|
disabled={!!result || busy}
|
||||||
zoom={session.difficultySettings.mapZoom}
|
zoom={session.difficultySettings.mapZoom}
|
||||||
|
pan={session.difficultySettings.mapPan}
|
||||||
compass={session.difficultySettings.compass}
|
compass={session.difficultySettings.compass}
|
||||||
/>
|
/>
|
||||||
<div className="map-actions">
|
<div className="map-actions">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function GuessMap({
|
|||||||
onError,
|
onError,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
zoom = true,
|
zoom = true,
|
||||||
|
pan = true,
|
||||||
compass = true,
|
compass = true,
|
||||||
}: {
|
}: {
|
||||||
bounds: Bounds;
|
bounds: Bounds;
|
||||||
@@ -27,6 +28,7 @@ export function GuessMap({
|
|||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
zoom?: boolean;
|
zoom?: boolean;
|
||||||
|
pan?: boolean;
|
||||||
compass?: boolean;
|
compass?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -68,11 +70,16 @@ export function GuessMap({
|
|||||||
touchZoom: zoom,
|
touchZoom: zoom,
|
||||||
doubleClickZoom: zoom,
|
doubleClickZoom: zoom,
|
||||||
boxZoom: zoom,
|
boxZoom: zoom,
|
||||||
|
dragging: pan,
|
||||||
|
keyboard: false,
|
||||||
});
|
});
|
||||||
if (zoom)
|
if (zoom)
|
||||||
L.control
|
L.control
|
||||||
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
|
.zoom({ zoomInTitle: t("Zoom in"), zoomOutTitle: t("Zoom out") })
|
||||||
.addTo(m);
|
.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.
|
// Install cleanup immediately, including when later initialization fails.
|
||||||
let observer: ResizeObserver | undefined;
|
let observer: ResizeObserver | undefined;
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
@@ -119,6 +126,29 @@ export function GuessMap({
|
|||||||
callbacks.current.onPin?.({ x, z });
|
callbacks.current.onPin?.({ x, z });
|
||||||
});
|
});
|
||||||
const keydown = (event: KeyboardEvent) => {
|
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 (
|
if (
|
||||||
event.key !== "Enter" ||
|
event.key !== "Enter" ||
|
||||||
event.target !== m.getContainer() ||
|
event.target !== m.getContainer() ||
|
||||||
@@ -171,6 +201,7 @@ export function GuessMap({
|
|||||||
image,
|
image,
|
||||||
attempt,
|
attempt,
|
||||||
zoom,
|
zoom,
|
||||||
|
pan,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const L = lib.current,
|
const L = lib.current,
|
||||||
@@ -204,15 +235,19 @@ export function GuessMap({
|
|||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
role="application"
|
role="application"
|
||||||
aria-label={
|
aria-label={
|
||||||
!compass
|
!pan
|
||||||
? t(
|
? 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
|
: !compass
|
||||||
? t("Minecraft map. North is up.")
|
? t(
|
||||||
: t(
|
"Minecraft map. Click to place a guess, or use arrow keys to pan and Enter to pin the center.",
|
||||||
"Minecraft map. North is up. 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 && (
|
{error && (
|
||||||
|
|||||||
@@ -125,18 +125,20 @@ export function MultiplayerGame({
|
|||||||
<small>{t("ROUND")}</small>
|
<small>{t("ROUND")}</small>
|
||||||
<strong>{room.round + 1} / 5</strong>
|
<strong>{room.round + 1} / 5</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{room.difficultySettings.timerVisible !== false && (
|
||||||
<small>{t("TIME LEFT")}</small>
|
<div>
|
||||||
<strong
|
<small>{t("TIME LEFT")}</small>
|
||||||
className={
|
<strong
|
||||||
room.status === "playing" && seconds <= 10 ? "urgent" : ""
|
className={
|
||||||
}
|
room.status === "playing" && seconds <= 10 ? "urgent" : ""
|
||||||
>
|
}
|
||||||
{room.status === "loading"
|
>
|
||||||
? t("Loading…")
|
{room.status === "loading"
|
||||||
: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`}
|
? t("Loading…")
|
||||||
</strong>
|
: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`}
|
||||||
</div>
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<small>{t("POINTS")}</small>
|
<small>{t("POINTS")}</small>
|
||||||
<strong>{formatNumber(me?.total ?? 0)}</strong>
|
<strong>{formatNumber(me?.total ?? 0)}</strong>
|
||||||
@@ -214,6 +216,7 @@ export function MultiplayerGame({
|
|||||||
onReady={mapReady}
|
onReady={mapReady}
|
||||||
onError={mediaError}
|
onError={mediaError}
|
||||||
zoom={room.difficultySettings.mapZoom}
|
zoom={room.difficultySettings.mapZoom}
|
||||||
|
pan={room.difficultySettings.mapPan}
|
||||||
compass={room.difficultySettings.compass}
|
compass={room.difficultySettings.compass}
|
||||||
disabled={
|
disabled={
|
||||||
room.status !== "playing" || submitted || busy || seconds === 0
|
room.status !== "playing" || submitted || busy || seconds === 0
|
||||||
|
|||||||
45
components/ReleaseNotes.tsx
Normal file
45
components/ReleaseNotes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export async function Footer() {
|
|||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<Link href="/downloads">{t("Tools & downloads")}</Link>
|
<Link href="/downloads">{t("Tools & downloads")}</Link>
|
||||||
<Link href="/docs">{t("Docs")}</Link>
|
<Link href="/docs">{t("Docs")}</Link>
|
||||||
|
<Link href="/changelogs">{t("Changelogs")}</Link>
|
||||||
{forum && (
|
{forum && (
|
||||||
<a href={forum} target="_blank" rel="noopener noreferrer">
|
<a href={forum} target="_blank" rel="noopener noreferrer">
|
||||||
{t("Forum")}
|
{t("Forum")}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const links = [
|
|||||||
["/servers", "Play & maps"],
|
["/servers", "Play & maps"],
|
||||||
["/leaderboard", "Leaderboard"],
|
["/leaderboard", "Leaderboard"],
|
||||||
["/tournaments", "Tournaments"],
|
["/tournaments", "Tournaments"],
|
||||||
|
["/changelogs", "Changelogs"],
|
||||||
["/downloads", "Downloads"],
|
["/downloads", "Downloads"],
|
||||||
["/about", "Guide"],
|
["/about", "Guide"],
|
||||||
["/docs", "Docs"],
|
["/docs", "Docs"],
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function StartGame({
|
|||||||
compass: boolean;
|
compass: boolean;
|
||||||
coordinates: boolean;
|
coordinates: boolean;
|
||||||
mapZoom: boolean;
|
mapZoom: boolean;
|
||||||
|
mapPan: boolean;
|
||||||
|
timerVisible: boolean;
|
||||||
scoring: ScoringMode;
|
scoring: ScoringMode;
|
||||||
}>(difficultyRuleDefaults),
|
}>(difficultyRuleDefaults),
|
||||||
[savingDifficulty, setSavingDifficulty] = useState(false),
|
[savingDifficulty, setSavingDifficulty] = useState(false),
|
||||||
@@ -63,6 +65,8 @@ export function StartGame({
|
|||||||
}, []);
|
}, []);
|
||||||
const options: DifficultyPreset[] = [
|
const options: DifficultyPreset[] = [
|
||||||
...Object.entries(difficulties).map(([id, preset]) => ({
|
...Object.entries(difficulties).map(([id, preset]) => ({
|
||||||
|
...difficultyRuleDefaults,
|
||||||
|
...preset,
|
||||||
id,
|
id,
|
||||||
name: preset.label,
|
name: preset.label,
|
||||||
seconds: preset.seconds,
|
seconds: preset.seconds,
|
||||||
@@ -169,7 +173,10 @@ export function StartGame({
|
|||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
aria-pressed={difficulty === 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}
|
disabled={loading}
|
||||||
onClick={() => setDifficulty(option.id)}
|
onClick={() => setDifficulty(option.id)}
|
||||||
>
|
>
|
||||||
@@ -185,7 +192,7 @@ export function StartGame({
|
|||||||
<ZoomIn size={13} />
|
<ZoomIn size={13} />
|
||||||
{option.zoom ? t("Zoom allowed") : t("No zoom")}
|
{option.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||||
</span>
|
</span>
|
||||||
{!(option.id in difficulties) && (
|
{(!(option.id in difficulties) || option.id === "void") && (
|
||||||
<span>
|
<span>
|
||||||
{t(
|
{t(
|
||||||
option.scoring === "strict"
|
option.scoring === "strict"
|
||||||
@@ -197,6 +204,9 @@ export function StartGame({
|
|||||||
· {t(option.pan === false ? "Fixed view" : "Panning allowed")}
|
· {t(option.pan === false ? "Fixed view" : "Panning allowed")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{option.id === "void" && (
|
||||||
|
<span>{t("Hardest · No compass · Fixed map")}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -238,9 +248,9 @@ export function StartGame({
|
|||||||
<input
|
<input
|
||||||
id="custom-difficulty-seconds"
|
id="custom-difficulty-seconds"
|
||||||
type="number"
|
type="number"
|
||||||
min={15}
|
min={5}
|
||||||
max={600}
|
max={600}
|
||||||
step={15}
|
step={5}
|
||||||
value={difficultySeconds}
|
value={difficultySeconds}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDifficultySeconds(Number(event.target.value))
|
setDifficultySeconds(Number(event.target.value))
|
||||||
@@ -265,6 +275,8 @@ export function StartGame({
|
|||||||
["compass", "Show north indicator"],
|
["compass", "Show north indicator"],
|
||||||
["coordinates", "Show guess coordinates"],
|
["coordinates", "Show guess coordinates"],
|
||||||
["mapZoom", "Allow guess-map zoom"],
|
["mapZoom", "Allow guess-map zoom"],
|
||||||
|
["mapPan", "Allow guess-map panning"],
|
||||||
|
["timerVisible", "Show round countdown"],
|
||||||
] as const
|
] as const
|
||||||
).map(([key, label]) => (
|
).map(([key, label]) => (
|
||||||
<label className="difficulty-builder-zoom" key={key}>
|
<label className="difficulty-builder-zoom" key={key}>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export function TournamentIndex({
|
|||||||
const [clock, setClock] = useState(0);
|
const [clock, setClock] = useState(0);
|
||||||
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
|
const [timerMode, setTimerMode] = useState<"shared" | "perMini">("shared");
|
||||||
const [roundSeconds, setRoundSeconds] = useState(450);
|
const [roundSeconds, setRoundSeconds] = useState(450);
|
||||||
|
const [difficulty, setDifficulty] = useState("normal");
|
||||||
const offset = useRef(0);
|
const offset = useRef(0);
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -115,8 +116,8 @@ export function TournamentIndex({
|
|||||||
difficulty: form.get("difficulty"),
|
difficulty: form.get("difficulty"),
|
||||||
playerLimit: Number(form.get("limit")),
|
playerLimit: Number(form.get("limit")),
|
||||||
roundCount: Number(form.get("rounds")),
|
roundCount: Number(form.get("rounds")),
|
||||||
roundTimeSeconds: Number(form.get("seconds")),
|
roundTimeSeconds: difficulty === "void" ? 10 : roundSeconds,
|
||||||
timerMode: form.get("timerMode"),
|
timerMode: difficulty === "void" ? "perMini" : timerMode,
|
||||||
startsAt: serverLocalToIso(
|
startsAt: serverLocalToIso(
|
||||||
String(form.get("start")),
|
String(form.get("start")),
|
||||||
events.serverTimezone,
|
events.serverTimezone,
|
||||||
@@ -211,10 +212,15 @@ export function TournamentIndex({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("Difficulty")}
|
{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="easy">{t("Easy")}</option>
|
||||||
<option value="normal">{t("Normal")}</option>
|
<option value="normal">{t("Normal")}</option>
|
||||||
<option value="hard">{t("Hard")}</option>
|
<option value="hard">{t("Hard")}</option>
|
||||||
|
<option value="void">{t("Void")}</option>
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
<option key={preset.id} value={preset.id}>
|
<option key={preset.id} value={preset.id}>
|
||||||
{preset.name}
|
{preset.name}
|
||||||
@@ -248,7 +254,8 @@ export function TournamentIndex({
|
|||||||
{t("Timer mode")}
|
{t("Timer mode")}
|
||||||
<select
|
<select
|
||||||
name="timerMode"
|
name="timerMode"
|
||||||
value={timerMode}
|
value={difficulty === "void" ? "perMini" : timerMode}
|
||||||
|
disabled={difficulty === "void"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const mode = e.target.value as "shared" | "perMini";
|
const mode = e.target.value as "shared" | "perMini";
|
||||||
setTimerMode(mode);
|
setTimerMode(mode);
|
||||||
@@ -265,17 +272,18 @@ export function TournamentIndex({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t(
|
{t(
|
||||||
timerMode === "shared"
|
difficulty !== "void" && timerMode === "shared"
|
||||||
? "Seconds per tournament round"
|
? "Seconds per tournament round"
|
||||||
: "Seconds per mini-round",
|
: "Seconds per mini-round",
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
name="seconds"
|
name="seconds"
|
||||||
type="number"
|
type="number"
|
||||||
min={15}
|
min={difficulty === "void" ? 10 : 15}
|
||||||
max={timerMode === "shared" ? 3000 : 600}
|
max={timerMode === "shared" ? 3000 : 600}
|
||||||
step={15}
|
step={difficulty === "void" ? 5 : 15}
|
||||||
value={roundSeconds}
|
value={difficulty === "void" ? 10 : roundSeconds}
|
||||||
|
disabled={difficulty === "void"}
|
||||||
onChange={(e) => setRoundSeconds(Number(e.target.value))}
|
onChange={(e) => setRoundSeconds(Number(e.target.value))}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
23
docs/changelogs.md
Normal file
23
docs/changelogs.md
Normal 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.
|
||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
16
lib/api.ts
16
lib/api.ts
@@ -188,14 +188,16 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
await client
|
await client
|
||||||
.from("difficulty_presets")
|
.from("difficulty_presets")
|
||||||
.select(
|
.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 }),
|
.order("name", { ascending: true }),
|
||||||
);
|
);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
presets: presets.map(({ map_zoom, ...preset }) => ({
|
presets: presets.map(({ map_zoom, map_pan, timer_visible, ...preset }) => ({
|
||||||
...preset,
|
...preset,
|
||||||
mapZoom: map_zoom,
|
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() ||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
"unknown";
|
"unknown";
|
||||||
await limit("difficulty-presets:" + hash(address), 8, 3600);
|
await limit("difficulty-presets:" + hash(address), 8, 3600);
|
||||||
const { mapZoom, ...fields } = body;
|
const { mapZoom, mapPan, timerVisible, ...fields } = body;
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from("difficulty_presets")
|
.from("difficulty_presets")
|
||||||
.insert({ ...fields, map_zoom: mapZoom })
|
.insert({ ...fields, map_zoom: mapZoom, map_pan: mapPan, timer_visible: timerVisible })
|
||||||
.select(
|
.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();
|
.single();
|
||||||
checkDifficultyPresetError(error);
|
checkDifficultyPresetError(error);
|
||||||
return NextResponse.json({
|
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
|
const body = z
|
||||||
.object({
|
.object({
|
||||||
datasetId: idSchema,
|
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));
|
.parse(await jsonBody(request));
|
||||||
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
|
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
|
||||||
|
|||||||
76
lib/changelog-feed.ts
Normal file
76
lib/changelog-feed.ts
Normal 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
21
lib/changelog.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export function checkDifficultyPresetError(
|
|||||||
throw new ServiceError("A difficulty with that name already exists", 409);
|
throw new ServiceError("A difficulty with that name already exists", 409);
|
||||||
if (error?.code === "23514")
|
if (error?.code === "23514")
|
||||||
throw new ServiceError(
|
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,
|
400,
|
||||||
);
|
);
|
||||||
checkServiceError(error);
|
checkServiceError(error);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ try {
|
|||||||
),
|
),
|
||||||
(error) => error.code === "23514",
|
(error) => error.code === "23514",
|
||||||
);
|
);
|
||||||
for (const seconds of [0, 14, 16, 601])
|
for (const seconds of [0, 4, 14, 16, 601])
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
db.query(
|
db.query(
|
||||||
"insert into public.difficulty_presets(name,seconds,zoom) values('Invalid clock',$1,true)",
|
"insert into public.difficulty_presets(name,seconds,zoom) values('Invalid clock',$1,true)",
|
||||||
@@ -83,6 +83,7 @@ try {
|
|||||||
["easy", 180],
|
["easy", 180],
|
||||||
["normal", 90],
|
["normal", 90],
|
||||||
["hard", 30],
|
["hard", 30],
|
||||||
|
["void", 10],
|
||||||
])
|
])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
@@ -111,6 +112,10 @@ try {
|
|||||||
"insert into auth.users values(gen_random_uuid()) returning id",
|
"insert into auth.users values(gen_random_uuid()) returning id",
|
||||||
)
|
)
|
||||||
).rows[0].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 = (
|
const map = (
|
||||||
await db.query(
|
await db.query(
|
||||||
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
|
||||||
@@ -137,6 +142,85 @@ try {
|
|||||||
revision,
|
revision,
|
||||||
map,
|
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 = (
|
const solo = (
|
||||||
await db.query("select public.create_game($1,$2,$3,$4) id", [
|
await db.query("select public.create_game($1,$2,$3,$4) id", [
|
||||||
map,
|
map,
|
||||||
@@ -173,6 +257,8 @@ try {
|
|||||||
compass: true,
|
compass: true,
|
||||||
coordinates: true,
|
coordinates: true,
|
||||||
mapZoom: true,
|
mapZoom: true,
|
||||||
|
mapPan: true,
|
||||||
|
timerVisible: true,
|
||||||
scoring: "normal",
|
scoring: "normal",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -201,6 +287,8 @@ try {
|
|||||||
compass: false,
|
compass: false,
|
||||||
coordinates: false,
|
coordinates: false,
|
||||||
mapZoom: false,
|
mapZoom: false,
|
||||||
|
mapPan: true,
|
||||||
|
timerVisible: true,
|
||||||
scoring: "strict",
|
scoring: "strict",
|
||||||
});
|
});
|
||||||
// Existing sessions retain the original curve even after the library changes.
|
// Existing sessions retain the original curve even after the library changes.
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ try {
|
|||||||
JSON.stringify({ ...base, ...payload }),
|
JSON.stringify({ ...base, ...payload }),
|
||||||
])
|
])
|
||||||
)[0].id;
|
)[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 = {}) =>
|
const act = async (id, user, action = "view", payload = {}) =>
|
||||||
(
|
(
|
||||||
await q("select public.tournament_action($1,$2,$3,$4) value", [
|
await q("select public.tournament_action($1,$2,$3,$4) value", [
|
||||||
|
|||||||
51
shared/changelog.ts
Normal file
51
shared/changelog.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -238,6 +238,8 @@ export type Database = {
|
|||||||
compass: boolean;
|
compass: boolean;
|
||||||
coordinates: boolean;
|
coordinates: boolean;
|
||||||
map_zoom: boolean;
|
map_zoom: boolean;
|
||||||
|
map_pan: boolean;
|
||||||
|
timer_visible: boolean;
|
||||||
scoring: "normal" | "strict" | "gentle";
|
scoring: "normal" | "strict" | "gentle";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -250,6 +252,8 @@ export type Database = {
|
|||||||
compass?: boolean;
|
compass?: boolean;
|
||||||
coordinates?: boolean;
|
coordinates?: boolean;
|
||||||
map_zoom?: boolean;
|
map_zoom?: boolean;
|
||||||
|
map_pan?: boolean;
|
||||||
|
timer_visible?: boolean;
|
||||||
scoring?: "normal" | "strict" | "gentle";
|
scoring?: "normal" | "strict" | "gentle";
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -262,6 +266,8 @@ export type Database = {
|
|||||||
compass?: boolean;
|
compass?: boolean;
|
||||||
coordinates?: boolean;
|
coordinates?: boolean;
|
||||||
map_zoom?: boolean;
|
map_zoom?: boolean;
|
||||||
|
map_pan?: boolean;
|
||||||
|
timer_visible?: boolean;
|
||||||
scoring?: "normal" | "strict" | "gentle";
|
scoring?: "normal" | "strict" | "gentle";
|
||||||
name?: string;
|
name?: string;
|
||||||
seconds?: number;
|
seconds?: number;
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ export const developerOpenApi = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
id,
|
id,
|
||||||
difficulty: { type: "string", enum: ["easy", "normal", "hard"] },
|
difficulty: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["easy", "normal", "hard", "void"],
|
||||||
|
},
|
||||||
createdAt: date,
|
createdAt: date,
|
||||||
currentRound: { type: "integer", minimum: 0, maximum: 5 },
|
currentRound: { type: "integer", minimum: 0, maximum: 5 },
|
||||||
complete: { type: "boolean" },
|
complete: { type: "boolean" },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const leaderboardDifficulties = [
|
|||||||
"easy",
|
"easy",
|
||||||
"normal",
|
"normal",
|
||||||
"hard",
|
"hard",
|
||||||
|
"void",
|
||||||
] as const;
|
] as const;
|
||||||
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
||||||
export type LeaderboardEntry =
|
export type LeaderboardEntry =
|
||||||
|
|||||||
@@ -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 для метки в центре.",
|
"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.": "Строгий подсчёт сильнее штрафует за расстояние, мягкий даёт больше очков за близкие метки. В игре по-прежнему пять раундов.",
|
"Strict scoring penalizes distance more; gentle scoring gives more credit for nearby guesses. Every game still has five rounds.": "Строгий подсчёт сильнее штрафует за расстояние, мягкий даёт больше очков за близкие метки. В игре по-прежнему пять раундов.",
|
||||||
"Guess selected": "Метка выбрана",
|
"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": "Исправления ошибок"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const createRoomSchema = z
|
|||||||
.object({
|
.object({
|
||||||
datasetId: z.string().uuid(),
|
datasetId: z.string().uuid(),
|
||||||
difficulty: z.union([
|
difficulty: z.union([
|
||||||
z.enum(["easy", "normal", "hard"]),
|
z.enum(["easy", "normal", "hard", "void"]),
|
||||||
z.string().uuid(),
|
z.string().uuid(),
|
||||||
]),
|
]),
|
||||||
playerLimit: z.number().int().min(2).max(16),
|
playerLimit: z.number().int().min(2).max(16),
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const metricDefinitions = {
|
|||||||
minegeo_games: {
|
minegeo_games: {
|
||||||
help: "Retained solo games by difficulty and completion state.",
|
help: "Retained solo games by difficulty and completion state.",
|
||||||
labels: {
|
labels: {
|
||||||
difficulty: ["easy", "normal", "hard"],
|
difficulty: ["easy", "normal", "hard", "void"],
|
||||||
status: ["incomplete", "complete"],
|
status: ["incomplete", "complete"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ export const difficulties = {
|
|||||||
easy: { seconds: 180, zoom: true, label: "Easy" },
|
easy: { seconds: 180, zoom: true, label: "Easy" },
|
||||||
normal: { seconds: 90, zoom: true, label: "Normal" },
|
normal: { seconds: 90, zoom: true, label: "Normal" },
|
||||||
hard: { seconds: 30, zoom: false, label: "Hard" },
|
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;
|
} as const;
|
||||||
export type Difficulty = keyof typeof difficulties;
|
export type Difficulty = keyof typeof difficulties;
|
||||||
export const difficultyRuleDefaults = {
|
export const difficultyRuleDefaults = {
|
||||||
@@ -190,6 +202,8 @@ export const difficultyRuleDefaults = {
|
|||||||
compass: true,
|
compass: true,
|
||||||
coordinates: true,
|
coordinates: true,
|
||||||
mapZoom: true,
|
mapZoom: true,
|
||||||
|
mapPan: true,
|
||||||
|
timerVisible: true,
|
||||||
scoring: "normal" as const,
|
scoring: "normal" as const,
|
||||||
};
|
};
|
||||||
export type ScoringMode = "normal" | "strict" | "gentle";
|
export type ScoringMode = "normal" | "strict" | "gentle";
|
||||||
@@ -203,6 +217,8 @@ export type DifficultyPreset = {
|
|||||||
compass?: boolean;
|
compass?: boolean;
|
||||||
coordinates?: boolean;
|
coordinates?: boolean;
|
||||||
mapZoom?: boolean;
|
mapZoom?: boolean;
|
||||||
|
mapPan?: boolean;
|
||||||
|
timerVisible?: boolean;
|
||||||
scoring?: ScoringMode;
|
scoring?: ScoringMode;
|
||||||
};
|
};
|
||||||
export const customDifficultySchema = z
|
export const customDifficultySchema = z
|
||||||
@@ -222,14 +238,16 @@ export const customDifficultySchema = z
|
|||||||
seconds: z
|
seconds: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(15)
|
.min(5)
|
||||||
.max(600)
|
.max(600)
|
||||||
.refine((seconds) => seconds % 15 === 0),
|
.refine((seconds) => seconds % 5 === 0),
|
||||||
zoom: z.boolean(),
|
zoom: z.boolean(),
|
||||||
pan: z.boolean().default(true),
|
pan: z.boolean().default(true),
|
||||||
compass: z.boolean().default(true),
|
compass: z.boolean().default(true),
|
||||||
coordinates: z.boolean().default(true),
|
coordinates: z.boolean().default(true),
|
||||||
mapZoom: 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"),
|
scoring: z.enum(["normal", "strict", "gentle"]).default("normal"),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|||||||
@@ -16,17 +16,24 @@ export const tournamentSettings = z
|
|||||||
name: safeName,
|
name: safeName,
|
||||||
datasetId: z.string().uuid(),
|
datasetId: z.string().uuid(),
|
||||||
difficulty: z.union([
|
difficulty: z.union([
|
||||||
z.enum(["easy", "normal", "hard"]),
|
z.enum(["easy", "normal", "hard", "void"]),
|
||||||
z.string().uuid(),
|
z.string().uuid(),
|
||||||
]),
|
]),
|
||||||
playerLimit: z.number().int().min(2).max(32),
|
playerLimit: z.number().int().min(2).max(32),
|
||||||
roundCount: z.number().int().min(1).max(10),
|
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"),
|
timerMode: z.enum(["shared", "perMini"]).default("shared"),
|
||||||
startsAt: z.string().datetime({ offset: true }),
|
startsAt: z.string().datetime({ offset: true }),
|
||||||
announceDiscord: z.boolean(),
|
announceDiscord: z.boolean(),
|
||||||
})
|
})
|
||||||
.strict()
|
.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(
|
.refine(
|
||||||
(value) => value.timerMode !== "perMini" || value.roundTimeSeconds <= 600,
|
(value) => value.timerMode !== "perMini" || value.roundTimeSeconds <= 600,
|
||||||
"Mini-round timers allow at most 600 seconds",
|
"Mini-round timers allow at most 600 seconds",
|
||||||
|
|||||||
135
supabase/migrations/20261020083343_void_difficulty_rules.sql
Normal file
135
supabase/migrations/20261020083343_void_difficulty_rules.sql
Normal 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
136
tests/changelog.test.ts
Normal 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, /<script>/);
|
||||||
|
assert.match(html, /href="https:\/\/minegeo.site\/docs"/);
|
||||||
|
assert.doesNotMatch(html, /href="javascript:/);
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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";
|
import { mergeDifficultyPresets } from "../shared/difficulty-presets";
|
||||||
|
|
||||||
const rules = { seconds: 90, zoom: true };
|
const rules = { seconds: 90, zoom: true };
|
||||||
@@ -12,6 +18,8 @@ test("expanded difficulty rules preserve defaults and validate controls", () =>
|
|||||||
compass: preset.compass,
|
compass: preset.compass,
|
||||||
coordinates: preset.coordinates,
|
coordinates: preset.coordinates,
|
||||||
mapZoom: preset.mapZoom,
|
mapZoom: preset.mapZoom,
|
||||||
|
mapPan: preset.mapPan,
|
||||||
|
timerVisible: preset.timerVisible,
|
||||||
scoring: preset.scoring,
|
scoring: preset.scoring,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -19,10 +27,19 @@ test("expanded difficulty rules preserve defaults and validate controls", () =>
|
|||||||
compass: true,
|
compass: true,
|
||||||
coordinates: true,
|
coordinates: true,
|
||||||
mapZoom: true,
|
mapZoom: true,
|
||||||
|
mapPan: true,
|
||||||
|
timerVisible: true,
|
||||||
scoring: "normal",
|
scoring: "normal",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
for (const key of ["pan", "compass", "coordinates", "mapZoom"])
|
for (const key of [
|
||||||
|
"pan",
|
||||||
|
"compass",
|
||||||
|
"coordinates",
|
||||||
|
"mapZoom",
|
||||||
|
"mapPan",
|
||||||
|
"timerVisible",
|
||||||
|
])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
customDifficultySchema.safeParse({
|
customDifficultySchema.safeParse({
|
||||||
name: "Fixture",
|
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", () => {
|
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(
|
assert.equal(
|
||||||
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
|
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
|
||||||
.success,
|
.success,
|
||||||
true,
|
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(
|
assert.equal(
|
||||||
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
|
customDifficultySchema.safeParse({ name: "Fixture", ...rules, seconds })
|
||||||
.success,
|
.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 () => {
|
test("a delayed preset listing cannot erase a newly saved preset or duplicate it", async () => {
|
||||||
const old = { id: "old", name: "Old", ...rules };
|
const old = { id: "old", name: "Old", ...rules };
|
||||||
const saved = { id: "saved", name: "New", ...rules };
|
const saved = { id: "saved", name: "New", ...rules };
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ const samples = Object.entries(metricDefinitions).flatMap(
|
|||||||
|
|
||||||
test("metrics exposition is bounded, complete and contains only fixed aggregate dimensions", () => {
|
test("metrics exposition is bounded, complete and contains only fixed aggregate dimensions", () => {
|
||||||
const text = formatMetrics(samples);
|
const text = formatMetrics(samples);
|
||||||
assert.equal(samples.length, 43);
|
assert.equal(samples.length, 45);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
text.split("\n").filter((line) => line && !line.startsWith("#")).length,
|
text.split("\n").filter((line) => line && !line.startsWith("#")).length,
|
||||||
43,
|
45,
|
||||||
);
|
);
|
||||||
assert.ok(text.endsWith("\n"));
|
assert.ok(text.endsWith("\n"));
|
||||||
assert.match(text, /# TYPE minegeo_games gauge/);
|
assert.match(text, /# TYPE minegeo_games gauge/);
|
||||||
|
|||||||
Reference in New Issue
Block a user