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