Add multiplayer rooms and Vercel analytics
This commit is contained in:
214
supabase/migrations/20261007071259_multiplayer_rooms.sql
Normal file
214
supabase/migrations/20261007071259_multiplayer_rooms.sql
Normal file
@@ -0,0 +1,214 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user