Add map-specific tournament creation link
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m31s

This commit is contained in:
2026-10-18 13:32:56 +05:00
parent a7085e4e80
commit da1a50a6ad
10 changed files with 74 additions and 9 deletions

View 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';