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:
@@ -18,6 +18,8 @@ await mkdir(work, { recursive: true });
|
||||
const routes = new Map([
|
||||
["/", "cinematics/index.html"],
|
||||
["/cinematics/scene.mjs", "cinematics/scene.mjs"],
|
||||
["/cinematics/composition.mjs", "cinematics/composition.mjs"],
|
||||
["/cinematics/motion.mjs", "cinematics/motion.mjs"],
|
||||
["/three/three.module.js", "node_modules/three/build/three.module.js"],
|
||||
["/three/three.core.js", "node_modules/three/build/three.core.js"],
|
||||
["/fonts/Monocraft.ttf", "public/fonts/Monocraft.ttf"],
|
||||
@@ -134,7 +136,7 @@ if (previewOnly) {
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-framerate",
|
||||
"24",
|
||||
"30",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-i",
|
||||
@@ -168,14 +170,17 @@ if (previewOnly) {
|
||||
encoderError = e;
|
||||
});
|
||||
const done = once(ffmpeg, "close");
|
||||
for (let frame = 0; frame < 720; frame++) {
|
||||
for (let frame = 0; frame < 900; frame++) {
|
||||
if (encoderError) throw encoderError;
|
||||
await page.evaluate((t) => window.renderCinematic(t), frame / 24);
|
||||
const png = await page.screenshot({ type: "png" });
|
||||
const encoded = await page.evaluate((t) => {
|
||||
window.renderCinematic(t);
|
||||
return window.cinematicCanvas.toDataURL("image/png").split(",")[1];
|
||||
}, frame / 30);
|
||||
const png = Buffer.from(encoded, "base64");
|
||||
if (!ffmpeg.stdin.write(png)) await once(ffmpeg.stdin, "drain");
|
||||
if (frame % 48 === 0)
|
||||
if (frame % 60 === 0)
|
||||
console.log(
|
||||
`Trailer: ${frame}/720 frames (${Math.round((frame / 720) * 100)}%)`,
|
||||
`Trailer: ${frame}/900 frames (${Math.round((frame / 900) * 100)}%)`,
|
||||
);
|
||||
}
|
||||
ffmpeg.stdin.end();
|
||||
@@ -251,7 +256,7 @@ function createSoundtrack() {
|
||||
Math.sin(2 * Math.PI * 55 * beat - 7 * Math.exp(-beat * 25)) *
|
||||
Math.exp(-beat * 12) *
|
||||
0.045;
|
||||
const swell = [4, 9, 14, 19, 24].reduce(
|
||||
const swell = [3, 8, 13, 18, 23].reduce(
|
||||
(n, c) => n + Math.exp(-(((t - c) / 0.45) ** 2)),
|
||||
0,
|
||||
);
|
||||
@@ -276,28 +281,28 @@ function createSoundtrack() {
|
||||
function createCaptions() {
|
||||
return `WEBVTT
|
||||
|
||||
00:00.000 --> 00:04.000
|
||||
00:00.000 --> 00:03.000
|
||||
[Ambient electronic music. A floating voxel island emerges.]
|
||||
Every world has a place. But can you find yours?
|
||||
Get lost. Find your world.
|
||||
|
||||
00:04.000 --> 00:09.000
|
||||
00:03.000 --> 00:08.000
|
||||
[Screenshot of the real MineGeo homepage.]
|
||||
Your expedition starts here. A new way to see Minecraft.
|
||||
Your expedition starts here. A Minecraft guessing game. A new way to explore.
|
||||
|
||||
00:09.000 --> 00:14.000
|
||||
00:08.000 --> 00:13.000
|
||||
[Screenshot of the community map catalog.]
|
||||
Pick your world. Browse published maps from the community.
|
||||
|
||||
00:14.000 --> 00:19.000
|
||||
00:13.000 --> 00:18.000
|
||||
[Real in-game panorama with a pin selected on the map.]
|
||||
Look. Guess. Discover. Look around, then place your pin.
|
||||
|
||||
00:19.000 --> 00:24.000
|
||||
00:18.000 --> 00:23.000
|
||||
[Screenshot of map details and difficulty options.]
|
||||
Make it your expedition. Choose your map, difficulty, and game mode.
|
||||
|
||||
00:24.000 --> 00:30.000
|
||||
MineGeo. Get lost in a familiar world.
|
||||
00:23.000 --> 00:30.000
|
||||
MineGeo. Get lost. Find your world.
|
||||
Your next adventure starts at minegeo.site.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ try {
|
||||
)
|
||||
).rows[0].id;
|
||||
const manifest = {
|
||||
bounds: { minX: 0, minZ: 0, maxX: 64, maxZ: 64 },
|
||||
viewpoints: Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
x: i,
|
||||
@@ -163,8 +164,174 @@ try {
|
||||
[id],
|
||||
)
|
||||
).rows[0].difficulty_settings,
|
||||
{ id: preset, name: "Playable preset 45", seconds: 45, zoom: false },
|
||||
{
|
||||
id: preset,
|
||||
name: "Playable preset 45",
|
||||
seconds: 45,
|
||||
zoom: false,
|
||||
pan: true,
|
||||
compass: true,
|
||||
coordinates: true,
|
||||
mapZoom: true,
|
||||
scoring: "normal",
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query(
|
||||
"update public.difficulty_presets set scoring='unsupported' where id=$1",
|
||||
[preset],
|
||||
),
|
||||
(error) => error.code === "23514",
|
||||
);
|
||||
await db.query(
|
||||
"update public.difficulty_presets set pan=false,compass=false,coordinates=false,map_zoom=false,scoring='strict' where id=$1",
|
||||
[preset],
|
||||
);
|
||||
const settings = (
|
||||
await db.query("select public.resolve_difficulty_preset($1) settings", [
|
||||
preset,
|
||||
])
|
||||
).rows[0].settings;
|
||||
assert.deepEqual(settings, {
|
||||
id: preset,
|
||||
name: "Playable preset 45",
|
||||
seconds: 45,
|
||||
zoom: false,
|
||||
pan: false,
|
||||
compass: false,
|
||||
coordinates: false,
|
||||
mapZoom: false,
|
||||
scoring: "strict",
|
||||
});
|
||||
// Existing sessions retain the original curve even after the library changes.
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select difficulty_settings->>'scoring' scoring from public.game_sessions where id=$1",
|
||||
[solo],
|
||||
)
|
||||
).rows[0].scoring,
|
||||
"normal",
|
||||
);
|
||||
for (const scoring of ["strict", "normal", "gentle"]) {
|
||||
await db.query(
|
||||
"update public.difficulty_presets set scoring=$1 where id=$2",
|
||||
[scoring, preset],
|
||||
);
|
||||
const sid = (
|
||||
await db.query("select public.create_game($1,$2,$3,$4) id", [
|
||||
map,
|
||||
preset,
|
||||
owner,
|
||||
"score-" + scoring,
|
||||
])
|
||||
).rows[0].id;
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select count(*)::int total from public.game_rounds where session_id=$1",
|
||||
[sid],
|
||||
)
|
||||
).rows[0].total,
|
||||
5,
|
||||
);
|
||||
await db.query("select public.activate_round($1,0,$2)", [
|
||||
sid,
|
||||
"score-" + scoring,
|
||||
]);
|
||||
const point = (
|
||||
await db.query(
|
||||
"select viewpoint from public.game_rounds where session_id=$1 and round_number=0",
|
||||
[sid],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
const expected = (
|
||||
await db.query("select public.score_difficulty_guess($1,$2,$3) points", [
|
||||
Math.hypot(32 - point.x, 32 - point.z),
|
||||
Math.hypot(64, 64),
|
||||
scoring,
|
||||
])
|
||||
).rows[0].points;
|
||||
const actual = (
|
||||
await db.query("select public.submit_guess($1,0,$2,32,32) result", [
|
||||
sid,
|
||||
"score-" + scoring,
|
||||
])
|
||||
).rows[0].result;
|
||||
assert.equal(actual.score, expected);
|
||||
const room = (
|
||||
await db.query(
|
||||
"select public.create_map_room($1,$2,'Scoring host',2,$3,$4) id",
|
||||
[map, preset, "a".repeat(64), owner],
|
||||
)
|
||||
).rows[0].id;
|
||||
await db.query(
|
||||
"select public.multiplayer_action($1,$2,'join','{\"name\":\"Guest\"}')",
|
||||
[room, "c".repeat(64)],
|
||||
);
|
||||
await db.query("select public.multiplayer_action($1,$2,'start')", [
|
||||
room,
|
||||
"a".repeat(64),
|
||||
]);
|
||||
await db.query(
|
||||
"select public.multiplayer_action($1,$2,'ready','{\"round\":0}')",
|
||||
[room, "a".repeat(64)],
|
||||
);
|
||||
await db.query(
|
||||
"select public.multiplayer_action($1,$2,'ready','{\"round\":0}')",
|
||||
[room, "c".repeat(64)],
|
||||
);
|
||||
const mp = (
|
||||
await db.query(
|
||||
"select viewpoint from public.multiplayer_rounds where room_id=$1 and round_number=0",
|
||||
[room],
|
||||
)
|
||||
).rows[0].viewpoint;
|
||||
await db.query(
|
||||
'select public.multiplayer_action($1,$2,\'guess\',\'{"round":0,"x":32,"z":32}\')',
|
||||
[room, "a".repeat(64)],
|
||||
);
|
||||
const scored = (
|
||||
await db.query(
|
||||
"select result from public.multiplayer_guesses where room_id=$1 and round_number=0",
|
||||
[room],
|
||||
)
|
||||
).rows[0].result;
|
||||
const expectedMulti = (
|
||||
await db.query("select public.score_difficulty_guess($1,$2,$3) points", [
|
||||
Math.hypot(32 - mp.x, 32 - mp.z),
|
||||
Math.hypot(64, 64),
|
||||
scoring,
|
||||
])
|
||||
).rows[0].points;
|
||||
assert.equal(scored.score, expectedMulti);
|
||||
}
|
||||
const decay = (
|
||||
await db.query(
|
||||
"select public.score_difficulty_guess(20,100,'strict') strict,public.score_difficulty_guess(20,100,'normal') normal,public.score_difficulty_guess(20,100,'gentle') gentle",
|
||||
)
|
||||
).rows[0];
|
||||
assert.ok(decay.strict < decay.normal && decay.normal < decay.gentle);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select public.score_difficulty_guess(0,100,'strict') points",
|
||||
)
|
||||
).rows[0].points,
|
||||
5000,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.score_difficulty_guess(1,100,'unknown')"),
|
||||
/Invalid scoring rules/,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
"select has_function_privilege('anon','public.score_difficulty_guess(double precision,double precision,text)','EXECUTE') allowed",
|
||||
)
|
||||
).rows[0].allowed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await db.query(
|
||||
|
||||
@@ -8,7 +8,10 @@ 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);`);
|
||||
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((file) => file.endsWith(".sql"))
|
||||
.sort()) {
|
||||
@@ -63,7 +66,7 @@ try {
|
||||
) {
|
||||
const session = (
|
||||
await db.query(
|
||||
"insert into public.game_sessions(revision_id,owner_id,access_hash,difficulty,current_round) values($1,$2,'private-fixture-secret',$3,$4) returning id",
|
||||
"insert into public.game_sessions(revision_id,owner_id,access_hash,difficulty,difficulty_settings,current_round) values($1,$2,'private-fixture-secret',$3,public.resolve_difficulty_preset($3),$4) returning id",
|
||||
[revision, owner, difficulty, current],
|
||||
)
|
||||
).rows[0].id;
|
||||
|
||||
@@ -9,7 +9,10 @@ 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);`);
|
||||
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((file) => file.endsWith(".sql"))
|
||||
.sort())
|
||||
@@ -50,8 +53,8 @@ try {
|
||||
async function create(cap = 2) {
|
||||
return (
|
||||
await db.query(
|
||||
"select public.create_multiplayer_room($1,'hard','Host',$2,$3) as id",
|
||||
[dataset, cap, secret(1)],
|
||||
"select public.create_map_room($1,'hard','Host',$2,$3,$4) as id",
|
||||
[dataset, cap, secret(1), account],
|
||||
)
|
||||
).rows[0].id;
|
||||
}
|
||||
@@ -166,6 +169,10 @@ try {
|
||||
console.log(
|
||||
"Multiplayer SQL account identity: forged names rejected, guest joins retained, authenticated transfer and guest-only closure verified.",
|
||||
);
|
||||
// Subsequent match fixtures use the same authenticated account as their host.
|
||||
await db.query("update public.profiles set display_name='Host' where id=$1", [
|
||||
account,
|
||||
]);
|
||||
for (const cap of [1, 17, null])
|
||||
await assert.rejects(create(cap), /Choose between 2 and 16 players/);
|
||||
const room = await create();
|
||||
|
||||
518
scripts/test-tournaments-sql.mjs
Normal file
518
scripts/test-tournaments-sql.mjs
Normal file
@@ -0,0 +1,518 @@
|
||||
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 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,
|
||||
);
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user