-- Preserve the original defaults and snapshot the expanded rules per game. alter table public.difficulty_presets add column pan boolean not null default true, add column compass boolean not null default true, add column coordinates boolean not null default true, add column map_zoom boolean not null default true, add column scoring text not null default 'normal' check (scoring in ('normal','strict','gentle')); update public.game_sessions set difficulty_settings = '{"pan":true,"compass":true,"coordinates":true,"mapZoom":true,"scoring":"normal"}'::jsonb || difficulty_settings; update public.multiplayer_rooms set difficulty_settings = '{"pan":true,"compass":true,"coordinates":true,"mapZoom":true,"scoring":"normal"}'::jsonb || difficulty_settings; 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 if p_id in ('easy','normal','hard') then return jsonb_build_object('id',p_id,'name',initcap(p_id), 'seconds',case p_id when 'easy' then 180 when 'hard' then 30 else 90 end, 'zoom',p_id <> 'hard','pan',true,'compass',true,'coordinates',true,'mapZoom',true,'scoring','normal'); end if; select jsonb_build_object('id',p.id::text,'name',p.name,'seconds',p.seconds,'zoom',p.zoom, 'pan',p.pan,'compass',p.compass,'coordinates',p.coordinates,'mapZoom',p.map_zoom,'scoring',p.scoring) 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; $$; -- Shared server-side curve used by solo, multiplayer, and tournament scoring. create function public.score_difficulty_guess(p_distance double precision,p_diagonal double precision,p_scoring text default 'normal') returns integer language plpgsql immutable security invoker set search_path = '' as $$ begin if p_scoring is null or p_scoring not in ('normal','strict','gentle') then raise exception 'Invalid scoring rules'; end if; if p_distance is null or p_diagonal is null or p_distance < 0 or p_diagonal <= 0 or p_distance in ('NaN'::float8,'Infinity'::float8,'-Infinity'::float8) or p_diagonal in ('NaN'::float8,'Infinity'::float8,'-Infinity'::float8) then raise exception 'Invalid scoring distance'; end if; if p_distance <= 1 then return 5000; end if; return round(5000 * exp(-(case p_scoring when 'strict' then 8 when 'gentle' then 3 else 5 end) * p_distance / p_diagonal))::integer; end; $$; revoke all on function public.score_difficulty_guess(double precision,double precision,text) from public,anon,authenticated; grant execute on function public.score_difficulty_guess(double precision,double precision,text) to service_role; create or replace function public.submit_guess(p_session uuid,p_round integer,p_access_hash text,p_x double precision default null,p_z double precision default null) returns jsonb language plpgsql security invoker set search_path='' as $$ declare s public.game_sessions; r public.game_rounds; manifest jsonb; b jsonb; d double precision; diagonal double precision; points integer:=0; timed_out boolean; answer jsonb; 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; 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 return r.result; end if; if s.current_round<>p_round or r.deadline is null then raise exception 'Round is not active'; end if; select revisions.manifest into manifest from public.revisions where id=s.revision_id; b:=manifest->'bounds'; timed_out:=clock_timestamp()>=r.deadline; if not timed_out and (p_x is null or p_z is null) then raise exception 'Place a guess first'; end if; if not timed_out then if p_x='NaN'::float8 or p_z='NaN'::float8 or p_x<(b->>'minX')::float8 or p_x>(b->>'maxX')::float8 or p_z<(b->>'minZ')::float8 or p_z>(b->>'maxZ')::float8 then raise exception 'Guess outside map'; end if; d:=sqrt(power(p_x-(r.viewpoint->>'x')::float8,2)+power(p_z-(r.viewpoint->>'z')::float8,2)); diagonal:=sqrt(power((b->>'maxX')::float8-(b->>'minX')::float8,2)+power((b->>'maxZ')::float8-(b->>'minZ')::float8,2)); points:=public.score_difficulty_guess(d,diagonal,coalesce(s.difficulty_settings->>'scoring','normal')); end if; answer:=jsonb_build_object('score',points,'distance',d,'timedOut',timed_out,'actual',jsonb_build_object('x',r.viewpoint->'x','z',r.viewpoint->'z'),'guess',case when timed_out then null else jsonb_build_object('x',p_x,'z',p_z) end); update public.game_rounds set result=answer where session_id=p_session and round_number=p_round; update public.game_sessions set current_round=current_round+1 where id=p_session; return answer; end; $$; create or replace 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 := public.score_difficulty_guess(distance,diagonal,coalesce(room.difficulty_settings->>'scoring','normal')); 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'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; $$;