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:
145
supabase/migrations/20261018060642_extended_difficulty_rules.sql
Normal file
145
supabase/migrations/20261018060642_extended_difficulty_rules.sql
Normal file
@@ -0,0 +1,145 @@
|
||||
-- Preserve the original defaults and snapshot the expanded rules per game.
|
||||
alter table public.difficulty_presets
|
||||
add column pan boolean not null default true,
|
||||
add column compass boolean not null default true,
|
||||
add column coordinates boolean not null default true,
|
||||
add column map_zoom boolean not null default true,
|
||||
add column scoring text not null default 'normal' check (scoring in ('normal','strict','gentle'));
|
||||
|
||||
update public.game_sessions set difficulty_settings =
|
||||
'{"pan":true,"compass":true,"coordinates":true,"mapZoom":true,"scoring":"normal"}'::jsonb || difficulty_settings;
|
||||
update public.multiplayer_rooms set difficulty_settings =
|
||||
'{"pan":true,"compass":true,"coordinates":true,"mapZoom":true,"scoring":"normal"}'::jsonb || difficulty_settings;
|
||||
|
||||
create or replace function public.resolve_difficulty_preset(p_id text)
|
||||
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
|
||||
declare preset jsonb;
|
||||
begin
|
||||
if p_id in ('easy','normal','hard') then
|
||||
return jsonb_build_object('id',p_id,'name',initcap(p_id),
|
||||
'seconds',case p_id when 'easy' then 180 when 'hard' then 30 else 90 end,
|
||||
'zoom',p_id <> 'hard','pan',true,'compass',true,'coordinates',true,'mapZoom',true,'scoring','normal');
|
||||
end if;
|
||||
select jsonb_build_object('id',p.id::text,'name',p.name,'seconds',p.seconds,'zoom',p.zoom,
|
||||
'pan',p.pan,'compass',p.compass,'coordinates',p.coordinates,'mapZoom',p.map_zoom,'scoring',p.scoring)
|
||||
into preset from public.difficulty_presets p where p.id::text=lower(p_id);
|
||||
if not found then raise exception 'Choose a saved difficulty'; end if;
|
||||
return preset;
|
||||
end; $$;
|
||||
|
||||
-- Shared server-side curve used by solo, multiplayer, and tournament scoring.
|
||||
create function public.score_difficulty_guess(p_distance double precision,p_diagonal double precision,p_scoring text default 'normal')
|
||||
returns integer language plpgsql immutable security invoker set search_path = '' as $$
|
||||
begin
|
||||
if p_scoring is null or p_scoring not in ('normal','strict','gentle') then raise exception 'Invalid scoring rules'; end if;
|
||||
if p_distance is null or p_diagonal is null or p_distance < 0 or p_diagonal <= 0
|
||||
or p_distance in ('NaN'::float8,'Infinity'::float8,'-Infinity'::float8)
|
||||
or p_diagonal in ('NaN'::float8,'Infinity'::float8,'-Infinity'::float8)
|
||||
then raise exception 'Invalid scoring distance'; end if;
|
||||
if p_distance <= 1 then return 5000; end if;
|
||||
return round(5000 * exp(-(case p_scoring when 'strict' then 8 when 'gentle' then 3 else 5 end) * p_distance / p_diagonal))::integer;
|
||||
end; $$;
|
||||
revoke all on function public.score_difficulty_guess(double precision,double precision,text) from public,anon,authenticated;
|
||||
grant execute on function public.score_difficulty_guess(double precision,double precision,text) to service_role;
|
||||
|
||||
create or replace function public.submit_guess(p_session uuid,p_round integer,p_access_hash text,p_x double precision default null,p_z double precision default null) returns jsonb language plpgsql security invoker set search_path='' as $$
|
||||
declare s public.game_sessions; r public.game_rounds; manifest jsonb; b jsonb; d double precision; diagonal double precision; points integer:=0; timed_out boolean; answer jsonb;
|
||||
begin
|
||||
select * into s from public.game_sessions where id=p_session and access_hash=p_access_hash for update;
|
||||
if not found then raise exception 'Session not found'; end if;
|
||||
select * into r from public.game_rounds where session_id=p_session and round_number=p_round for update;
|
||||
if r.result is not null then return r.result; end if;
|
||||
if s.current_round<>p_round or r.deadline is null then raise exception 'Round is not active'; end if;
|
||||
select revisions.manifest into manifest from public.revisions where id=s.revision_id; b:=manifest->'bounds';
|
||||
timed_out:=clock_timestamp()>=r.deadline;
|
||||
if not timed_out and (p_x is null or p_z is null) then raise exception 'Place a guess first'; end if;
|
||||
if not timed_out then
|
||||
if p_x='NaN'::float8 or p_z='NaN'::float8 or p_x<(b->>'minX')::float8 or p_x>(b->>'maxX')::float8 or p_z<(b->>'minZ')::float8 or p_z>(b->>'maxZ')::float8 then raise exception 'Guess outside map'; end if;
|
||||
d:=sqrt(power(p_x-(r.viewpoint->>'x')::float8,2)+power(p_z-(r.viewpoint->>'z')::float8,2));
|
||||
diagonal:=sqrt(power((b->>'maxX')::float8-(b->>'minX')::float8,2)+power((b->>'maxZ')::float8-(b->>'minZ')::float8,2));
|
||||
points:=public.score_difficulty_guess(d,diagonal,coalesce(s.difficulty_settings->>'scoring','normal'));
|
||||
end if;
|
||||
answer:=jsonb_build_object('score',points,'distance',d,'timedOut',timed_out,'actual',jsonb_build_object('x',r.viewpoint->'x','z',r.viewpoint->'z'),'guess',case when timed_out then null else jsonb_build_object('x',p_x,'z',p_z) end);
|
||||
update public.game_rounds set result=answer where session_id=p_session and round_number=p_round;
|
||||
update public.game_sessions set current_round=current_round+1 where id=p_session;
|
||||
return answer;
|
||||
end; $$;
|
||||
|
||||
create or replace function public.multiplayer_action(p_room uuid,p_access_hash text,p_action text,p_payload jsonb default '{}')
|
||||
returns jsonb language plpgsql security invoker set search_path='' as $$
|
||||
declare room public.multiplayer_rooms; player public.multiplayer_players; turn public.multiplayer_rounds;
|
||||
target uuid; n integer; px double precision; pz double precision; distance double precision;
|
||||
diagonal double precision; points integer; bounds jsonb; answer jsonb; active integer;
|
||||
begin
|
||||
select * into room from public.multiplayer_rooms where id=p_room for update;
|
||||
if not found then raise exception 'Room not found'; end if;
|
||||
select * into player from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash;
|
||||
if p_action='join' then
|
||||
if room.status='closed' then raise exception 'This room is closed'; end if;
|
||||
if player.id is not null and not player.has_left then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
|
||||
if room.status<>'lobby' then raise exception 'This match has already started'; end if;
|
||||
select count(*) into active from public.multiplayer_players where room_id=p_room and not has_left;
|
||||
if active>=room.player_limit then raise exception 'This room is full'; end if;
|
||||
if exists(select 1 from public.multiplayer_players where room_id=p_room and lower(display_name)=lower(btrim(p_payload->>'name'))) then
|
||||
raise exception 'This name is already taken in this room';
|
||||
end if;
|
||||
insert into public.multiplayer_players(room_id,access_hash,display_name) values(p_room,p_access_hash,btrim(p_payload->>'name'));
|
||||
return public.multiplayer_snapshot(p_room,p_access_hash);
|
||||
end if;
|
||||
if player.id is null or player.has_left then
|
||||
if p_action='view' then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
|
||||
raise exception 'Room membership permission required';
|
||||
end if;
|
||||
perform public.multiplayer_tick(p_room);
|
||||
select * into room from public.multiplayer_rooms where id=p_room;
|
||||
if p_action in ('start','next','kick','close') and room.host_player_id<>player.id then raise exception 'Room owner permission required'; end if;
|
||||
if p_action='start' then
|
||||
if room.status<>'lobby' then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
|
||||
if (select count(*) from public.multiplayer_players where room_id=p_room and not has_left)<2 then raise exception 'Invite at least one friend before starting'; end if;
|
||||
update public.multiplayer_rooms set status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room;
|
||||
elsif p_action='ready' then
|
||||
n := (p_payload->>'round')::integer;
|
||||
if room.status='loading' and n=room.current_round then update public.multiplayer_players set ready_round=n where id=player.id; end if;
|
||||
elsif p_action='guess' then
|
||||
n := (p_payload->>'round')::integer;
|
||||
if exists(select 1 from public.multiplayer_guesses where room_id=p_room and player_id=player.id and round_number=n) then
|
||||
return public.multiplayer_snapshot(p_room,p_access_hash);
|
||||
end if;
|
||||
if room.status<>'playing' or n is distinct from room.current_round then raise exception 'Round is not active'; end if;
|
||||
select * into turn from public.multiplayer_rounds where room_id=p_room and round_number=n;
|
||||
px := (p_payload->>'x')::double precision; pz := (p_payload->>'z')::double precision;
|
||||
if px is null or pz is null then raise exception 'Place a guess first'; end if;
|
||||
select manifest->'bounds' into bounds from public.revisions where id=room.revision_id;
|
||||
if px='NaN'::float8 or pz='NaN'::float8 or px<(bounds->>'minX')::float8 or px>(bounds->>'maxX')::float8 or pz<(bounds->>'minZ')::float8 or pz>(bounds->>'maxZ')::float8 then raise exception 'Guess outside map'; end if;
|
||||
-- Check again after validation: a request arriving at the deadline scores zero.
|
||||
if turn.deadline<=clock_timestamp() then
|
||||
perform public.multiplayer_tick(p_room); return public.multiplayer_snapshot(p_room,p_access_hash);
|
||||
end if;
|
||||
distance := sqrt(power(px-(turn.viewpoint->>'x')::float8,2)+power(pz-(turn.viewpoint->>'z')::float8,2));
|
||||
diagonal := sqrt(power((bounds->>'maxX')::float8-(bounds->>'minX')::float8,2)+power((bounds->>'maxZ')::float8-(bounds->>'minZ')::float8,2));
|
||||
points := public.score_difficulty_guess(distance,diagonal,coalesce(room.difficulty_settings->>'scoring','normal'));
|
||||
answer := jsonb_build_object('score',points,'distance',distance,'timedOut',false,'guess',jsonb_build_object('x',px,'z',pz),'actual',jsonb_build_object('x',turn.viewpoint->'x','z',turn.viewpoint->'z'));
|
||||
insert into public.multiplayer_guesses(room_id,player_id,round_number,result) values(p_room,player.id,n,answer);
|
||||
elsif p_action='next' then
|
||||
n := (p_payload->>'round')::integer;
|
||||
if n<room.current_round then return public.multiplayer_snapshot(p_room,p_access_hash); end if;
|
||||
if room.status<>'results' or n is distinct from room.current_round then raise exception 'Wait for the round to finish'; end if;
|
||||
update public.multiplayer_rooms set current_round=current_round+1,status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room;
|
||||
elsif p_action='kick' then
|
||||
if room.status<>'lobby' then raise exception 'Players can only be removed before the match starts'; end if;
|
||||
target := (p_payload->>'playerId')::uuid;
|
||||
if target=room.host_player_id then raise exception 'The owner cannot remove themselves'; end if;
|
||||
delete from public.multiplayer_players where room_id=p_room and id=target;
|
||||
elsif p_action='leave' then
|
||||
if room.status='lobby' then delete from public.multiplayer_players where id=player.id;
|
||||
else update public.multiplayer_players set has_left=true where id=player.id; end if;
|
||||
select id into target from public.multiplayer_players where room_id=p_room and not has_left order by joined_at,id limit 1;
|
||||
if target is null then update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room;
|
||||
elsif room.host_player_id=player.id then update public.multiplayer_rooms set host_player_id=target where id=p_room; end if;
|
||||
elsif p_action='close' then
|
||||
update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room;
|
||||
elsif p_action<>'view' then raise exception 'Invalid room action';
|
||||
end if;
|
||||
perform public.multiplayer_tick(p_room);
|
||||
return public.multiplayer_snapshot(p_room,p_access_hash);
|
||||
end; $$;
|
||||
394
supabase/migrations/20261018060844_tournaments.sql
Normal file
394
supabase/migrations/20261018060844_tournaments.sql
Normal file
@@ -0,0 +1,394 @@
|
||||
-- Tournament writes and secrets are only available to the trusted server.
|
||||
create table public.tournaments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null check(length(name) between 3 and 80 and name=btrim(name) and name !~ '[[:cntrl:]]' and name !~ U&'[\202A-\202E\2066-\2069]'),
|
||||
owner_id uuid references public.profiles(id) on delete set null,
|
||||
revision_id uuid not null references public.revisions(id),
|
||||
starts_at timestamptz not null, server_timezone text not null,
|
||||
player_limit integer not null check(player_limit between 2 and 32),
|
||||
round_count integer not null check(round_count between 1 and 10),
|
||||
round_time_seconds integer not null check(round_time_seconds between 15 and 3000 and round_time_seconds%15=0),
|
||||
timer_mode text not null default 'shared' check(timer_mode in('shared','perMini')),
|
||||
constraint tournaments_per_mini_time check(timer_mode<>'perMini' or round_time_seconds<=600),
|
||||
difficulty text not null, difficulty_settings jsonb not null,
|
||||
announce_discord boolean not null default false,
|
||||
status text not null default 'scheduled' check(status in('scheduled','running','finished','cancelled')),
|
||||
current_round integer not null default 0,
|
||||
finished_at timestamptz, winner_id uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default clock_timestamp()
|
||||
);
|
||||
create index tournaments_due on public.tournaments(status,starts_at) where status in('scheduled','running');
|
||||
create index tournaments_recent_winners on public.tournaments(finished_at desc,id) where status='finished' and winner_id is not null;
|
||||
create index tournaments_owner on public.tournaments(owner_id);
|
||||
create index tournaments_revision on public.tournaments(revision_id);
|
||||
create index tournaments_winner on public.tournaments(winner_id);
|
||||
create table public.tournament_members (
|
||||
tournament_id uuid not null references public.tournaments(id) on delete cascade,
|
||||
account_id uuid not null references public.profiles(id) on delete cascade,
|
||||
player_id uuid not null default gen_random_uuid(),
|
||||
access_hash text not null default(replace(gen_random_uuid()::text,'-','')||replace(gen_random_uuid()::text,'-','')) check(access_hash ~ '^[a-f0-9]{64}$'),
|
||||
joined_at timestamptz not null default clock_timestamp(),
|
||||
primary key(tournament_id,account_id)
|
||||
);
|
||||
create index tournament_members_account on public.tournament_members(account_id);
|
||||
create table public.tournament_rounds (
|
||||
tournament_id uuid not null references public.tournaments(id) on delete cascade,
|
||||
round_number integer not null check(round_number between 0 and 9),
|
||||
room_id uuid not null unique references public.multiplayer_rooms(id),
|
||||
deadline timestamptz,
|
||||
primary key(tournament_id,round_number)
|
||||
);
|
||||
-- Regular rooms remain limited to 16; only linked tournament rooms allow 32.
|
||||
alter table public.multiplayer_rooms add column tournament_id uuid references public.tournaments(id);
|
||||
alter table public.multiplayer_rooms drop constraint multiplayer_rooms_player_limit_check;
|
||||
alter table public.multiplayer_rooms add constraint multiplayer_rooms_player_limit_check
|
||||
check(player_limit between 2 and case when tournament_id is null then 16 else 32 end);
|
||||
create index multiplayer_rooms_tournament on public.multiplayer_rooms(tournament_id) where tournament_id is not null;
|
||||
create table public.tournament_announcements (
|
||||
id uuid primary key default gen_random_uuid(), tournament_id uuid not null unique references public.tournaments(id) on delete cascade,
|
||||
state text not null default 'pending' check(state in('pending','sending','sent','skipped','failed')),
|
||||
attempts integer not null default 0 check(attempts>=0), next_attempt_at timestamptz not null,
|
||||
lease_owner uuid, lease_until timestamptz, channel_id text, message_id text,
|
||||
sent_at timestamptz, last_error text
|
||||
);
|
||||
create index tournament_announcements_due on public.tournament_announcements(next_attempt_at) where state in('pending','sending');
|
||||
alter table public.tournaments enable row level security;
|
||||
alter table public.tournament_members enable row level security;
|
||||
alter table public.tournament_rounds enable row level security;
|
||||
alter table public.tournament_announcements enable row level security;
|
||||
revoke all on public.tournaments,public.tournament_members,public.tournament_rounds,public.tournament_announcements from public,anon,authenticated;
|
||||
grant all on public.tournaments,public.tournament_members,public.tournament_rounds,public.tournament_announcements to service_role;
|
||||
|
||||
create function public.sync_tournament_announcement() returns trigger
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
if new.status='scheduled' and new.announce_discord then
|
||||
insert into public.tournament_announcements(tournament_id,next_attempt_at)
|
||||
values(new.id,new.starts_at-interval '15 minutes')
|
||||
on conflict(tournament_id) do update set
|
||||
next_attempt_at=excluded.next_attempt_at,state='pending',lease_owner=null,lease_until=null,attempts=0,last_error=null
|
||||
where public.tournament_announcements.state<>'sent';
|
||||
else
|
||||
update public.tournament_announcements set state='skipped',lease_owner=null,lease_until=null
|
||||
where tournament_id=new.id and state<>'sent';
|
||||
end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger sync_tournament_announcement after insert or update of starts_at,announce_discord,status on public.tournaments
|
||||
for each row execute function public.sync_tournament_announcement();
|
||||
|
||||
create function public.guard_tournament_room_member() returns trigger
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare tournament uuid;
|
||||
begin
|
||||
-- Preserve historical room results during the profile FK's SET NULL cleanup.
|
||||
if tg_op='UPDATE' and old.account_id is not null and new.account_id is null
|
||||
and new.room_id=old.room_id and new.id=old.id and new.access_hash=old.access_hash
|
||||
and not exists(select 1 from public.profiles where id=old.account_id) then return new; end if;
|
||||
select tournament_id into tournament from public.multiplayer_rooms where id=new.room_id;
|
||||
if tournament is not null and not exists(select 1 from public.tournament_members m
|
||||
join public.profiles p on p.id=m.account_id and p.account_status='active'
|
||||
where m.tournament_id=tournament and m.account_id=new.account_id and m.player_id=new.id and m.access_hash=new.access_hash)
|
||||
then raise exception 'Tournament membership required'; end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger guard_tournament_room_member before insert or update of room_id,account_id,access_hash,id on public.multiplayer_players
|
||||
for each row execute function public.guard_tournament_room_member();
|
||||
|
||||
create function public.create_tournament(p_owner uuid,p_payload jsonb) returns uuid
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare tid uuid; rev public.revisions; settings jsonb; start_time timestamptz; zone text;
|
||||
seconds integer; cap integer; rounds integer; title text; mode text;
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_owner and account_status='active') then raise exception 'Active account required'; end if;
|
||||
select r.* into rev from public.datasets d join public.revisions r on r.id=d.revision_id
|
||||
where d.id=(p_payload->>'datasetId')::uuid and d.state='published';
|
||||
if not found then raise exception 'Choose a published map'; end if;
|
||||
if jsonb_array_length(public.game_viewpoint_pool(rev.manifest,rev.asset_root))<5 then raise exception 'Map needs at least five distinct panorama locations'; end if;
|
||||
start_time:=(p_payload->>'startsAt')::timestamptz;
|
||||
if start_time is null or start_time<=clock_timestamp() or start_time>clock_timestamp()+interval '90 days' then raise exception 'Choose a future start within 90 days'; end if;
|
||||
zone:=p_payload->>'serverTimezone';
|
||||
if zone is null or not exists(select 1 from pg_catalog.pg_timezone_names where name=zone) then raise exception 'Invalid server timezone'; end if;
|
||||
title:=btrim(p_payload->>'name'); cap:=(p_payload->>'playerLimit')::integer;
|
||||
rounds:=(p_payload->>'roundCount')::integer; seconds:=(p_payload->>'roundTimeSeconds')::integer;
|
||||
if cap is null or cap not between 2 and 32 then raise exception 'Choose between 2 and 32 players'; end if;
|
||||
if rounds is null or rounds not between 1 and 10 then raise exception 'Choose between 1 and 10 tournament rounds'; end if;
|
||||
mode:=coalesce(p_payload->>'timerMode','shared');
|
||||
if mode not in('shared','perMini') then raise exception 'Invalid tournament timer mode'; end if;
|
||||
if seconds is null or seconds not between 15 and (case when mode='shared' then 3000 else 600 end) or seconds%15<>0 then raise exception 'Choose a valid tournament time in steps of 15 seconds'; end if;
|
||||
settings:=public.resolve_difficulty_preset(p_payload->>'difficulty')||jsonb_build_object('seconds',least(seconds,600),'roundCount',5);
|
||||
insert into public.tournaments(name,owner_id,revision_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,difficulty,difficulty_settings,announce_discord)
|
||||
values(title,p_owner,rev.id,start_time,zone,cap,rounds,seconds,mode,p_payload->>'difficulty',settings,coalesce((p_payload->>'announceDiscord')::boolean,false)) returning id into tid;
|
||||
insert into public.tournament_members(tournament_id,account_id) values(tid,p_owner);
|
||||
return tid;
|
||||
end; $$;
|
||||
|
||||
-- The shared clock starts when the first panorama becomes playable. It keeps
|
||||
-- running through subsequent loading/results screens and is enforced server-side.
|
||||
create 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=least(mr.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=least(mr.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; $$;
|
||||
|
||||
create function public.tournament_tick(p_tournament uuid) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare t public.tournaments; rid uuid; r public.multiplayer_rooms; vp jsonb; n integer:=0; host uuid; winner uuid;
|
||||
begin
|
||||
select * into t from public.tournaments where id=p_tournament for update;
|
||||
if not found or t.status in('finished','cancelled') then return; end if;
|
||||
if t.status='running' and not exists(select 1 from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id) then
|
||||
update public.tournaments set status='cancelled' where id=t.id;
|
||||
update public.multiplayer_rooms set status='closed',transition_at=null where tournament_id=t.id and status not in('finished','closed');
|
||||
return;
|
||||
end if;
|
||||
if not exists(select 1 from public.revisions revision join public.datasets d on d.id=revision.dataset_id where revision.id=t.revision_id and d.state='published') then
|
||||
update public.tournaments set status='cancelled' where id=t.id;
|
||||
update public.multiplayer_rooms set status='closed',transition_at=null where tournament_id=t.id and status not in('finished','closed');
|
||||
return;
|
||||
end if;
|
||||
if t.status='scheduled' then
|
||||
if t.starts_at>clock_timestamp() then return; end if;
|
||||
delete from public.tournament_members m where m.tournament_id=t.id and not exists(select 1 from public.profiles p where p.id=m.account_id and p.account_status='active');
|
||||
if (select count(*) from public.tournament_members where tournament_id=t.id)<2 then
|
||||
update public.tournaments set status='cancelled' where id=t.id; return;
|
||||
end if;
|
||||
update public.tournaments set status='running' where id=t.id returning * into t;
|
||||
else
|
||||
select room_id into rid from public.tournament_rounds where tournament_id=t.id and round_number=t.current_round;
|
||||
-- Restricted accounts cannot submit or hold up a running event.
|
||||
update public.multiplayer_players p set has_left=true where room_id=rid and not exists(select 1 from public.profiles a where a.id=p.account_id and a.account_status='active');
|
||||
perform public.tournament_room_tick(t.id,rid);
|
||||
select * into r from public.multiplayer_rooms where id=rid;
|
||||
if r.status='closed' then update public.tournaments set status='cancelled' where id=t.id; return; end if;
|
||||
if r.status<>'finished' then return; end if;
|
||||
if t.current_round+1=t.round_count then
|
||||
select p.account_id into winner from public.tournament_members m
|
||||
join public.profiles a on a.id=m.account_id and a.account_status='active'
|
||||
join public.multiplayer_players p on p.account_id=m.account_id
|
||||
join public.tournament_rounds tr on tr.room_id=p.room_id and tr.tournament_id=t.id
|
||||
join public.multiplayer_guesses g on g.room_id=p.room_id and g.player_id=p.id
|
||||
join public.multiplayer_rounds mr on mr.room_id=g.room_id and mr.round_number=g.round_number and mr.closed_at is not null
|
||||
where m.tournament_id=t.id group by p.account_id,m.joined_at
|
||||
order by sum((g.result->>'score')::integer) desc,
|
||||
sum(coalesce((g.result->>'distance')::float8,1e12)),m.joined_at,p.account_id limit 1;
|
||||
update public.tournaments set status='finished',finished_at=clock_timestamp(),winner_id=winner where id=t.id;
|
||||
return;
|
||||
end if;
|
||||
update public.tournaments set current_round=current_round+1 where id=t.id returning * into t;
|
||||
end if;
|
||||
-- A fresh private room per tournament round keeps the existing 5-mini-round engine.
|
||||
if not exists(select 1 from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id) then
|
||||
update public.tournaments set status='cancelled' where id=t.id; return;
|
||||
end if;
|
||||
update public.tournament_members set player_id=gen_random_uuid() where tournament_id=t.id;
|
||||
select m.player_id into host from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id
|
||||
order by (m.account_id=t.owner_id) desc,m.joined_at,m.account_id limit 1;
|
||||
insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,difficulty_settings,player_limit,status,transition_at,tournament_id)
|
||||
values(t.revision_id,host,t.difficulty,t.difficulty_settings,t.player_limit,'loading',clock_timestamp()+interval '30 seconds',t.id) returning id into rid;
|
||||
insert into public.tournament_rounds(tournament_id,round_number,room_id) values(t.id,t.current_round,rid);
|
||||
insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id,joined_at)
|
||||
select m.player_id,rid,m.access_hash,left(p.display_name,26)||' #'||(row_number() over(order by m.joined_at,m.account_id))::text,m.account_id,m.joined_at
|
||||
from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active' where m.tournament_id=t.id;
|
||||
for vp in select value from public.revisions rev,jsonb_array_elements(public.game_viewpoint_pool(rev.manifest,rev.asset_root))
|
||||
where rev.id=t.revision_id order by random() limit 5 loop
|
||||
insert into public.multiplayer_rounds(room_id,round_number,viewpoint) values(rid,n,vp); n:=n+1;
|
||||
end loop;
|
||||
end; $$;
|
||||
|
||||
create function public.tournament_snapshot(p_tournament uuid,p_account uuid) returns jsonb
|
||||
language sql security invoker set search_path='' as $$
|
||||
with totals as (
|
||||
select m.account_id,m.joined_at,p.display_name,
|
||||
coalesce(sum((g.result->>'score')::integer) filter(where mr.closed_at is not null),0) total,
|
||||
coalesce(sum(coalesce((g.result->>'distance')::float8,1e12)) filter(where mr.closed_at is not null),0) distance
|
||||
from public.tournament_members m join public.profiles p on p.id=m.account_id and p.account_status='active'
|
||||
left join public.tournament_rounds tr on tr.tournament_id=m.tournament_id
|
||||
left join public.multiplayer_players mp on mp.room_id=tr.room_id and mp.account_id=m.account_id
|
||||
left join public.multiplayer_guesses g on g.room_id=mp.room_id and g.player_id=mp.id
|
||||
left join public.multiplayer_rounds mr on mr.room_id=g.room_id and mr.round_number=g.round_number
|
||||
where m.tournament_id=p_tournament group by m.account_id,m.joined_at,p.display_name
|
||||
), ranked as(select *,row_number() over(order by total desc,distance,joined_at,account_id) rank from totals)
|
||||
select jsonb_build_object('id',t.id,'name',t.name,'ownerId',t.owner_id,'datasetId',rev.dataset_id,
|
||||
'startsAt',t.starts_at,'serverTimezone',t.server_timezone,'playerLimit',t.player_limit,'roundCount',t.round_count,
|
||||
'roundTimeSeconds',t.round_time_seconds,'difficulty',t.difficulty,'difficultySettings',t.difficulty_settings,
|
||||
'timerMode',t.timer_mode,'roundDeadline',tr.deadline,
|
||||
'announceDiscord',t.announce_discord,'status',t.status,'currentRound',t.current_round,'finishedAt',t.finished_at,'winnerId',t.winner_id,
|
||||
'announcementState',case when p_account=t.owner_id then (select state from public.tournament_announcements where tournament_id=t.id) else null end,
|
||||
'you',(select account_id from public.tournament_members where tournament_id=t.id and account_id=p_account),
|
||||
'roomId',tr.room_id,'currentRoom',case when m.account_id is not null and exists(select 1 from public.profiles where id=p_account and account_status='active') then public.multiplayer_snapshot(tr.room_id,m.access_hash) else null end,
|
||||
'players',coalesce((select jsonb_agg(jsonb_build_object('accountId',account_id,'name',display_name,'total',total,'rank',rank) order by rank) from ranked),'[]'::jsonb),
|
||||
'serverNow',floor(extract(epoch from clock_timestamp())*1000))
|
||||
from public.tournaments t join public.revisions rev on rev.id=t.revision_id
|
||||
left join public.tournament_rounds tr on tr.tournament_id=t.id and tr.round_number=t.current_round
|
||||
left join public.tournament_members m on m.tournament_id=t.id and m.account_id=p_account where t.id=p_tournament;
|
||||
$$;
|
||||
|
||||
create function public.tournament_action(p_tournament uuid,p_account uuid,p_action text,p_payload jsonb default '{}') returns jsonb
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare t public.tournaments; m public.tournament_members; rid uuid; start_time timestamptz; cap integer;
|
||||
begin
|
||||
select * into t from public.tournaments where id=p_tournament for update;
|
||||
if not found then raise exception 'Tournament not found'; end if;
|
||||
if p_action<>'view' and not exists(select 1 from public.profiles where id=p_account and account_status='active') then raise exception 'Active account required'; end if;
|
||||
perform public.tournament_tick(t.id);
|
||||
select * into t from public.tournaments where id=t.id;
|
||||
select * into m from public.tournament_members where tournament_id=t.id and account_id=p_account;
|
||||
if p_action in('cancel','edit','start') and p_account is distinct from t.owner_id then raise exception 'Tournament owner permission required'; end if;
|
||||
if p_action='join' then
|
||||
if m.account_id is not null then return public.tournament_snapshot(t.id,p_account); end if;
|
||||
if t.status<>'scheduled' then raise exception 'Registration is closed'; end if;
|
||||
if (select count(*) from public.tournament_members where tournament_id=t.id)>=t.player_limit then raise exception 'Tournament is full'; end if;
|
||||
insert into public.tournament_members(tournament_id,account_id) values(t.id,p_account);
|
||||
elsif p_action='leave' then
|
||||
if t.status<>'scheduled' then raise exception 'Registration is closed'; end if;
|
||||
if p_account=t.owner_id then raise exception 'Owner must cancel the tournament'; end if;
|
||||
delete from public.tournament_members where tournament_id=t.id and account_id=p_account;
|
||||
elsif p_action='edit' then
|
||||
if t.status<>'scheduled' then raise exception 'Only scheduled tournaments can be edited'; end if;
|
||||
start_time:=coalesce((p_payload->>'startsAt')::timestamptz,t.starts_at);
|
||||
if start_time<=clock_timestamp() or start_time>clock_timestamp()+interval '90 days' then raise exception 'Choose a future start within 90 days'; end if;
|
||||
cap:=coalesce((p_payload->>'playerLimit')::integer,t.player_limit);
|
||||
if cap not between 2 and 32 or cap<(select count(*) from public.tournament_members where tournament_id=t.id) then raise exception 'Player limit cannot exclude registered players'; end if;
|
||||
update public.tournaments set starts_at=start_time,player_limit=cap,announce_discord=coalesce((p_payload->>'announceDiscord')::boolean,t.announce_discord) where id=t.id;
|
||||
elsif p_action='cancel' then
|
||||
if t.status not in('scheduled','running') then raise exception 'Tournament has ended'; end if;
|
||||
update public.tournaments set status='cancelled' where id=t.id;
|
||||
update public.multiplayer_rooms set status='closed',transition_at=null where tournament_id=t.id and status not in('finished','closed');
|
||||
elsif p_action='start' then
|
||||
if t.starts_at>clock_timestamp() then raise exception 'Wait for the scheduled start'; end if;
|
||||
elsif p_action in('ready','guess','next') then
|
||||
if t.status<>'running' or m.account_id is null then raise exception 'Tournament membership required'; end if;
|
||||
if (p_payload->>'tournamentRound')::integer is distinct from t.current_round then raise exception 'Tournament round changed; refresh before submitting'; end if;
|
||||
if p_action='next' and p_account<>t.owner_id then raise exception 'Tournament owner permission required'; end if;
|
||||
select room_id into rid from public.tournament_rounds where tournament_id=t.id and round_number=t.current_round;
|
||||
perform public.multiplayer_action(rid,m.access_hash,p_action,p_payload);
|
||||
elsif p_action<>'view' then raise exception 'Invalid tournament action'; end if;
|
||||
perform public.tournament_tick(t.id);
|
||||
return public.tournament_snapshot(t.id,p_account);
|
||||
end; $$;
|
||||
|
||||
create function public.tick_tournaments() returns void language plpgsql security invoker set search_path='' as $$
|
||||
declare target uuid;
|
||||
begin
|
||||
for target in select id from public.tournaments where status='running' or (status='scheduled' and starts_at<=clock_timestamp()) order by starts_at,id for update skip locked
|
||||
loop perform public.tournament_tick(target); end loop;
|
||||
end; $$;
|
||||
|
||||
create function public.claim_tournament_announcements(p_worker uuid,p_target uuid default null)
|
||||
returns setof public.tournament_announcements language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
if p_worker is null then raise exception 'Worker identity required'; end if;
|
||||
update public.tournament_announcements set state='failed',lease_owner=null,lease_until=null,
|
||||
last_error='Announcement retry limit reached after worker lease expired'
|
||||
where state='sending' and attempts>=8 and lease_until<=clock_timestamp();
|
||||
return query with due as (
|
||||
select a.id from public.tournament_announcements a join public.tournaments t on t.id=a.tournament_id
|
||||
where (p_target is null or t.id=p_target) and t.status='scheduled' and t.announce_discord
|
||||
and t.starts_at>clock_timestamp() and t.starts_at-interval '15 minutes'<=clock_timestamp()
|
||||
and a.attempts<8 and a.next_attempt_at<=clock_timestamp()
|
||||
and (a.state='pending' or (a.state='sending' and a.lease_until<=clock_timestamp()))
|
||||
order by a.next_attempt_at,a.id limit 10 for update of a skip locked
|
||||
) update public.tournament_announcements a set state='sending',attempts=attempts+1,lease_owner=p_worker,lease_until=clock_timestamp()+interval '2 minutes'
|
||||
from due where a.id=due.id returning a.*;
|
||||
end; $$;
|
||||
|
||||
revoke all on function public.sync_tournament_announcement(),public.guard_tournament_room_member(),public.create_tournament(uuid,jsonb),public.tournament_room_tick(uuid,uuid),public.tournament_tick(uuid),public.tournament_snapshot(uuid,uuid),public.tournament_action(uuid,uuid,text,jsonb),public.tick_tournaments(),public.claim_tournament_announcements(uuid,uuid) from public,anon,authenticated;
|
||||
grant execute on function public.sync_tournament_announcement(),public.guard_tournament_room_member(),public.create_tournament(uuid,jsonb),public.tournament_room_tick(uuid,uuid),public.tournament_tick(uuid),public.tournament_snapshot(uuid,uuid),public.tournament_action(uuid,uuid,text,jsonb),public.tick_tournaments(),public.claim_tournament_announcements(uuid,uuid) to service_role;
|
||||
-- Insert the award into the existing score query, preserving search/pagination.
|
||||
-- It changes ordering only: solo scores remain real, and the award expires dynamically.
|
||||
do $$
|
||||
declare body text;
|
||||
begin
|
||||
select pg_get_functiondef('public.player_leaderboard(text,text,integer,integer)'::regprocedure) into body;
|
||||
body:=replace(body,'), ranked as (', $patch$), champion as (
|
||||
select t.winner_id player_id,jsonb_build_object('tournament_id',t.id,'name',t.name,'expires_at',t.finished_at+interval '7 days') award
|
||||
from public.tournaments t join public.profiles p on p.id=t.winner_id and p.account_status='active'
|
||||
where p_difficulty='all' and t.status='finished' and t.finished_at>now()-interval '7 days'
|
||||
order by t.finished_at desc,t.id desc limit 1
|
||||
), eligible as (
|
||||
select p.*,c.award tournament_champion from players p left join champion c on c.player_id=p.player_id
|
||||
union all
|
||||
select c.player_id,p.display_name,0::bigint,0::numeric,0::bigint,c.award
|
||||
from champion c join public.profiles p on p.id=c.player_id
|
||||
where not exists(select 1 from players where player_id=c.player_id)
|
||||
), ranked as ($patch$);
|
||||
body:=replace(body,'order by best_score desc, average_score desc, games_played desc, player_id',
|
||||
'order by (tournament_champion is not null) desc, best_score desc, average_score desc, games_played desc, player_id');
|
||||
body:=replace(body,'round(average_score)::integer as average_score, games_played','round(average_score)::integer as average_score, games_played, tournament_champion');
|
||||
body:=replace(body,'from players'||chr(10)||' ), matched as (','from eligible'||chr(10)||' ), matched as (');
|
||||
if strpos(body,'from eligible')=0 or strpos(body,'tournament_champion is not null')=0
|
||||
or strpos(body,'tournament_champion from players')=0 then
|
||||
raise exception 'Leaderboard migration could not locate the expected score-query anchors';
|
||||
end if;
|
||||
execute body;
|
||||
end; $$;
|
||||
-- Keep the current distinct-panorama pool and expanded difficulty snapshot,
|
||||
-- while retaining the account-only host identity introduced before presets.
|
||||
create or replace function public.create_map_room(
|
||||
p_dataset uuid,p_difficulty text,p_name text,p_limit integer,
|
||||
p_access_hash text,p_owner uuid
|
||||
) returns uuid language plpgsql security invoker set search_path='' as $$
|
||||
declare rid uuid; revision uuid; pid uuid:=gen_random_uuid(); rev public.revisions;
|
||||
vp jsonb; locations jsonb; n integer:=0; settings jsonb; account_name text;
|
||||
begin
|
||||
select display_name into account_name from public.profiles where id=p_owner and account_status='active';
|
||||
if not found then raise exception 'Log in to create a multiplayer room with an active account'; end if;
|
||||
if p_limit is null or p_limit not between 2 and 16 then raise exception 'Choose between 2 and 16 players'; end if;
|
||||
settings:=public.resolve_difficulty_preset(p_difficulty);
|
||||
revision:=public.playable_map_revision(p_dataset,p_owner);
|
||||
select * into rev from public.revisions where id=revision;
|
||||
if not found then raise exception 'Map unavailable'; end if;
|
||||
locations:=public.game_viewpoint_pool(rev.manifest,rev.asset_root);
|
||||
if jsonb_array_length(locations)<5 then raise exception 'Map needs at least five distinct panorama locations'; end if;
|
||||
insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,difficulty_settings,player_limit)
|
||||
values(rev.id,pid,p_difficulty,settings,p_limit) returning id into rid;
|
||||
insert into public.multiplayer_players(id,room_id,access_hash,display_name,account_id)
|
||||
values(pid,rid,p_access_hash,account_name,p_owner);
|
||||
for vp in select value from jsonb_array_elements(locations) order by random() limit 5 loop
|
||||
insert into public.multiplayer_rounds(room_id,round_number,viewpoint) values(rid,n,vp); n:=n+1;
|
||||
end loop;
|
||||
return rid;
|
||||
end; $$;
|
||||
revoke all on function public.create_map_room(uuid,text,text,integer,text,uuid) from public,anon,authenticated;
|
||||
grant execute on function public.create_map_room(uuid,text,text,integer,text,uuid) to service_role;
|
||||
notify pgrst,'reload schema';
|
||||
Reference in New Issue
Block a user