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_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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
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" &&
|
||||
!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(
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
<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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
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 />
|
||||
<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")}
|
||||
|
||||
@@ -8,6 +8,7 @@ const links = [
|
||||
["/servers", "Play & maps"],
|
||||
["/leaderboard", "Leaderboard"],
|
||||
["/tournaments", "Tournaments"],
|
||||
["/changelogs", "Changelogs"],
|
||||
["/downloads", "Downloads"],
|
||||
["/about", "Guide"],
|
||||
["/docs", "Docs"],
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
16
lib/api.ts
16
lib/api.ts
@@ -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
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);
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
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;
|
||||
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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -3,6 +3,7 @@ export const leaderboardDifficulties = [
|
||||
"easy",
|
||||
"normal",
|
||||
"hard",
|
||||
"void",
|
||||
] as const;
|
||||
export type LeaderboardDifficulty = (typeof leaderboardDifficulties)[number];
|
||||
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 для метки в центре.",
|
||||
"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": "Исправления ошибок"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
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 { 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 };
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user