Improve MineGeo UI and application flows
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
This commit is contained in:
48
tests/cinematic-motion.test.ts
Normal file
48
tests/cinematic-motion.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
216
tests/tournament-discord.test.ts
Normal file
216
tests/tournament-discord.test.ts
Normal 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
452
tests/tournaments.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user