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