Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s

This commit is contained in:
2026-10-18 11:44:04 +05:00
parent ad73132f15
commit a7085e4e80
50 changed files with 4790 additions and 703 deletions

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
easeOut,
easeInOut,
enter,
visibility,
shots,
} from "../cinematics/motion.mjs";
test("cinematic timeline preserves all real site captures and covers exactly thirty seconds", () => {
assert.equal(shots[0].start, 0);
assert.equal(shots.at(-1)?.end, 30);
assert.deepEqual(
shots.flatMap((s) => (s.image ? [s.image] : [])),
["home", "catalog", "game", "map"],
);
for (let i = 1; i < shots.length; i++)
assert.equal(shots[i].start, shots[i - 1].end);
});
test("canonical motion curves clamp endpoints and have no overshoot", () => {
for (const curve of [easeOut, easeInOut]) {
assert.equal(curve(-1), 0);
assert.equal(curve(0), 0);
assert.equal(curve(1), 1);
assert.equal(curve(2), 1);
let previous = 0;
for (let i = 0; i <= 100; i++) {
const n = curve(i / 100);
assert.ok(n >= previous && n <= 1);
previous = n;
}
}
assert.ok(
easeOut(0.25) > 0.6,
"the entry must respond promptly rather than ease in",
);
});
test("screenshots settle quickly and stay fully readable throughout the hold", () => {
for (const shot of shots.filter((s) => s.image)) {
assert.equal(enter(shot.start + 0.25, shot.start), 1);
assert.equal(visibility(shot.start + 1, shot.start, shot.end), 1);
assert.equal(visibility(shot.end - 1, shot.start, shot.end), 1);
assert.equal(visibility(shot.end, shot.start, shot.end), 0);
}
});

View File

@@ -1,9 +1,63 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { customDifficultySchema } from "../shared/protocol";
import { customDifficultySchema, scoreGuess } from "../shared/protocol";
import { mergeDifficultyPresets } from "../shared/difficulty-presets";
const rules = { seconds: 90, zoom: true };
test("expanded difficulty rules preserve defaults and validate controls", () => {
const preset = customDifficultySchema.parse({ name: "Classic", ...rules });
assert.deepEqual(
{
pan: preset.pan,
compass: preset.compass,
coordinates: preset.coordinates,
mapZoom: preset.mapZoom,
scoring: preset.scoring,
},
{
pan: true,
compass: true,
coordinates: true,
mapZoom: true,
scoring: "normal",
},
);
for (const key of ["pan", "compass", "coordinates", "mapZoom"])
assert.equal(
customDifficultySchema.safeParse({
name: "Fixture",
...rules,
[key]: "false",
}).success,
false,
);
assert.equal(
customDifficultySchema.safeParse({
name: "Fixture",
...rules,
scoring: "extreme",
}).success,
false,
);
assert.equal(
customDifficultySchema.safeParse({ name: "Fixture", ...rules, rounds: 10 })
.success,
false,
);
});
test("scoring strictness changes distance decay while perfect guesses remain 5000", () => {
const bounds = { minX: 0, minZ: 0, maxX: 100, maxZ: 100 };
const actual = { x: 10, z: 10 },
guess = { x: 40, z: 40 };
const scores = ["strict", "normal", "gentle"].map(
(mode) =>
scoreGuess(actual, guess, bounds, mode as "strict" | "normal" | "gentle")
.score,
);
assert.ok(scores[0] < scores[1] && scores[1] < scores[2]);
for (const mode of ["strict", "normal", "gentle"] as const)
assert.equal(scoreGuess(actual, actual, bounds, mode).score, 5000);
});
test("difficulty names accept normal text and count Unicode characters consistently with PostgreSQL", () => {
for (const name of [
"Normal",

View File

@@ -0,0 +1,216 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DiscordClient, DiscordDeliveryError } from "../lib/discord-client";
import {
deliverTournamentAnnouncement,
tournamentAnnouncementMessage,
TOURNAMENT_NEWS_CHANNEL,
TOURNAMENT_EVENT_ROLES,
type TournamentAnnouncementJob,
type TournamentAnnouncementSource,
} from "../lib/tournament-discord";
const now = Date.parse("2026-10-18T10:00:00Z");
const id = "00000000-0000-4000-8000-000000000001";
const source: TournamentAnnouncementSource = {
id,
name: "Cave cup",
owner: "Pavel",
starts_at: new Date(now + 15 * 60000).toISOString(),
server_timezone: "UTC",
player_limit: 32,
round_count: 3,
round_time_seconds: 300,
timer_mode: "shared",
status: "scheduled",
announce_discord: true,
};
const job: TournamentAnnouncementJob = {
id,
tournament_id: id,
state: "sending",
attempts: 1,
next_attempt_at: new Date(now).toISOString(),
lease_owner: id,
lease_until: new Date(now + 120000).toISOString(),
channel_id: null,
message_id: null,
sent_at: null,
last_error: null,
};
test("tournament Discord announcements pin their channel, public URL, explicit role mentions and stable nonce", async () => {
const unsafe = {
...source,
name: "Cup @everyone <@123> [bad](https://evil.example)",
owner: "Owner <@&999>",
};
const message = tournamentAnnouncementMessage(id, unsafe);
assert.deepEqual(message.allowed_mentions, {
roles: [...TOURNAMENT_EVENT_ROLES],
users: [],
replied_user: false,
});
assert.ok(
message.content.startsWith(
TOURNAMENT_EVENT_ROLES.map((role) => `<@&${role}>`).join(", "),
),
);
assert.ok(message.content.includes(`https://minegeo.site/tournaments/${id}`));
assert.ok(!message.content.includes("Cup @everyone <@123>"));
assert.match(
message.embeds[0].fields.find(
(field) => field.name === "Starts · server time",
)!.value,
/UTC/,
);
assert.match(
message.embeds[0].fields.find((field) => field.name === "Format")!.value,
/3 tournament rounds · 5 mini-rounds each/,
);
assert.equal(message.nonce, tournamentAnnouncementMessage(id, source).nonce);
assert.notEqual(
message.nonce,
tournamentAnnouncementMessage("another-event", source).nonce,
);
assert.throws(
() =>
tournamentAnnouncementMessage(id, { ...source, id: "../../elsewhere" }),
/Invalid tournament link/,
);
const client = new DiscordClient("test-token", async (url, init) => {
assert.equal(
url,
`https://discord.com/api/v10/channels/${TOURNAMENT_NEWS_CHANNEL}/messages`,
);
assert.deepEqual(
JSON.parse(String(init?.body)).allowed_mentions,
message.allowed_mentions,
);
return Response.json({
id: "223456789012345678",
channel_id: TOURNAMENT_NEWS_CHANNEL,
});
});
assert.equal(
await client.send(TOURNAMENT_NEWS_CHANNEL, message),
"223456789012345678",
);
});
test("tournament announcement timer describes shared and per-mini-round budgets", () => {
const timer = (value: TournamentAnnouncementSource) =>
tournamentAnnouncementMessage(id, value).embeds[0].fields.find(
(field) => field.name === "Round timer",
)!.value;
assert.equal(timer(source), "300 seconds shared across all 5 mini-rounds");
assert.equal(
timer({ ...source, timer_mode: "perMini", round_time_seconds: 90 }),
"90 seconds per mini-round",
);
assert.equal(
timer({ ...source, round_time_seconds: 3000 }),
"3000 seconds shared across all 5 mini-rounds",
);
});
async function deliver(
value: TournamentAnnouncementSource | null,
failure?: Error,
attempts = 1,
) {
const patches: Partial<TournamentAnnouncementJob>[] = [];
const sends: string[] = [];
await deliverTournamentAnnouncement(
{ ...job, attempts },
{
now: () => now,
load: async () => value,
client: {
send: async (channel) => {
sends.push(channel);
if (failure) throw failure;
return "223456789012345678";
},
},
finish: async (patch) => {
patches.push(patch);
},
},
);
return { patches, sends };
}
test("only opted-in scheduled tournaments are announced during their last15minutes", async () => {
for (const value of [
null,
{ ...source, announce_discord: false },
{ ...source, status: "cancelled" },
{ ...source, status: "running" },
{ ...source, starts_at: new Date(now).toISOString() },
{ ...source, id: "different" },
]) {
const result = await deliver(value);
assert.equal(result.sends.length, 0);
assert.equal(result.patches[0].state, "skipped");
assert.equal(result.patches[0].lease_owner, null);
}
const sent = await deliver(source);
assert.deepEqual(sent.sends, [TOURNAMENT_NEWS_CHANNEL]);
assert.equal(sent.patches[0].state, "sent");
assert.equal(sent.patches[0].sent_at, new Date(now).toISOString());
assert.equal(
(
await deliver({
...source,
starts_at: new Date(now + 60000).toISOString(),
})
).patches[0].state,
"sent",
);
});
test("a rescheduled tournament releases its old lease until15minutes before its new start", async () => {
const result = await deliver({
...source,
starts_at: new Date(now + 2 * 3600000).toISOString(),
});
assert.equal(result.sends.length, 0);
assert.equal(result.patches[0].state, "pending");
assert.equal(
result.patches[0].next_attempt_at,
new Date(now + 105 * 60000).toISOString(),
);
});
test("Discord rate limits retry durably and configuration failures stop after safe errors", async () => {
const limited = await deliver(
source,
new DiscordDeliveryError("Discord rate limit.", 180),
);
assert.equal(limited.patches[0].state, "pending");
assert.equal(
limited.patches[0].next_attempt_at,
new Date(now + 180000).toISOString(),
);
assert.equal(limited.patches[0].lease_until, null);
assert.equal(
(
await deliver(
source,
new DiscordDeliveryError("Missing permissions.", 60, true),
)
).patches[0].state,
"failed",
);
assert.equal(
(await deliver(source, new Error("secret request body"))).patches[0]
.last_error,
"Tournament announcement could not be confirmed. Delivery will retry.",
);
assert.equal(
(await deliver(source, new DiscordDeliveryError("Retry."), 8)).patches[0]
.state,
"failed",
);
});

452
tests/tournaments.test.ts Normal file
View File

@@ -0,0 +1,452 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { NextRequest } from "next/server";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../shared/database";
import { tournamentApi } from "../lib/tournament-api";
import {
serverLocalToIso,
tournamentSettings,
tournamentEdit,
} from "../shared/tournaments";
import { ServiceError } from "../lib/service-error";
const tournament = "11111111-1111-4111-8111-111111111111";
const member = "22222222-2222-4222-8222-222222222222";
const map = "33333333-3333-4333-8333-333333333333";
const room = "44444444-4444-4444-8444-444444444444";
const revision = "55555555-5555-4555-8555-555555555555";
const now = Date.parse("2026-10-18T12:00:00.000Z");
const valid = {
name: "Sunday expedition",
datasetId: map,
difficulty: "normal",
playerLimit: 32,
roundCount: 10,
roundTimeSeconds: 300,
startsAt: "2026-10-19T17:00:00+05:00",
announceDiscord: true,
};
type Result = {
data: unknown;
error: { message: string; code?: string } | null;
};
function fixture({ signedIn = true, inactive = false } = {}) {
const calls: { table: string; method: string; args: unknown[] }[] = [];
const replies = new Map<string, Result[]>();
const rates: string[] = [],
announcements: (string | undefined)[] = [];
const signatures: (string | null)[][] = [];
let bodyReads = 0;
const take = (table: string) =>
replies.get(table)?.shift() ?? { data: null, error: null };
const db = {
from(table: string) {
const query: Record<string, unknown> = {};
for (const method of ["select", "eq", "order", "limit", "single"])
query[method] = (...args: unknown[]) => {
calls.push({ table, method, args });
return query;
};
query.then = (resolve: (value: Result) => unknown) =>
Promise.resolve(take(table)).then(resolve);
return query;
},
rpc(table: string, args: unknown) {
calls.push({ table, method: "rpc", args: [args] });
return Promise.resolve(take(table));
},
} as unknown as SupabaseClient<Database>;
return {
calls,
rates,
announcements,
signatures,
get bodyReads() {
return bodyReads;
},
reply(table: string, data: unknown, error: Result["error"] = null) {
const list = replies.get(table) ?? [];
list.push({ data, error });
replies.set(table, list);
},
run(path: string, method = "GET", body?: unknown) {
return tournamentApi(
new NextRequest("https://minegeo.test/api/v1/" + path, { method }),
path.split("?")[0].split("/"),
{
db,
account: async () => {
if (inactive) throw new ServiceError("Account banned", 403);
return signedIn ? { id: member } : null;
},
jsonBody: async () => {
bodyReads++;
return body;
},
limit: async (key) => {
rates.push(key);
},
mediaUrls: async (paths) => {
signatures.push(paths);
return paths.map((path) =>
path ? "https://media.test/" + path : null,
);
},
announce: (target) => {
announcements.push(target);
},
serverTimezone: "Asia/Yekaterinburg",
},
);
},
};
}
function playingView(overrides: Record<string, unknown> = {}) {
return {
id: tournament,
currentRound: 2,
you: member,
currentRoom: { id: room, you: member, status: "playing", round: 3 },
...overrides,
};
}
const manifest = {
format: "minegeo-dataset/v1",
minecraftVersion: "1.21.11",
dimension: "minecraft:overworld",
source: "plugin",
name: "Fixture map",
serverName: "Fixture server",
north: "-z",
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: i,
y: 64,
z: i,
})),
assets: [{ path: "terrain.json.gz", sha256: "a".repeat(64), bytes: 1 }],
};
function mediaReplies(f: ReturnType<typeof fixture>, view = playingView()) {
f.reply("tournament_action", view);
f.reply("multiplayer_rooms", { revision_id: revision });
f.reply("multiplayer_rounds", { viewpoint: manifest.viewpoints[3] });
f.reply("revisions", { asset_root: map, manifest });
}
test("tournament limits include the creator, bound both round tiers, and keep timer modes explicit", () => {
assert.equal(tournamentSettings.parse(valid).timerMode, "shared");
for (const playerLimit of [2, 32])
assert.equal(
tournamentSettings.safeParse({ ...valid, playerLimit }).success,
true,
);
for (const playerLimit of [1, 33, 2.5, "32", null])
assert.equal(
tournamentSettings.safeParse({ ...valid, playerLimit }).success,
false,
);
for (const roundCount of [1, 10])
assert.equal(
tournamentSettings.safeParse({ ...valid, roundCount }).success,
true,
);
for (const roundCount of [0, 11, 1.5])
assert.equal(
tournamentSettings.safeParse({ ...valid, roundCount }).success,
false,
);
for (const roundTimeSeconds of [15, 600, 3000])
assert.equal(
tournamentSettings.safeParse({ ...valid, roundTimeSeconds }).success,
true,
);
for (const roundTimeSeconds of [0, 14, 16, 3001, 15.5])
assert.equal(
tournamentSettings.safeParse({ ...valid, roundTimeSeconds }).success,
false,
);
assert.equal(
tournamentSettings.safeParse({
...valid,
timerMode: "perMini",
roundTimeSeconds: 600,
}).success,
true,
);
assert.equal(
tournamentSettings.safeParse({
...valid,
timerMode: "perMini",
roundTimeSeconds: 615,
}).success,
false,
);
assert.equal(
tournamentSettings.safeParse({ ...valid, timerMode: "unknown" }).success,
false,
);
for (const extra of [
{ ownerId: map },
{ owner_id: map },
{ score: 25000 },
{ serverTimezone: "UTC" },
{ rounds: 99 },
])
assert.equal(
tournamentSettings.safeParse({ ...valid, ...extra }).success,
false,
);
assert.equal(
tournamentEdit.safeParse({
startsAt: valid.startsAt,
playerLimit: 32,
announceDiscord: true,
ownerId: map,
}).success,
false,
);
});
test("server local start times depend only on the server zone and represent the same instant", () => {
assert.equal(
serverLocalToIso("2026-10-18T17:00", "Asia/Yekaterinburg"),
"2026-10-18T12:00:00.000Z",
);
assert.equal(
serverLocalToIso("2026-10-18T12:00", "UTC"),
"2026-10-18T12:00:00.000Z",
);
assert.equal(
serverLocalToIso("2026-10-18T17:45", "Asia/Kathmandu"),
"2026-10-18T12:00:00.000Z",
);
assert.equal(
serverLocalToIso("2026-07-18T08:00", "America/New_York"),
"2026-07-18T12:00:00.000Z",
);
assert.equal(
serverLocalToIso("2026-01-18T07:00", "America/New_York"),
"2026-01-18T12:00:00.000Z",
);
for (const value of [
"2026-02-30T12:00",
"2026-02-29T12:00",
"2026-13-01T12:00",
"2026-10-18T24:00",
"not-a-date",
"2026-10-18",
])
assert.throws(() => serverLocalToIso(value, "UTC"));
assert.throws(
() => serverLocalToIso("2026-03-08T02:30", "America/New_York"),
/does not exist/,
);
assert.throws(() => serverLocalToIso("2026-10-18T12:00", "Invalid/Timezone"));
});
test("guests cannot create, join, or play and banned account errors propagate before writes", async () => {
for (const [path, method, body] of [
["tournaments", "POST", valid],
[`tournaments/${tournament}/join`, "POST", {}],
[
`tournaments/${tournament}/ready`,
"POST",
{ round: 0, tournamentRound: 0 },
],
[
`tournaments/${tournament}/guess`,
"POST",
{ round: 0, tournamentRound: 0, x: 1, z: 1 },
],
[`tournaments/${tournament}/media?tournamentRound=2`, "GET", undefined],
] as const) {
const f = fixture({ signedIn: false });
await assert.rejects(
f.run(path, method, body),
(e) => e instanceof ServiceError && e.status === 401,
);
assert.equal(f.calls.length, 0);
assert.equal(f.bodyReads, 0);
}
const inactive = fixture({ inactive: true });
await assert.rejects(
inactive.run("tournaments", "POST", valid),
/Account banned/,
);
assert.equal(inactive.calls.length, 0);
});
test("creation derives the owner and timezone from trusted dependencies and rate-limits that account", async (context) => {
context.mock.method(Date, "now", () => now);
const f = fixture();
f.reply("create_tournament", tournament);
const response = await f.run("tournaments", "POST", valid);
assert.deepEqual(await response.json(), { id: tournament });
const args = f.calls.find((call) => call.table === "create_tournament")!
.args[0] as { p_owner: string; p_payload: Record<string, unknown> };
assert.equal(args.p_owner, member);
assert.equal(args.p_payload.serverTimezone, "Asia/Yekaterinburg");
assert.equal(args.p_payload.timerMode, "shared");
assert.equal(
Date.parse(args.p_payload.startsAt as string),
Date.parse("2026-10-19T12:00:00Z"),
);
assert.deepEqual(f.rates, ["tournaments:create:" + member]);
assert.deepEqual(f.announcements, [tournament]);
assert.equal(response.headers.get("Cache-Control"), "no-store");
for (const extra of [
{ ownerId: map },
{ owner_id: map },
{ score: 5000 },
{ serverTimezone: "UTC" },
]) {
const rejected = fixture();
await assert.rejects(
rejected.run("tournaments", "POST", { ...valid, ...extra }),
);
assert.equal(rejected.calls.length, 0);
}
});
test("schedules must be future instants no further than ninety days away", async (context) => {
context.mock.method(Date, "now", () => now);
for (const start of [now - 1, now, now + 90 * 86400000 + 1]) {
const f = fixture();
await assert.rejects(
f.run("tournaments", "POST", {
...valid,
startsAt: new Date(start).toISOString(),
}),
(e) => e instanceof ServiceError && e.status === 400,
);
assert.equal(f.calls.length, 0);
}
const edge = fixture();
edge.reply("create_tournament", tournament);
assert.equal(
(
await edge.run("tournaments", "POST", {
...valid,
startsAt: new Date(now + 90 * 86400000).toISOString(),
})
).status,
200,
);
});
test("game actions require both round indices and reject forged scores and unknown fields", async () => {
for (const action of ["ready", "guess", "next"]) {
const base = {
round: 4,
tournamentRound: 9,
...(action === "guess" ? { x: 12, z: 21 } : {}),
};
const f = fixture();
f.reply("tournament_action", playingView());
await f.run(`tournaments/${tournament}/${action}`, "POST", base);
const args = f.calls.find((call) => call.table === "tournament_action")!
.args[0] as { p_account: string; p_action: string; p_payload: unknown };
assert.equal(args.p_account, member);
assert.equal(args.p_action, action);
assert.deepEqual(args.p_payload, base);
const { tournamentRound: _omitted, ...missing } = base;
for (const body of [
missing,
{ ...base, tournamentRound: -1 },
{ ...base, tournamentRound: 10 },
{ ...base, tournamentRound: "2" },
{ ...base, round: 5 },
{ ...base, score: 5000 },
{ ...base, playerId: map },
...(action === "guess"
? [
{ ...base, x: Infinity },
{ ...base, z: NaN },
]
: []),
]) {
const rejected = fixture();
await assert.rejects(
rejected.run(`tournaments/${tournament}/${action}`, "POST", body),
);
assert.equal(rejected.calls.length, 0);
}
}
for (const action of ["join", "leave", "cancel", "start"]) {
const f = fixture();
await assert.rejects(
f.run(`tournaments/${tournament}/${action}`, "POST", { ownerId: map }),
);
assert.equal(f.calls.length, 0);
}
});
test("spectators and unavailable rooms cannot sign panorama media", async () => {
for (const currentRoom of [
null,
{ id: room, you: null, status: "playing", round: 0 },
{ id: room, you: member, status: "lobby", round: 0 },
]) {
const f = fixture();
f.reply("tournament_action", playingView({ you: null, currentRoom }));
await assert.rejects(
f.run(`tournaments/${tournament}/media?tournamentRound=2`),
(e) => e instanceof ServiceError && e.status === 409,
);
assert.equal(f.signatures.length, 0);
assert.equal(
f.calls.filter((call) => call.table !== "tournament_action").length,
0,
);
}
});
test("media requires the current tournament round and signs only the current mini-round", async () => {
for (const query of [
"",
"?tournamentRound=1",
"?tournamentRound=3",
"?tournamentRound=-1",
"?tournamentRound=10",
"?tournamentRound=2.5",
]) {
const f = fixture();
mediaReplies(f);
await assert.rejects(f.run(`tournaments/${tournament}/media${query}`));
assert.equal(f.signatures.length, 0);
}
const f = fixture();
mediaReplies(f);
const response = await f.run(
`tournaments/${tournament}/media?tournamentRound=2`,
);
const body = await response.json();
assert.equal(body.round, 3);
assert.equal(body.faces.length, 6);
assert.equal(f.signatures.length, 1);
assert.ok(
f.signatures[0].slice(1).every((path) => path?.startsWith(map + "/p3/")),
);
assert.ok(
f.calls.some(
(call) =>
call.table === "multiplayer_rounds" &&
call.method === "eq" &&
call.args[0] === "round_number" &&
call.args[1] === 3,
),
);
assert.equal(JSON.stringify(body).includes("viewpoint"), false);
assert.equal(response.headers.get("Cache-Control"), "no-store");
});
test("a blank media round cannot be coerced into tournament round zero", async () => {
const f = fixture();
mediaReplies(f, playingView({ currentRound: 0 }));
await assert.rejects(
f.run(`tournaments/${tournament}/media?tournamentRound=`),
);
assert.equal(f.signatures.length, 0);
});