Files
MineGeo/scripts/test-tournaments-sql.mjs
pavel444-byte 9c314ecb4e
All checks were successful
Deploy MineGeo to Dokploy / deploy (push) Successful in 12m17s
Add tournament and multiplayer community chat moderation
2026-10-27 12:54:15 +05:00

642 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { readFile, readdir } from "node:fs/promises";
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
const db = new PGlite();
try {
await db.exec(`create role anon;create role authenticated;create role service_role;
create schema auth;create table auth.users(id uuid primary key);
create function auth.uid() returns uuid language sql as $$select null::uuid$$;
create schema storage;create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
create table storage.objects(id uuid primary key,bucket_id text,name text,owner_id text);
create function storage.foldername(text) returns text[] language sql as $$select string_to_array($1,'/')$$;`);
for (const file of (await readdir("supabase/migrations"))
.filter((f) => f.endsWith(".sql"))
.sort())
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
const q = async (sql, args = []) => (await db.query(sql, args)).rows;
const users = [];
for (let i = 0; i < 34; i++) {
const id = randomUUID();
users.push(id);
await q("insert into auth.users values($1)", [id]);
await q(
"insert into public.profiles(id,display_name) values($1,$2) on conflict(id) do update set display_name=excluded.display_name",
[id, `Player ${i}`],
);
}
const map = (
await q(
`insert into public.datasets(owner_id,name,source,bounds,state,panorama_count)
values($1,'Tournament fixture','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published',5) returning id`,
[users[0]],
)
)[0].id;
const manifest = {
name: "Tournament fixture",
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
viewpoints: Array.from({ length: 5 }, (_, i) => ({
id: `p${i}`,
x: 10 + i,
y: 64,
z: 10 + i,
})),
};
const rev = (
await q(
"insert into public.revisions(dataset_id,asset_root,manifest) values($1,'fixture',$2) returning id",
[map, JSON.stringify(manifest)],
)
)[0].id;
await q("update public.datasets set revision_id=$1 where id=$2", [rev, map]);
const base = {
name: "Community Cup",
datasetId: map,
difficulty: "normal",
playerLimit: 32,
roundCount: 2,
roundTimeSeconds: 30,
timerMode: "perMini",
startsAt: new Date(Date.now() + 3600000).toISOString(),
serverTimezone: "UTC",
announceDiscord: true,
};
const create = async (payload = {}) =>
(
await q("select public.create_tournament($1,$2) id", [
users[0],
JSON.stringify({ ...base, ...payload }),
])
)[0].id;
const communityEvent = await create();
const eventAction = (account, action, payload = {}) =>
q("select public.tournament_action($1,$2,$3,$4)", [
communityEvent,
account,
action,
JSON.stringify(payload),
]);
const community = (account, action, payload = {}) =>
q("select public.tournament_community($1,$2,$3,$4) value", [
communityEvent,
account,
action,
JSON.stringify(payload),
]);
const chat = (account, text = null, clientId = null) =>
q("select public.tournament_chat($1,$2,$3,$4) value", [
communityEvent,
account,
text,
clientId,
]);
await eventAction(users[1], "join");
await eventAction(users[2], "join");
await assert.rejects(
community(users[1], "ban", { accountId: users[2] }),
/moderator permission/,
);
await community(users[0], "admin", { accountId: users[1] });
await assert.rejects(
community(users[1], "ban", { accountId: users[0] }),
/owner or yourself/,
);
await community(users[1], "tag", { accountId: users[2], tag: "Explorer" });
await community(users[1], "slow", { seconds: 10 });
const messageId = randomUUID();
await chat(users[2], "Hello", messageId);
const repeated = await chat(users[2], "Hello", messageId);
assert.equal(repeated[0].value.messages.length, 1);
assert.equal(repeated[0].value.messages[0].tag, "Explorer");
await assert.rejects(chat(users[2], "Too fast", randomUUID()), /slow mode/);
await community(users[1], "ban", { accountId: users[2] });
await assert.rejects(chat(users[2]), /membership/);
await assert.rejects(eventAction(users[2], "join"), /banned/);
await community(users[1], "unban", { accountId: users[2] });
await eventAction(users[2], "join");
await eventAction(users[1], "leave");
await assert.rejects(
community(users[1], "slow", { seconds: 0 }),
/membership/,
);
await eventAction(users[1], "join");
await assert.rejects(
community(users[1], "slow", { seconds: 0 }),
/moderator permission/,
);
await q("delete from public.tournaments where id=$1", [communityEvent]);
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", [
id,
user,
action,
JSON.stringify(payload),
])
)[0].value;
for (const payload of [
{ playerLimit: 33 },
{ playerLimit: 1 },
{ roundCount: 0 },
{ roundTimeSeconds: 16 },
{ serverTimezone: "Invalid/Zone" },
{ startsAt: new Date(0).toISOString() },
])
await assert.rejects(create(payload));
await assert.rejects(
q("select public.create_tournament(null,$1)", [JSON.stringify(base)]),
/Active account/,
);
const tid = await create();
for (let i = 1; i < 32; i++) await act(tid, users[i], "join");
assert.equal((await act(tid, users[1], "join")).players.length, 32);
await assert.rejects(act(tid, users[32], "join"), /full/);
await assert.rejects(act(tid, users[1], "edit", {}), /owner/);
await assert.rejects(
act(tid, users[0], "edit", { playerLimit: 31 }),
/exclude/,
);
await assert.rejects(act(tid, users[0], "start"), /scheduled start/);
const worker = randomUUID();
assert.equal(
(
await q("select * from public.claim_tournament_announcements($1,$2)", [
worker,
tid,
])
).length,
0,
);
await act(tid, users[0], "edit", {
startsAt: new Date(Date.now() + 14 * 60000).toISOString(),
});
assert.equal(
(
await q("select * from public.claim_tournament_announcements($1,$2)", [
worker,
tid,
])
).length,
1,
);
assert.equal(
(
await q("select * from public.claim_tournament_announcements($1,$2)", [
randomUUID(),
tid,
])
).length,
0,
);
await q(
"update public.tournament_announcements set attempts=8,lease_until=clock_timestamp()-interval '1 second' where tournament_id=$1",
[tid],
);
assert.equal(
(
await q("select * from public.claim_tournament_announcements($1,$2)", [
worker,
tid,
])
).length,
0,
);
assert.equal(
(
await q(
"select state from public.tournament_announcements where tournament_id=$1",
[tid],
)
)[0].state,
"failed",
);
await act(tid, users[0], "edit", {
startsAt: new Date(Date.now() + 13 * 60000).toISOString(),
});
assert.equal(
(
await q("select * from public.claim_tournament_announcements($1,$2)", [
worker,
tid,
])
).length,
1,
);
await q(
"update public.tournament_announcements set lease_until=clock_timestamp()-interval '1 second' where tournament_id=$1",
[tid],
);
assert.equal(
(
await q("select * from public.claim_tournament_announcements($1,$2)", [
worker,
tid,
])
).length,
1,
);
await act(tid, users[0], "edit", { announceDiscord: false });
assert.equal(
(
await q(
"select state from public.tournament_announcements where tournament_id=$1",
[tid],
)
)[0].state,
"skipped",
);
await q(
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
[tid],
);
await q("select public.tick_tournaments()");
let state = await act(tid, users[0]);
assert.equal(state.status, "running");
assert.equal(state.currentRoom.players.length, 32);
assert.equal(state.currentRoom.rounds.length, 5);
assert.equal(state.currentRoom.difficultySettings.seconds, 30);
assert.equal(state.currentRoom.difficultySettings.roundCount, 5);
assert.equal(state.announcementState, "skipped");
assert.equal((await act(tid, users[1])).announcementState, null);
assert(!JSON.stringify(state).includes("viewpoint"));
for (const row of await q(
"select access_hash from public.tournament_members where tournament_id=$1",
[tid],
))
assert(!JSON.stringify(state).includes(row.access_hash));
assert.equal((await act(tid, null)).currentRoom, null);
await assert.rejects(act(tid, users[32], "join"), /Registration/);
await assert.rejects(
q(
"select public.multiplayer_action($1,$2,'join','{\"name\":\"Intruder\"}')",
[state.roomId, "f".repeat(64)],
),
/already started/,
);
await assert.rejects(
q(
"insert into public.multiplayer_players(room_id,access_hash,display_name,account_id) values($1,$2,'Intruder',$3)",
[state.roomId, "f".repeat(64), users[32]],
),
/Tournament membership/,
);
await assert.rejects(
q("select public.create_map_room($1,'normal','Host',32,$2,$3)", [
map,
"a".repeat(64),
users[0],
]),
/16/,
);
// Play all ten server-scored mini rounds; the owner wins perfect guesses.
for (let outer = 0; outer < 2; outer++) {
state = await act(tid, users[0]);
const room = state.roomId;
for (let mini = 0; mini < 5; mini++) {
await q(
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
[room],
);
await q("select public.tournament_tick($1)", [tid]);
await q(
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1 and status='loading'",
[room],
);
await q("select public.tournament_tick($1)", [tid]);
const answer = (
await q(
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=$2",
[room, mini],
)
)[0].viewpoint;
await assert.rejects(
act(tid, users[0], "guess", {
round: mini,
tournamentRound: outer - 1,
x: answer.x,
z: answer.z,
}),
/round changed/,
);
state = await act(tid, users[0], "guess", {
round: mini,
tournamentRound: outer,
x: answer.x,
z: answer.z,
score: 999999,
});
assert.equal(state.players[0].total, outer * 25000 + mini * 5000); // open round remains private/unscored
for (let i = 1; i < 32; i++)
state = await act(tid, users[i], "guess", {
round: mini,
tournamentRound: outer,
x: 64,
z: 64,
score: 999999,
});
}
}
state = await act(tid, users[0]);
assert.equal(state.status, "finished");
assert.equal(state.winnerId, users[0]);
assert.equal(state.players[0].total, 50000);
const finishedAt = state.finishedAt;
await q("select public.tick_tournaments()");
assert.equal((await act(tid, users[0])).finishedAt, finishedAt);
const board = (await q("select public.player_leaderboard() value"))[0].value;
assert.equal(board.entries[0].player_id, users[0]);
assert.equal(board.entries[0].rank, 1);
assert.equal(board.entries[0].best_score, 0);
assert.equal(board.entries[0].tournament_champion.tournament_id, tid);
assert.equal(
(await q("select public.player_leaderboard('normal') value"))[0].value
.entries.length,
0,
);
assert.equal(
(await q("select public.player_leaderboard('all','nonexistent') value"))[0]
.value.matching_players,
0,
);
// Solo scores remain unchanged; winner insertion/de-duplication preserves pagination.
for (let i = 0; i < 3; i++) {
const sid = (
await q("select public.create_game($1,'normal',$2,$3) id", [
map,
users[i],
String(i).repeat(64),
])
)[0].id;
await q("update public.game_sessions set current_round=5 where id=$1", [
sid,
]);
await q("update public.game_rounds set result=$2 where session_id=$1", [
sid,
JSON.stringify({ score: 1000 + i }),
]);
}
let ranked = (await q("select public.player_leaderboard() value"))[0].value;
assert.equal(ranked.total_players, 3);
assert.equal(ranked.entries[0].player_id, users[0]);
assert.equal(ranked.entries[0].best_score, 5000);
const secondPage = (
await q("select public.player_leaderboard('all','',2,2) value")
)[0].value;
assert.equal(secondPage.pages, 2);
assert.equal(secondPage.entries.length, 1);
assert.equal(secondPage.entries[0].rank, 3);
const newer = await create({ announceDiscord: false });
await q(
"update public.tournaments set status='finished',finished_at=clock_timestamp(),winner_id=$2 where id=$1",
[newer, users[1]],
);
ranked = (await q("select public.player_leaderboard() value"))[0].value;
assert.equal(ranked.entries[0].player_id, users[1]);
assert.equal(ranked.entries[0].tournament_champion.tournament_id, newer);
assert.equal(
ranked.entries.filter((p) => p.tournament_champion !== null).length,
1,
);
await q(
"update public.tournaments set finished_at=clock_timestamp()-interval '8 days' where id=$1",
[tid],
);
await q(
"update public.tournaments set finished_at=clock_timestamp()-interval '8 days' where id=$1",
[newer],
);
ranked = (await q("select public.player_leaderboard() value"))[0].value;
assert.equal(ranked.entries[0].player_id, users[2]);
assert(ranked.entries.every((p) => p.tournament_champion === null));
for (const role of ["anon", "authenticated"])
for (const table of [
"tournaments",
"tournament_members",
"tournament_rounds",
"tournament_announcements",
])
assert.equal(
(
await q("select has_table_privilege($1,$2,'SELECT') allowed", [
role,
`public.${table}`,
])
)[0].allowed,
false,
);
for (const role of ["anon", "authenticated"])
assert.equal(
(
await q(
"select has_function_privilege($1,'public.tournament_action(uuid,uuid,text,jsonb)','EXECUTE') allowed",
[role],
)
)[0].allowed,
false,
);
for (const fn of await q(
"select proname,prosecdef,proconfig from pg_catalog.pg_proc p join pg_catalog.pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and (p.proname like '%tournament%' or p.proname='player_leaderboard')",
)) {
assert.equal(fn.prosecdef, false, fn.proname);
assert(
fn.proconfig.some((setting) => setting.startsWith("search_path=")),
fn.proname,
);
}
const alone = await create({ announceDiscord: false });
await q(
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
[alone],
);
assert.equal((await act(alone, users[0])).status, "cancelled");
// A shared round clock starts after loading and caps subsequent mini rounds.
const shared = await create({
timerMode: "shared",
roundCount: 1,
roundTimeSeconds: 90,
announceDiscord: false,
});
await act(shared, users[1], "join");
await q(
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
[shared],
);
let sharedState = await act(shared, users[0]);
assert.equal(sharedState.timerMode, "shared");
assert.equal(sharedState.roundDeadline, null);
await act(shared, users[0], "ready", { round: 0, tournamentRound: 0 });
sharedState = await act(shared, users[1], "ready", {
round: 0,
tournamentRound: 0,
});
const sharedDeadline = sharedState.roundDeadline;
assert(sharedDeadline);
assert.equal(sharedState.currentRoom.status, "playing");
const firstAnswer = (
await q(
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
[sharedState.roomId],
)
)[0].viewpoint;
for (const user of users.slice(0, 2))
await act(shared, user, "guess", {
round: 0,
tournamentRound: 0,
x: firstAnswer.x,
z: firstAnswer.z,
});
await q(
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
[sharedState.roomId],
);
await act(shared, users[0], "ready", { round: 1, tournamentRound: 0 });
sharedState = await act(shared, users[1], "ready", {
round: 1,
tournamentRound: 0,
});
assert.equal(sharedState.roundDeadline, sharedDeadline);
assert.equal(sharedState.currentRoom.rounds[1].deadline, sharedDeadline);
await q(
"update public.tournament_rounds set deadline=clock_timestamp()-interval '1 second' where tournament_id=$1",
[shared],
);
await assert.rejects(
act(shared, users[0], "guess", {
round: 1,
tournamentRound: 0,
x: 64,
z: 64,
score: 999999,
}),
);
// An invalid action rolls back its transaction, so a normal poll settles it.
sharedState = await act(shared, users[0]);
assert.equal(sharedState.status, "finished");
assert.equal(sharedState.players[0].total, 5000);
assert.equal(
sharedState.currentRoom.rounds.filter((r) => r.closedAt !== null).length,
5,
);
assert(
sharedState.currentRoom.rounds
.slice(1)
.every((r) =>
r.results.every((result) => result.score === 0 && result.timedOut),
),
);
await assert.rejects(create({ timerMode: "perMini", roundTimeSeconds: 615 }));
await assert.rejects(create({ timerMode: "shared", roundTimeSeconds: 3015 }));
const bigShared = await create({
timerMode: "shared",
roundTimeSeconds: 3000,
announceDiscord: false,
});
assert.equal(
(await act(bigShared, users[0])).difficultySettings.seconds,
600,
);
await act(bigShared, users[1], "join");
await q(
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
[bigShared],
);
await act(bigShared, users[0], "ready", { round: 0, tournamentRound: 0 });
let bigState = await act(bigShared, users[1], "ready", {
round: 0,
tournamentRound: 0,
});
const bigDeadline = bigState.roundDeadline;
assert(bigDeadline);
assert.equal(bigState.currentRoom.rounds[0].deadline, bigDeadline);
assert(Date.parse(bigDeadline) - bigState.serverNow > 2990 * 1000);
// The ordinary engine's 600-second mini timer must never cut a shared budget short.
await q(
"update public.multiplayer_rounds set deadline=clock_timestamp()-interval '1 second' where room_id=$1 and round_number=0",
[bigState.roomId],
);
bigState = await act(bigShared, users[0]);
assert.equal(bigState.currentRoom.status, "playing");
assert.equal(bigState.roundDeadline, bigDeadline);
assert.equal(bigState.currentRoom.rounds[0].deadline, bigDeadline);
for (const user of users.slice(0, 2))
await act(bigShared, user, "guess", {
round: 0,
tournamentRound: 0,
x: 10,
z: 10,
});
await q(
"update public.multiplayer_rooms set transition_at=clock_timestamp()-interval '1 second' where id=$1",
[bigState.roomId],
);
await act(bigShared, users[0], "ready", { round: 1, tournamentRound: 0 });
bigState = await act(bigShared, users[1], "ready", {
round: 1,
tournamentRound: 0,
});
assert.equal(bigState.roundDeadline, bigDeadline);
assert.equal(bigState.currentRoom.rounds[1].deadline, bigDeadline);
const moderated = await create({ announceDiscord: false });
await act(moderated, users[1], "join");
await q("update public.datasets set state='draft' where id=$1", [map]);
assert.equal((await act(moderated, users[0])).status, "cancelled");
await q("update public.datasets set state='published' where id=$1", [map]);
const abandoned = await create({ announceDiscord: false });
await act(abandoned, users[1], "join");
await q(
"update public.tournaments set starts_at=clock_timestamp()-interval '1 second' where id=$1",
[abandoned],
);
await act(abandoned, users[0]);
await q(
"update public.profiles set account_status='banned' where id=any($1::uuid[])",
[users.slice(0, 2)],
);
assert.equal((await act(abandoned, null)).status, "cancelled");
await assert.rejects(create(), /Active account/);
await q(
"update public.profiles set account_status='active' where id=any($1::uuid[])",
[users.slice(0, 2)],
);
// Account removal must preserve event history instead of blocking Auth deletion.
await q("delete from auth.users where id=$1", [users[0]]);
assert.equal((await act(tid, null)).ownerId, null);
console.log(
"Tournament SQL passed: 32-player cap, admission/auth, private rooms, 2×5 mini-rounds, shared/per-mini clocks, server scores, schedules/outbox leases, immutable results, seven-day champion and account deletion.",
);
} catch (error) {
console.error(
error.message,
error.where ?? "",
error.stack?.split("\n").slice(0, 3).join("\n"),
);
process.exitCode = 1;
} finally {
await db.close();
}