Files
MineGeo/supabase/migrations/20261007071259_multiplayer_rooms.sql

215 lines
16 KiB
PL/PgSQL

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