-- Public profile details and in-game cosmetic purchases. alter table public.profiles add column bio text not null default '' check (char_length(bio) <= 280); create table public.profile_cosmetics ( id text primary key check (id in ( 'redstone-pulse', 'ender-orbit', 'nether-flame', 'aurora-crown' )), name text not null, description text not null, price integer not null check (price > 0), rarity text not null check (rarity in ('common', 'rare', 'epic', 'legendary')), sort_order integer not null unique, active boolean not null default true, created_at timestamptz not null default clock_timestamp() ); insert into public.profile_cosmetics (id, name, description, price, rarity, sort_order) values ('redstone-pulse', 'Redstone Pulse', 'A redstone signal ripples around your portrait.', 50, 'common', 1), ('ender-orbit', 'Ender Orbit', 'Two ender sparks circle your portrait.', 90, 'rare', 2), ('nether-flame', 'Nether Flame', 'A warm nether flame flickers along the frame.', 130, 'epic', 3), ('aurora-crown', 'Aurora Crown', 'A living aurora sweeps around your portrait.', 180, 'legendary', 4); alter table public.profiles add column profile_effect text references public.profile_cosmetics(id) on delete set null; create table public.game_wallets ( user_id uuid primary key references public.profiles(id) on delete cascade, balance integer not null default 0 check (balance >= 0), updated_at timestamptz not null default clock_timestamp() ); create table public.profile_cosmetic_ownership ( user_id uuid not null references public.profiles(id) on delete cascade, cosmetic_id text not null references public.profile_cosmetics(id) on delete restrict, purchased_at timestamptz not null default clock_timestamp(), primary key (user_id, cosmetic_id) ); create table public.game_wallet_transactions ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.profiles(id) on delete cascade, amount integer not null check (amount <> 0), source text not null check (source in ('game_reward', 'cosmetic_purchase')), source_key text not null check (char_length(source_key) between 1 and 64), created_at timestamptz not null default clock_timestamp(), unique (user_id, source, source_key), check ( (source = 'game_reward' and amount > 0) or (source = 'cosmetic_purchase' and amount < 0) ) ); create index profile_cosmetic_ownership_user on public.profile_cosmetic_ownership(user_id, purchased_at desc); create index game_wallet_transactions_user_created on public.game_wallet_transactions(user_id, created_at desc); alter table public.profile_cosmetics enable row level security; alter table public.game_wallets enable row level security; alter table public.profile_cosmetic_ownership enable row level security; alter table public.game_wallet_transactions enable row level security; revoke all on public.profile_cosmetics, public.game_wallets, public.profile_cosmetic_ownership, public.game_wallet_transactions from public, anon, authenticated; grant select on public.profile_cosmetics to service_role; grant all on public.game_wallets, public.profile_cosmetic_ownership, public.game_wallet_transactions to service_role; create function public.cosmetics_shop_state(p_user uuid) returns jsonb language plpgsql security invoker set search_path = '' as $$ declare v_balance integer; v_owned jsonb; v_effect text; v_catalog jsonb; begin if not exists ( select 1 from public.profiles where id = p_user and account_status = 'active' ) then raise exception using errcode = '42501', message = 'Active account required'; end if; insert into public.game_wallets(user_id) values (p_user) on conflict (user_id) do nothing; select balance into v_balance from public.game_wallets where user_id = p_user; select profile_effect into v_effect from public.profiles where id = p_user; select coalesce( jsonb_agg(cosmetic_id order by purchased_at, cosmetic_id), '[]'::jsonb ) into v_owned from public.profile_cosmetic_ownership where user_id = p_user; select coalesce( jsonb_agg( jsonb_build_object( 'id', id, 'name', name, 'description', description, 'price', price, 'rarity', rarity ) order by sort_order ), '[]'::jsonb ) into v_catalog from public.profile_cosmetics where active; return jsonb_build_object( 'balance', v_balance, 'owned', v_owned, 'equipped', v_effect, 'catalog', v_catalog ); end; $$; create function public.buy_profile_cosmetic(p_user uuid, p_cosmetic_id text) returns jsonb language plpgsql security invoker set search_path = '' as $$ declare v_price integer; v_balance integer; v_status text; begin select account_status into v_status from public.profiles where id = p_user for update; if v_status is distinct from 'active' then raise exception using errcode = '42501', message = 'Active account required'; end if; select price into v_price from public.profile_cosmetics where id = p_cosmetic_id and active; if not found then raise exception using errcode = '22023', message = 'Cosmetic is not available'; end if; insert into public.game_wallets(user_id) values (p_user) on conflict (user_id) do nothing; select balance into v_balance from public.game_wallets where user_id = p_user for update; if exists ( select 1 from public.profile_cosmetic_ownership where user_id = p_user and cosmetic_id = p_cosmetic_id ) then return public.cosmetics_shop_state(p_user); end if; if v_balance < v_price then raise exception using errcode = 'P0001', message = 'Not enough GeoCoins'; end if; insert into public.profile_cosmetic_ownership(user_id, cosmetic_id) values (p_user, p_cosmetic_id); insert into public.game_wallet_transactions(user_id, amount, source, source_key) values (p_user, -v_price, 'cosmetic_purchase', p_cosmetic_id); update public.game_wallets set balance = balance - v_price, updated_at = clock_timestamp() where user_id = p_user; update public.profiles set profile_effect = p_cosmetic_id where id = p_user; return public.cosmetics_shop_state(p_user); end; $$; create function public.equip_profile_cosmetic(p_user uuid, p_cosmetic_id text) returns jsonb language plpgsql security invoker set search_path = '' as $$ declare v_status text; begin select account_status into v_status from public.profiles where id = p_user for update; if v_status is distinct from 'active' then raise exception using errcode = '42501', message = 'Active account required'; end if; if p_cosmetic_id is not null and not exists ( select 1 from public.profile_cosmetic_ownership where user_id = p_user and cosmetic_id = p_cosmetic_id ) then raise exception using errcode = '42501', message = 'Cosmetic not owned'; end if; update public.profiles set profile_effect = p_cosmetic_id where id = p_user; return public.cosmetics_shop_state(p_user); end; $$; create function public.reward_completed_game_cosmetic_currency() returns trigger language plpgsql security invoker set search_path = '' as $$ declare v_user uuid; v_status text; v_wallet_balance integer; v_score integer; v_earned_24h integer; v_reward integer; begin if old.result is not null or new.result is null or new.round_number <> 4 then return new; end if; select s.owner_id into v_user from public.game_sessions s join public.revisions r on r.id = s.revision_id join public.datasets d on d.id = r.dataset_id where s.id = new.session_id and d.state = 'published'; if v_user is null then return new; end if; select account_status into v_status from public.profiles where id = v_user for update; if v_status is distinct from 'active' then return new; end if; insert into public.game_wallets(user_id) values (v_user) on conflict (user_id) do nothing; select balance into v_wallet_balance from public.game_wallets where user_id = v_user for update; select coalesce(sum(amount), 0)::integer into v_earned_24h from public.game_wallet_transactions where user_id = v_user and source = 'game_reward' and created_at >= clock_timestamp() - interval '24 hours'; select coalesce(sum((result->>'score')::integer), 0)::integer into v_score from public.game_rounds where session_id = new.session_id and result is not null; v_reward := least(20, 10 + floor(v_score::numeric / 2500)::integer); v_reward := least(v_reward, greatest(0, 100 - v_earned_24h)); if v_reward <= 0 then return new; end if; insert into public.game_wallet_transactions(user_id, amount, source, source_key) values (v_user, v_reward, 'game_reward', new.session_id::text) on conflict (user_id, source, source_key) do nothing; if not found then return new; end if; update public.game_wallets set balance = balance + v_reward, updated_at = clock_timestamp() where user_id = v_user; return new; end; $$; create trigger game_rounds_reward_cosmetic_currency after update of result on public.game_rounds for each row when (old.result is null and new.result is not null and new.round_number = 4) execute function public.reward_completed_game_cosmetic_currency(); revoke all on function public.cosmetics_shop_state(uuid) from public, anon, authenticated; revoke all on function public.buy_profile_cosmetic(uuid, text) from public, anon, authenticated; revoke all on function public.equip_profile_cosmetic(uuid, text) from public, anon, authenticated; revoke all on function public.reward_completed_game_cosmetic_currency() from public, anon, authenticated; grant execute on function public.cosmetics_shop_state(uuid) to service_role; grant execute on function public.buy_profile_cosmetic(uuid, text) to service_role; grant execute on function public.equip_profile_cosmetic(uuid, text) to service_role; grant execute on function public.reward_completed_game_cosmetic_currency() to service_role;