Add profile photo uploads and custom difficulty support

This commit is contained in:
2026-10-13 20:50:54 +05:00
parent 8c3de302e7
commit de812453ec
37 changed files with 1139 additions and 74 deletions

View File

@@ -0,0 +1,361 @@
-- 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;
$$;

View File

@@ -0,0 +1,34 @@
alter table public.profiles
add column avatar_path text;
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
values (
'minegeo-avatars',
'minegeo-avatars',
true,
5242880,
array['image/jpeg', 'image/png', 'image/webp']
)
on conflict (id) do update set
name = excluded.name,
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy "MineGeo users upload their own avatars"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'minegeo-avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
create policy "MineGeo users delete their own avatars"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'minegeo-avatars'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);