362 lines
13 KiB
PL/PgSQL
362 lines
13 KiB
PL/PgSQL
-- Store shared custom difficulty presets and snapshot their rules in each game.
|
|
-- Writes stay behind the MineGeo server API; the exposed public schema grants
|
|
-- no table access to anon or authenticated clients.
|
|
|
|
create table public.difficulty_presets (
|
|
id uuid primary key default gen_random_uuid(),
|
|
name text not null check (
|
|
name = btrim(name)
|
|
and length(name) between 2 and 40
|
|
and name !~ '[[:cntrl:]]'
|
|
and name !~ U&'[\\202A-\\202E\\2066-\\2069]'
|
|
),
|
|
seconds integer not null check (
|
|
seconds between 15 and 600 and seconds % 15 = 0
|
|
),
|
|
zoom boolean not null,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
create unique index difficulty_presets_name
|
|
on public.difficulty_presets (lower(name));
|
|
alter table public.difficulty_presets enable row level security;
|
|
revoke all on public.difficulty_presets from public, anon, authenticated;
|
|
grant select, insert on public.difficulty_presets to service_role;
|
|
|
|
alter table public.game_sessions
|
|
drop constraint if exists game_sessions_difficulty_check;
|
|
alter table public.game_sessions
|
|
add column difficulty_settings jsonb;
|
|
update public.game_sessions
|
|
set difficulty_settings = jsonb_build_object(
|
|
'id', difficulty,
|
|
'name', initcap(difficulty),
|
|
'seconds', case difficulty when 'easy' then 180 when 'hard' then 30 else 90 end,
|
|
'zoom', difficulty <> 'hard'
|
|
);
|
|
alter table public.game_sessions
|
|
alter column difficulty_settings set not null;
|
|
alter table public.game_sessions
|
|
add constraint game_sessions_difficulty_check check (
|
|
difficulty in ('easy', 'normal', 'hard')
|
|
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
);
|
|
|
|
alter table public.multiplayer_rooms
|
|
drop constraint if exists multiplayer_rooms_difficulty_check;
|
|
alter table public.multiplayer_rooms
|
|
add column difficulty_settings jsonb;
|
|
update public.multiplayer_rooms
|
|
set difficulty_settings = jsonb_build_object(
|
|
'id', difficulty,
|
|
'name', initcap(difficulty),
|
|
'seconds', case difficulty when 'easy' then 180 when 'hard' then 30 else 90 end,
|
|
'zoom', difficulty <> 'hard'
|
|
);
|
|
alter table public.multiplayer_rooms
|
|
alter column difficulty_settings set not null;
|
|
alter table public.multiplayer_rooms
|
|
add constraint multiplayer_rooms_difficulty_check check (
|
|
difficulty in ('easy', 'normal', 'hard')
|
|
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
);
|
|
|
|
create or replace function public.resolve_difficulty_preset(p_id text)
|
|
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
|
|
declare preset jsonb;
|
|
begin
|
|
case p_id
|
|
when 'easy' then
|
|
return jsonb_build_object('id', 'easy', 'name', 'Easy', 'seconds', 180, 'zoom', true);
|
|
when 'normal' then
|
|
return jsonb_build_object('id', 'normal', 'name', 'Normal', 'seconds', 90, 'zoom', true);
|
|
when 'hard' then
|
|
return jsonb_build_object('id', 'hard', 'name', 'Hard', 'seconds', 30, 'zoom', false);
|
|
end case;
|
|
|
|
select jsonb_build_object(
|
|
'id', p.id::text,
|
|
'name', p.name,
|
|
'seconds', p.seconds,
|
|
'zoom', p.zoom
|
|
)
|
|
into preset
|
|
from public.difficulty_presets p
|
|
where p.id::text = lower(p_id);
|
|
if not found then raise exception 'Choose a saved difficulty'; end if;
|
|
return preset;
|
|
end;
|
|
$$;
|
|
revoke all on function public.resolve_difficulty_preset(text)
|
|
from public, anon, authenticated;
|
|
grant execute on function public.resolve_difficulty_preset(text) to service_role;
|
|
|
|
create or replace function public.create_game(
|
|
p_dataset uuid,
|
|
p_difficulty text,
|
|
p_owner uuid,
|
|
p_access_hash text
|
|
) returns uuid language plpgsql security invoker set search_path = '' as $$
|
|
declare
|
|
sid uuid;
|
|
revision uuid;
|
|
rev public.revisions;
|
|
vp jsonb;
|
|
n integer := 0;
|
|
settings jsonb;
|
|
begin
|
|
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;
|
|
if jsonb_array_length(rev.manifest->'viewpoints') < 5 then
|
|
raise exception 'Map has fewer than five viewpoints';
|
|
end if;
|
|
insert into public.game_sessions(
|
|
revision_id, owner_id, access_hash, difficulty, difficulty_settings
|
|
) values (rev.id, p_owner, p_access_hash, p_difficulty, settings)
|
|
returning id into sid;
|
|
for vp in
|
|
select value from jsonb_array_elements(rev.manifest->'viewpoints')
|
|
order by random() limit 5
|
|
loop
|
|
insert into public.game_rounds(session_id, round_number, viewpoint)
|
|
values (sid, n, vp);
|
|
n := n + 1;
|
|
end loop;
|
|
return sid;
|
|
end;
|
|
$$;
|
|
|
|
create or replace function public.activate_round(
|
|
p_session uuid,
|
|
p_round integer,
|
|
p_access_hash text
|
|
) returns timestamptz language plpgsql security invoker set search_path = '' as $$
|
|
declare
|
|
s public.game_sessions;
|
|
r public.game_rounds;
|
|
seconds integer;
|
|
begin
|
|
select * into s from public.game_sessions
|
|
where id = p_session and access_hash = p_access_hash for update;
|
|
if not found then raise exception 'Session not found'; end if;
|
|
if s.current_round <> p_round then raise exception 'Round is not current'; end if;
|
|
select * into r from public.game_rounds
|
|
where session_id = p_session and round_number = p_round for update;
|
|
if r.result is not null then raise exception 'Round already finished'; end if;
|
|
if r.deadline is not null then return r.deadline; end if;
|
|
seconds := (s.difficulty_settings->>'seconds')::integer;
|
|
update public.game_rounds
|
|
set deadline = clock_timestamp() + make_interval(secs => seconds)
|
|
where session_id = p_session and round_number = p_round
|
|
returning deadline into r.deadline;
|
|
return r.deadline;
|
|
end;
|
|
$$;
|
|
|
|
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;
|
|
n integer := 0;
|
|
settings jsonb;
|
|
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;
|
|
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;
|
|
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, 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
|
|
) 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;
|
|
$$;
|
|
|
|
create or replace function public.create_multiplayer_room(
|
|
p_dataset uuid,
|
|
p_difficulty text,
|
|
p_name text,
|
|
p_limit integer,
|
|
p_access_hash text
|
|
) returns uuid language sql security invoker set search_path = '' as $$
|
|
select public.create_map_room(
|
|
p_dataset, p_difficulty, p_name, p_limit, p_access_hash, null
|
|
);
|
|
$$;
|
|
|
|
create or replace 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 := (room.difficulty_settings->>'seconds')::integer;
|
|
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;
|
|
$$;
|
|
|
|
create or replace 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,
|
|
'difficultySettings', room.difficulty_settings,
|
|
'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;
|
|
$$;
|