Add MineGeo gameplay systems and interface

This commit is contained in:
2026-10-10 10:27:47 +05:00
parent bf01d9801d
commit 9af71c85c2
47 changed files with 9954 additions and 93 deletions

View File

@@ -0,0 +1,119 @@
-- Admin accounts manage settings and enforcement. Moderators review map submissions.
alter table public.profiles drop constraint profiles_role_check;
alter table public.profiles add constraint profiles_role_check
check (role in ('contributor','moderator','admin'));
create or replace function public.publish_dataset(p_dataset uuid,p_reviewer uuid,p_decision text,p_reason text default null) returns void
language plpgsql security invoker set search_path='' as $$
declare d public.datasets; m jsonb; rev uuid; first jsonb; count integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role in ('moderator','admin')) then raise exception 'Moderator permission required'; end if;
if p_decision='unpublish' and not exists(select 1 from public.profiles where id=p_reviewer and role='admin') then raise exception 'Admin permission required'; end if;
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update;
if not found then raise exception 'Map not found'; end if;
if p_decision='approve' then
if not(d.state='review' or (d.state='published' and coalesce(d.update_state,'')='review')) then raise exception 'Map is not ready for publication'; end if;
select manifest into m from public.dataset_inputs where dataset_id=p_dataset and validated;
count:=jsonb_array_length(m->'viewpoints'); first:=m->'viewpoints'->0;
if m is null or count<5 or count>24 then raise exception 'Map requires 5–24 validated panoramas'; end if;
insert into public.revisions(dataset_id,manifest,asset_root) values(p_dataset,m,p_dataset::text) returning id into rev;
update public.datasets set state='published',revision_id=rev,update_state=null,name=m->>'name',description=m->>'description',panorama_count=count,
map_path=coalesce(m->>'mapPath',d.map_path),
cover_path=coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp',rejection_reason=null,updated_at=now() where id=p_dataset;
elsif p_decision='reject' and (d.state='review' or (d.state='published' and d.update_state='review')) then
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide a rejection reason'; end if;
update public.datasets set state=case when state='published' then state else 'rejected' end,update_state=case when state='published' then 'rejected' end,rejection_reason=p_reason,updated_at=now() where id=p_dataset;
elsif p_decision='unpublish' and d.state='published' then
update public.datasets set state='unpublished',update_state=null,updated_at=now() where id=p_dataset;
else raise exception 'Invalid review transition'; end if;
insert into public.review_decisions(dataset_id,reviewer_id,decision,reason) values(p_dataset,p_reviewer,p_decision,p_reason);
end; $$;
create or replace function public.review_content_report(p_report uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare r public.reports; d public.datasets; map_id uuid; strikes integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
select dataset_id into map_id from public.reports where id=p_report;
select * into d from public.datasets where id=map_id for update;
select * into r from public.reports where id=p_report for update;
if not found or r.state<>'open' or r.report_cycle<>d.report_cycle then raise exception 'Report is no longer awaiting review'; end if;
if p_decision is null or p_decision not in ('confirm','dismiss') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide a review decision and feedback in 10–2000 characters'; end if;
if p_decision='confirm' then
if d.banned_at is not null then raise exception 'This map is already banned; dismiss the remaining report or review its appeal'; end if;
if p_reviewer=r.owner_id or p_reviewer=d.owner_id then raise exception 'An independent moderator must confirm this report'; end if;
-- Distinct reporters provide each strike, including legacy reports.
if exists(select 1 from public.reports where dataset_id=d.id and owner_id=r.owner_id and report_cycle=d.report_cycle and state='confirmed') then raise exception 'This reporter already contributed a strike'; end if;
strikes:=d.report_strikes+1;
update public.datasets set report_strikes=strikes,
state=case when strikes>=3 then 'unpublished' else state end,
update_state=case when strikes>=3 then null else update_state end,
banned_at=case when strikes>=3 then now() end,
ban_reason=case when strikes>=3 then trim(p_reason) end,updated_at=now() where id=d.id;
end if;
update public.reports set state=case when p_decision='confirm' then 'confirmed' else 'dismissed' end,
reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=r.id;
end; $$;
create or replace function public.review_map_appeal(p_appeal uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
language plpgsql security invoker set search_path='' as $$
declare a public.map_appeals; d public.datasets; map_id uuid;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
select dataset_id into map_id from public.map_appeals where id=p_appeal;
select * into d from public.datasets where id=map_id for update;
select * into a from public.map_appeals where id=p_appeal for update;
if not found or a.state<>'open' or d.banned_at is null or a.report_cycle<>d.report_cycle then raise exception 'Appeal is no longer awaiting review'; end if;
if p_reviewer=d.owner_id then raise exception 'An independent moderator must review your appeal'; end if;
if p_decision is null or p_decision not in ('approve','reject') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide an appeal decision and feedback in 10–2000 characters'; end if;
if p_decision='approve' then
if d.revision_id is null then raise exception 'Map has no approved version to restore'; end if;
update public.datasets set report_strikes=0,report_cycle=report_cycle+1,banned_at=null,ban_reason=null,state='published',updated_at=now() where id=d.id;
update public.reports set state='dismissed',reviewer_id=p_reviewer,reviewed_at=now(),review_reason='Closed when the map ban appeal was approved.' where dataset_id=d.id and report_cycle=d.report_cycle and state='open';
end if;
update public.map_appeals set state=case when p_decision='approve' then 'approved' else 'rejected' end,reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=a.id;
end; $$;
create or replace function public.set_beta_badge(p_reviewer uuid, p_enabled boolean)
returns boolean language plpgsql security invoker set search_path = '' as $$
begin
if not exists (
select 1 from public.profiles where id = p_reviewer and role = 'admin'
) then
raise exception 'Admin permission required';
end if;
if p_enabled is null then
raise exception 'Beta badge visibility is required';
end if;
update public.site_settings
set beta_badge_enabled = p_enabled, updated_at = now(), updated_by = p_reviewer
where id = true;
if not found then raise exception 'Site settings not found'; end if;
return p_enabled;
end;
$$;
create or replace function public.set_user_prefix(p_reviewer uuid,p_user uuid,p_prefix text,p_icon text) returns void
language plpgsql security invoker set search_path='' as $$
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
if p_icon not in ('star','crown','heart','flame','gem','trophy','sparkles') or p_icon is null then raise exception 'Invalid prefix icon'; end if;
if p_prefix is not null and (length(btrim(p_prefix)) not between 1 and 24 or p_prefix ~ '[[:cntrl:]]') then raise exception 'Invalid custom prefix'; end if;
update public.profiles set custom_prefix=case when p_prefix is null then null else btrim(p_prefix) end,custom_prefix_icon=p_icon where id=p_user;
if not found then raise exception 'Player not found'; end if;
end; $$;
revoke all on function public.publish_dataset(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.publish_dataset(uuid,uuid,text,text) to service_role;
revoke all on function public.review_content_report(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.review_content_report(uuid,uuid,text,text) to service_role;
revoke all on function public.review_map_appeal(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.review_map_appeal(uuid,uuid,text,text) to service_role;
revoke all on function public.set_beta_badge(uuid,boolean) from public,anon,authenticated;
grant execute on function public.set_beta_badge(uuid,boolean) to service_role;
revoke all on function public.set_user_prefix(uuid,uuid,text,text) from public,anon,authenticated;
grant execute on function public.set_user_prefix(uuid,uuid,text,text) to service_role;

View File

@@ -0,0 +1,100 @@
-- Durable, server-only announcements. One event per public map or shared party.
create table public.discord_announcements (
id uuid primary key default gen_random_uuid(),
kind text not null check (kind in ('map','party')),
target_id uuid not null,
state text not null default 'pending' check (state in ('pending','sending','sent','skipped','failed')),
attempts integer not null default 0 check (attempts>=0),
next_attempt_at timestamptz not null default clock_timestamp(),
lease_owner text,
lease_until timestamptz,
channel_id text,
message_id text,
last_error text,
created_at timestamptz not null default clock_timestamp(),
sent_at timestamptz,
unique(kind,target_id)
);
create index discord_announcements_due on public.discord_announcements(next_attempt_at,created_at)
where state in ('pending','sending');
alter table public.discord_announcements enable row level security;
revoke all on public.discord_announcements from public,anon,authenticated;
grant all on public.discord_announcements to service_role;
create policy "Server manages Discord delivery" on public.discord_announcements
for all to service_role using (true) with check (true);
create function public.queue_new_map_discord() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if new.state='published' and old.state<>'published' and old.revision_id is null
and new.parent_dataset_id is null and new.imported_from_id is null then
insert into public.discord_announcements(kind,target_id) values('map',new.id)
on conflict(kind,target_id) do nothing;
end if;
return new;
end; $$;
create trigger queue_new_map_discord after update of state on public.datasets
for each row execute function public.queue_new_map_discord();
create function public.publish_discord_party(p_room uuid,p_access_hash text) returns uuid
language plpgsql security invoker set search_path='' as $$
declare room public.multiplayer_rooms; event_id uuid; 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;
if not exists(select 1 from public.multiplayer_players where room_id=p_room
and id=room.host_player_id and access_hash=p_access_hash and not has_left) then
raise exception 'Only the room owner can publish this party';
end if;
if room.status<>'lobby' or room.created_at<clock_timestamp()-interval '24 hours' then
raise exception 'Only open lobbies can be published';
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 not exists(select 1 from public.revisions r join public.datasets d on d.id=r.dataset_id
where r.id=room.revision_id and d.state='published' and d.parent_dataset_id is null and d.imported_from_id is null) then
raise exception 'Only parties on published maps can be shared on Discord';
end if;
insert into public.discord_announcements(kind,target_id) values('party',p_room)
on conflict(kind,target_id) do nothing;
select id into event_id from public.discord_announcements where kind='party' and target_id=p_room;
return event_id;
end; $$;
create function public.claim_discord_announcements(p_worker text,p_kinds text[],p_target uuid default null)
returns setof public.discord_announcements language sql security invoker set search_path='' as $$
with exhausted as (
update public.discord_announcements set state='failed',lease_owner=null,lease_until=null,
last_error='Discord delivery could not be confirmed. Check the channel before retrying.'
where state='sending' and attempts>=8 and lease_until<clock_timestamp() returning id
)
update public.discord_announcements e set state='sending',attempts=e.attempts+1,
lease_owner=p_worker,lease_until=clock_timestamp()+interval '3 minutes'
where e.id in (
select id from public.discord_announcements where kind=any(p_kinds)
and (p_target is null or target_id=p_target) and state in ('pending','sending')
and attempts<8 and next_attempt_at<=clock_timestamp()
and (lease_until is null or lease_until<clock_timestamp())
order by created_at,id limit 5 for update skip locked
) returning e.*;
$$;
create function public.retry_discord_announcements(p_reviewer uuid) returns integer
language plpgsql security invoker set search_path='' as $$
declare changed integer;
begin
if not exists(select 1 from public.profiles where id=p_reviewer and role='admin') then
raise exception 'Admin permission required';
end if;
update public.discord_announcements set state='pending',attempts=0,next_attempt_at=clock_timestamp(),last_error=null,
lease_owner=null,lease_until=null where state='failed' or (state='pending' and lease_owner is null)
or (state='sending' and lease_until<clock_timestamp());
get diagnostics changed=row_count;
return changed;
end; $$;
revoke all on function public.queue_new_map_discord(),public.publish_discord_party(uuid,text),
public.claim_discord_announcements(text,text[],uuid),public.retry_discord_announcements(uuid)
from public,anon,authenticated;
grant execute on function public.queue_new_map_discord(),public.publish_discord_party(uuid,text),
public.claim_discord_announcements(text,text[],uuid),public.retry_discord_announcements(uuid) to service_role;

View File

@@ -0,0 +1,82 @@
-- Preserve the original Discord destination even if environment settings change.
alter table public.discord_announcements
add column closed_at timestamptz,
add column delete_after timestamptz,
add column deleted_at timestamptz,
add column cleanup_run_id text,
add column cleanup_lease_until timestamptz,
add column cleanup_error text;
create index discord_cleanup_due on public.discord_announcements(closed_at)
where kind='party' and state='sent' and deleted_at is null;
create function public.close_party_discord() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if tg_op='DELETE' then
update public.discord_announcements set closed_at=coalesce(closed_at,clock_timestamp())
where kind='party' and target_id=old.id and state='sent';
return old;
end if;
if new.status='closed' then
update public.discord_announcements set closed_at=coalesce(closed_at,clock_timestamp())
where kind='party' and target_id=new.id and state='sent';
end if;
return new;
end; $$;
create trigger close_party_discord after update of status or delete on public.multiplayer_rooms
for each row execute function public.close_party_discord();
-- Handle the race where closing a room happens while Discord is acknowledging a send.
create function public.capture_closed_discord_party() returns trigger
language plpgsql security invoker set search_path='' as $$
begin
if new.kind='party' and new.state='sent' and new.closed_at is null
and not exists(select 1 from public.multiplayer_rooms where id=new.target_id and status<>'closed') then
new.closed_at=clock_timestamp();
end if;
return new;
end; $$;
create trigger capture_closed_discord_party before insert or update of state,channel_id,message_id
on public.discord_announcements for each row execute function public.capture_closed_discord_party();
-- Older closed announcements receive the same red notice and five-minute grace period.
update public.discord_announcements e set closed_at=clock_timestamp()
where kind='party' and state='sent' and not exists(
select 1 from public.multiplayer_rooms r where r.id=e.target_id and r.status<>'closed');
create function public.claim_discord_cleanup(p_worker text,p_target uuid default null)
returns setof public.discord_announcements language plpgsql security invoker set search_path='' as $$
begin
-- Reconcile overlapping close/send transactions before claiming cleanup.
update public.discord_announcements e set closed_at=clock_timestamp()
where kind='party' and state='sent' and closed_at is null
and (p_target is null or target_id=p_target)
and not exists(select 1 from public.multiplayer_rooms r where r.id=e.target_id and r.status<>'closed');
return query
update public.discord_announcements e set cleanup_run_id=p_worker,
cleanup_lease_until=clock_timestamp()+interval '3 minutes'
where e.id in (
select id from public.discord_announcements where kind='party' and state='sent'
and closed_at is not null and deleted_at is null and channel_id is not null and message_id is not null
and (p_target is null or target_id=p_target)
and (cleanup_run_id is null or cleanup_run_id like 'starting:%')
and (cleanup_lease_until is null or cleanup_lease_until<clock_timestamp())
order by closed_at,id limit 20 for update skip locked
) returning e.*;
end;
$$;
-- Only start the grace period after Discord has acknowledged the red notice.
-- Replayed workflows and retries keep the original deadline.
create function public.mark_discord_party_closed(p_event uuid) returns timestamptz
language sql security invoker set search_path='' as $$
update public.discord_announcements set delete_after=coalesce(delete_after,clock_timestamp()+interval '5 minutes'),
cleanup_error=null
where id=p_event and kind='party' and state='sent' and closed_at is not null and deleted_at is null
returning delete_after;
$$;
revoke all on function public.close_party_discord(),public.capture_closed_discord_party(),
public.claim_discord_cleanup(text,uuid),public.mark_discord_party_closed(uuid) from public,anon,authenticated;
grant execute on function public.close_party_discord(),public.capture_closed_discord_party(),
public.claim_discord_cleanup(text,uuid),public.mark_discord_party_closed(uuid) to service_role;