From 425b0e0805e194e1863579b3b13e9b3fdab41069 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Mon, 19 Oct 2026 10:16:23 +0500 Subject: [PATCH] Test and document shared tournament deadlines --- docs/tournaments.md | 13 ++++- scripts/test-tournaments-sql.mjs | 41 +++++++++++++++ ...61019044542_shared_tournament_deadline.sql | 50 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20261019044542_shared_tournament_deadline.sql diff --git a/docs/tournaments.md b/docs/tournaments.md index b3c15a2..f16a8a3 100644 --- a/docs/tournaments.md +++ b/docs/tournaments.md @@ -26,8 +26,9 @@ Delivery uses two-minute database leases, a stable Discord nonce, bounded reques Apply the checked-in migrations before deploying the application code, in filename order. The relevant additions are: -1. `supabase/migrations/20261018060642_extended_difficulty_rules.sql` -2. `supabase/migrations/20261018060844_tournaments.sql` +1. `supabase/migrations/20261018075331_extended_difficulty_rules.sql` +2. `supabase/migrations/20261018075348_tournaments.sql` +3. `supabase/migrations/20261019044542_shared_tournament_deadline.sql` Use the project's normal controlled database migration process. If applying these through the Supabase SQL editor, run each complete file once, in that order, after the earlier project migrations. These files are schema changes, not application `.env` settings. Do not clear production data to repair a missing table. If PostgREST reports a stale schema cache after a successful migration, reload its schema cache with `NOTIFY pgrst, 'reload schema';`. @@ -42,3 +43,11 @@ A missing migration or database connection pauses scheduling; the worker retries `npm test` includes tournament schema-validation, API, timer, and announcement tests. `npm run typecheck -- --incremental false` checks the server/client contracts. `node scripts/test-tournaments-sql.mjs` runs SQL integration tests against an isolated in-memory PostgreSQL-compatible fixture and requires the local test dependency at `.data/sql-verification/node_modules/@electric-sql/pglite`. The SQL suite covers the 32-player limit, locked owner actions, registration, shared game engine, standings, announcement leases, and restricted direct database access. Announcement unit tests use fake Discord clients; they do not publish messages. Apply production migrations and verify a real opted-in event deliberately after deployment; no external test announcement is required during local development. + +## 1.0.4.1 bug fix — October 19, 2026 + +Shared budgets above 600 seconds previously inherited the ordinary engine's shorter mini-round deadline. The shared-deadline migration makes each active mini-round use the persisted tournament-round deadline, including after transitions. Polling does not extend that deadline. Separate mini-round timers retain their existing behavior. + +The migration was applied to the configured MineGeo production database. A production query confirmed the corrected function, invoker security, and server-only execution permissions. Regression coverage fails against the original function and passes with the correction. The complete tournament SQL suite, 179 unit tests, TypeScript check, and production HTTP/API/Storage smoke test passed; the smoke test completed and cleaned up a temporary five-round guest game. No application code changed, so this fix requires no web deployment or restart. + +The [Discord bug-fix update](https://discord.com/channels/1558039087980486728/1558048830770122772/1561601584360722495) was posted with only the two requested role mentions. It contains no event announcement. diff --git a/scripts/test-tournaments-sql.mjs b/scripts/test-tournaments-sql.mjs index a25cc10..46102c9 100644 --- a/scripts/test-tournaments-sql.mjs +++ b/scripts/test-tournaments-sql.mjs @@ -478,6 +478,47 @@ try { (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]); diff --git a/supabase/migrations/20261019044542_shared_tournament_deadline.sql b/supabase/migrations/20261019044542_shared_tournament_deadline.sql new file mode 100644 index 0000000..802cedf --- /dev/null +++ b/supabase/migrations/20261019044542_shared_tournament_deadline.sql @@ -0,0 +1,50 @@ +-- Every playable mini-round uses the remaining shared tournament budget. +-- The regular room engine caps its individual timer at 600 seconds; keeping +-- that deadline would cut a longer shared budget short. Reuse the persisted +-- budget so polling and transitions never extend the tournament round. +create or replace function public.tournament_room_tick(p_tournament uuid,p_room uuid) returns void +language plpgsql security invoker set search_path='' as $$ +declare t public.tournaments; room public.multiplayer_rooms; budget timestamptz; first_deadline timestamptz; i integer; +begin + select * into t from public.tournaments where id=p_tournament; + if t.timer_mode='perMini' then perform public.multiplayer_tick(p_room); return; end if; + select deadline into budget from public.tournament_rounds where room_id=p_room; + if budget is null then + select deadline into first_deadline from public.multiplayer_rounds where room_id=p_room and round_number=0; + if first_deadline is not null then + budget:=first_deadline-make_interval(secs=>(t.difficulty_settings->>'seconds')::integer)+make_interval(secs=>t.round_time_seconds); + update public.tournament_rounds set deadline=budget where room_id=p_room; + end if; + end if; + if budget is not null and budget<=clock_timestamp() then + -- Finish every remaining mini round now, without results/loading delays. + for i in 0..4 loop + select * into room from public.multiplayer_rooms where id=p_room for update; + exit when room.status in('finished','closed'); + if room.status='results' then + update public.multiplayer_rooms set current_round=current_round+1,status='playing',transition_at=null where id=p_room; + else update public.multiplayer_rooms set status='playing',transition_at=null where id=p_room; end if; + update public.multiplayer_rounds mr set deadline=budget where mr.room_id=p_room and mr.round_number=(select current_round from public.multiplayer_rooms where id=p_room); + perform public.multiplayer_tick(p_room); + end loop; + return; + end if; + if budget is not null then + update public.multiplayer_rounds mr set deadline=budget + where mr.room_id=p_room and mr.round_number=(select current_round from public.multiplayer_rooms where id=p_room) and mr.deadline is not null; + end if; + perform public.multiplayer_tick(p_room); + if budget is null then + select deadline into first_deadline from public.multiplayer_rounds where room_id=p_room and round_number=0; + if first_deadline is not null then + budget:=first_deadline-make_interval(secs=>(t.difficulty_settings->>'seconds')::integer)+make_interval(secs=>t.round_time_seconds); + update public.tournament_rounds set deadline=budget where room_id=p_room; + end if; + end if; + if budget is not null then + update public.multiplayer_rounds mr set deadline=budget + where mr.room_id=p_room and mr.round_number=(select current_round from public.multiplayer_rooms where id=p_room) and mr.deadline is not null; + end if; +end; $$; +notify pgrst,'reload schema'; +