-- Guest identities are verified by the server's HttpOnly cookie. No client role -- can read room secrets, future locations, guesses, or invoke these functions. create table public.multiplayer_rooms ( id uuid primary key default gen_random_uuid(), revision_id uuid not null references public.revisions(id), host_player_id uuid not null, difficulty text not null check (difficulty in ('easy','normal','hard')), player_limit integer not null check (player_limit between 2 and 16), status text not null default 'lobby' check (status in ('lobby','loading','playing','results','finished','closed')), current_round integer not null default 0 check (current_round between 0 and 4), transition_at timestamptz, created_at timestamptz not null default now() ); create index multiplayer_rooms_revision on public.multiplayer_rooms(revision_id); create table public.multiplayer_players ( id uuid primary key default gen_random_uuid(), room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, access_hash text not null check (access_hash ~ '^[a-f0-9]{64}$'), display_name text not null check (length(display_name) between 2 and 32 and display_name !~ '[[:cntrl:]]' and display_name !~ U&'[\202A-\202E\2066-\2069]'), ready_round integer not null default -1, has_left boolean not null default false, joined_at timestamptz not null default clock_timestamp(), unique(room_id,access_hash), unique(room_id,id) ); create unique index multiplayer_player_names on public.multiplayer_players(room_id,lower(display_name)); create table public.multiplayer_rounds ( room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, round_number integer not null check (round_number between 0 and 4), viewpoint jsonb not null, deadline timestamptz, closed_at timestamptz, primary key(room_id,round_number) ); create table public.multiplayer_guesses ( room_id uuid not null, player_id uuid not null, round_number integer not null, result jsonb not null, primary key(room_id,player_id,round_number), foreign key(room_id,player_id) references public.multiplayer_players(room_id,id) on delete cascade, foreign key(room_id,round_number) references public.multiplayer_rounds(room_id,round_number) on delete cascade ); create index multiplayer_guesses_round on public.multiplayer_guesses(room_id,round_number); alter table public.multiplayer_rooms enable row level security; alter table public.multiplayer_players enable row level security; alter table public.multiplayer_rounds enable row level security; alter table public.multiplayer_guesses enable row level security; revoke all on public.multiplayer_rooms, public.multiplayer_players, public.multiplayer_rounds, public.multiplayer_guesses from public,anon,authenticated; grant all on public.multiplayer_rooms, public.multiplayer_players, public.multiplayer_rounds, public.multiplayer_guesses to service_role; create function public.create_multiplayer_room(p_dataset uuid,p_difficulty text,p_name text,p_limit integer,p_access_hash text) returns uuid language plpgsql security invoker set search_path='' as $$ declare rid uuid; pid uuid := gen_random_uuid(); rev public.revisions; vp jsonb; n integer := 0; begin if p_limit is null or p_limit not between 2 and 16 then raise exception 'Choose between 2 and 16 players'; end if; if p_difficulty is null or p_difficulty not in ('easy','normal','hard') then raise exception 'Invalid difficulty'; end if; select r.* into rev from public.datasets d join public.revisions r on r.id=d.revision_id where d.id=p_dataset and d.state='published'; if not found then raise exception 'Map unavailable'; end if; if coalesce(jsonb_array_length(rev.manifest->'viewpoints'),0)<5 then raise exception 'Map has fewer than five viewpoints'; end if; insert into public.multiplayer_rooms(revision_id,host_player_id,difficulty,player_limit) values(rev.id,pid,p_difficulty,p_limit) returning id into rid; insert into public.multiplayer_players(id,room_id,access_hash,display_name) values(pid,rid,p_access_hash,btrim(p_name)); for vp in select value from jsonb_array_elements(rev.manifest->'viewpoints') 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; $$; -- One room lock serializes capacity checks, guesses, and deadline transitions. -- GET polling also settles disconnected players; it never relies on their timers. create function public.multiplayer_tick(p_room uuid) returns void language plpgsql security invoker set search_path='' as $$ declare room public.multiplayer_rooms; turn public.multiplayer_rounds; seconds integer; begin select * into room from public.multiplayer_rooms where id=p_room for update; if room.status not in ('finished','closed') and room.created_at < clock_timestamp()-interval '24 hours' then update public.multiplayer_rooms set status='closed',transition_at=null where id=p_room; return; end if; if room.status='results' and room.transition_at<=clock_timestamp() then update public.multiplayer_rooms set current_round=current_round+1,status='loading',transition_at=clock_timestamp()+interval '30 seconds' where id=p_room returning * into room; end if; if room.status='loading' and ( room.transition_at<=clock_timestamp() or not exists(select 1 from public.multiplayer_players where room_id=p_room and not has_left and ready_round<>room.current_round) ) then seconds := case room.difficulty when 'easy' then 180 when 'normal' then 90 else 30 end; update public.multiplayer_rounds set deadline=clock_timestamp()+make_interval(secs=>seconds) where room_id=p_room and round_number=room.current_round; update public.multiplayer_rooms set status='playing',transition_at=null where id=p_room returning * into room; end if; if room.status<>'playing' then return; end if; select * into turn from public.multiplayer_rounds where room_id=p_room and round_number=room.current_round; insert into public.multiplayer_guesses(room_id,player_id,round_number,result) select p_room,p.id,room.current_round,jsonb_build_object('score',0,'distance',null,'timedOut',true,'guess',null,'actual',jsonb_build_object('x',turn.viewpoint->'x','z',turn.viewpoint->'z')) from public.multiplayer_players p where p.room_id=p_room and (p.has_left or turn.deadline<=clock_timestamp()) on conflict(room_id,player_id,round_number) do nothing; if not exists(select 1 from public.multiplayer_players p where p.room_id=p_room and not exists( select 1 from public.multiplayer_guesses g where g.room_id=p_room and g.player_id=p.id and g.round_number=room.current_round )) then update public.multiplayer_rounds set closed_at=clock_timestamp() where room_id=p_room and round_number=room.current_round; update public.multiplayer_rooms set status=case when current_round=4 then 'finished' else 'results' end, transition_at=case when current_round=4 then null else clock_timestamp()+interval '20 seconds' end where id=p_room; end if; end; $$; -- Only closed rounds contribute scores or reveal locations, even to the host. create function public.multiplayer_snapshot(p_room uuid,p_access_hash text) returns jsonb language sql security invoker set search_path='' as $$ with room as (select * from public.multiplayer_rooms where id=p_room), viewer as (select p.id from public.multiplayer_players p where p.room_id=p_room and p.access_hash=p_access_hash and not p.has_left), totals as ( select p.*,coalesce((select sum((g.result->>'score')::integer) from public.multiplayer_guesses g join public.multiplayer_rounds r on r.room_id=g.room_id and r.round_number=g.round_number where g.room_id=p_room and g.player_id=p.id and r.closed_at is not null),0) as total from public.multiplayer_players p where p.room_id=p_room ), ranked as (select *,dense_rank() over(order by total desc) as rank from totals) select jsonb_build_object( 'id',room.id,'datasetId',rev.dataset_id,'name',rev.manifest->>'name','difficulty',room.difficulty, 'playerLimit',room.player_limit,'playerCount',(select count(*) from totals where not has_left), 'status',room.status,'round',room.current_round,'hostId',room.host_player_id,'you',(select id from viewer), 'serverNow',floor(extract(epoch from clock_timestamp())*1000),'transitionAt',room.transition_at, 'players',case when exists(select 1 from viewer) then coalesce((select jsonb_agg(jsonb_build_object( 'id',p.id,'name',p.display_name,'left',p.has_left,'ready',p.ready_round=room.current_round, 'submitted',exists(select 1 from public.multiplayer_guesses g where g.room_id=p_room and g.player_id=p.id and g.round_number=room.current_round), 'total',p.total,'rank',p.rank ) order by p.rank,p.joined_at,p.id) from ranked p),'[]'::jsonb) else '[]'::jsonb end, 'rounds',case when exists(select 1 from viewer) then coalesce((select jsonb_agg(jsonb_build_object( 'number',r.round_number,'deadline',r.deadline,'closedAt',r.closed_at, 'results',case when r.closed_at is not null then coalesce((select jsonb_agg(g.result||jsonb_build_object('playerId',g.player_id) order by g.player_id) from public.multiplayer_guesses g where g.room_id=p_room and g.round_number=r.round_number),'[]'::jsonb) else '[]'::jsonb end ) order by r.round_number) from public.multiplayer_rounds r where r.room_id=p_room),'[]'::jsonb) else '[]'::jsonb end, 'myGuess',case when exists(select 1 from viewer) then (select g.result->'guess' from public.multiplayer_guesses g where g.room_id=p_room and g.player_id=(select id from viewer) and g.round_number=room.current_round) else null end ) from room join public.revisions rev on rev.id=room.revision_id; $$; create 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 := case when distance<=1 then 5000 else round(5000*exp(-5*distance/diagonal))::integer end; 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'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; $$; revoke all on function public.create_multiplayer_room(uuid,text,text,integer,text),public.multiplayer_tick(uuid),public.multiplayer_snapshot(uuid,text),public.multiplayer_action(uuid,text,text,jsonb) from public,anon,authenticated; grant execute on function public.create_multiplayer_room(uuid,text,text,integer,text),public.multiplayer_tick(uuid),public.multiplayer_snapshot(uuid,text),public.multiplayer_action(uuid,text,text,jsonb) to service_role;