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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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