-
+
+
+
{entry.player_id === playerId && (
{t("You")}
)}
@@ -158,9 +164,15 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
-
+
+
+
{formatNumber(entry.best_score)} / 25,000
diff --git a/components/MultiplayerGame.tsx b/components/MultiplayerGame.tsx
index 67ada09..d1ea298 100644
--- a/components/MultiplayerGame.tsx
+++ b/components/MultiplayerGame.tsx
@@ -142,6 +142,7 @@ export function MultiplayerGame({
diff --git a/components/MultiplayerRoom.tsx b/components/MultiplayerRoom.tsx
index 00ae8ef..fe2ea73 100644
--- a/components/MultiplayerRoom.tsx
+++ b/components/MultiplayerRoom.tsx
@@ -334,6 +334,7 @@ export function MultiplayerRoom({ id }: { id: string }) {
{player.id === room.hostId && (
@@ -441,6 +442,7 @@ export function MultiplayerRoom({ id }: { id: string }) {
value === effect);
if (avatarUrl)
return (
-
+
+
+
);
const seed = [...id].reduce(
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
@@ -53,26 +59,28 @@ export function PlayerAvatar({
const skin = ["#c99a77", "#a67b60", "#dab28d", "#875f4d"][(seed >>> 3) % 4];
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
return (
-
+
+
+
);
}
diff --git a/lib/account-api.ts b/lib/account-api.ts
index 3d92be3..ec4efef 100644
--- a/lib/account-api.ts
+++ b/lib/account-api.ts
@@ -181,7 +181,7 @@ export async function accountApi(
.update({ avatar_path: avatarPath })
.eq("id", account.id)
.select(
- "id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
+ "id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
)
.single(),
);
@@ -269,10 +269,11 @@ export async function accountApi(
...(body.avatarPath !== undefined
? { avatar_path: body.avatarPath }
: {}),
+ ...(body.bio !== undefined ? { bio: body.bio } : {}),
})
.eq("id", account.id)
.select(
- "id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
+ "id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
)
.single(),
);
diff --git a/lib/api.ts b/lib/api.ts
index 0e46c73..ac81179 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -18,6 +18,7 @@ import { requestOrigin, verifyOrigin } from "./request-origin";
import { browserAccount } from "./request-security";
import { jsonBody } from "./json-body";
import { accountApi } from "./account-api";
+import { cosmeticsApi } from "./cosmetics-api";
import { multiplayerApi } from "./multiplayer-api";
import { bugReportApi } from "./bug-report-api";
import { moderatorUsersApi } from "./moderator-users-api";
@@ -131,6 +132,8 @@ export async function handle(request: NextRequest, path: string[]) {
return await accountControlsApi(request, path, { jsonBody, limit });
if (path[0] === "auth" || path[0] === "profile")
return await accountApi(request, path, { jsonBody, limit });
+ if (path[0] === "cosmetics")
+ return await cosmeticsApi(request, path, { jsonBody, limit });
// Check signed-in identities before any guest-cookie fallback (games/rooms).
if (path[0] !== "health") await user();
if (path[0] === "rooms")
diff --git a/lib/catalog.ts b/lib/catalog.ts
index 3d30200..d60d2e0 100644
--- a/lib/catalog.ts
+++ b/lib/catalog.ts
@@ -64,7 +64,7 @@ export async function catalog() {
await client
.from("profiles")
.select(
- "id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ "id,display_name,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", [
...new Set(
@@ -106,7 +106,7 @@ export async function getMap(id: string) {
? admin()
.from("profiles")
.select(
- "display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ "display_name,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.eq("id", map.owner_id)
.maybeSingle()
diff --git a/lib/cosmetics-api.ts b/lib/cosmetics-api.ts
new file mode 100644
index 0000000..795de3c
--- /dev/null
+++ b/lib/cosmetics-api.ts
@@ -0,0 +1,52 @@
+import "server-only";
+import { NextRequest, NextResponse } from "next/server";
+import { profileCosmeticActionSchema } from "@/shared/profile-cosmetics";
+import { admin, required, user } from "./supabase";
+import { browserAccount } from "./request-security";
+import { profileCosmeticsState } from "./profile-cosmetics";
+
+type Context = {
+ jsonBody: (request: NextRequest) => Promise;
+ limit: (key: string, count: number, seconds: number) => Promise;
+};
+
+const json = (body: unknown, status = 200) =>
+ NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } });
+
+export async function cosmeticsApi(
+ request: NextRequest,
+ path: string[],
+ context: Context,
+) {
+ if (path.length !== 1) return json({ error: "Not found" }, 404);
+ const account = await browserAccount(request, user);
+ if (request.method === "GET")
+ return json({ shop: await profileCosmeticsState(account.id) });
+ if (request.method !== "POST")
+ return json({ error: "Method not allowed" }, 405);
+
+ const body = profileCosmeticActionSchema.parse(
+ await context.jsonBody(request),
+ );
+ if (body.action === "purchase") {
+ await context.limit("cosmetic-purchase:" + account.id, 8, 60);
+ return json({
+ shop: required(
+ await admin().rpc("buy_profile_cosmetic", {
+ p_user: account.id,
+ p_cosmetic_id: body.cosmeticId,
+ }),
+ ),
+ });
+ }
+
+ await context.limit("cosmetic-equip:" + account.id, 30, 60);
+ return json({
+ shop: required(
+ await admin().rpc("equip_profile_cosmetic", {
+ p_user: account.id,
+ p_cosmetic_id: body.cosmeticId,
+ }),
+ ),
+ });
+}
diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts
index 59b2be5..8d5ae58 100644
--- a/lib/leaderboard.ts
+++ b/lib/leaderboard.ts
@@ -35,7 +35,7 @@ export async function leaderboard({
await admin()
.from("profiles")
.select(
- "id,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ "id,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", ids),
)
diff --git a/lib/multiplayer-appearance.ts b/lib/multiplayer-appearance.ts
index d8d413e..41c1f91 100644
--- a/lib/multiplayer-appearance.ts
+++ b/lib/multiplayer-appearance.ts
@@ -25,7 +25,7 @@ export async function multiplayerAppearance(room: string, ids: string[]) {
await db
.from("profiles")
.select(
- "id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
+ "id,display_name,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
)
.in("id", accounts),
);
diff --git a/lib/profile-cosmetics.ts b/lib/profile-cosmetics.ts
new file mode 100644
index 0000000..1855076
--- /dev/null
+++ b/lib/profile-cosmetics.ts
@@ -0,0 +1,15 @@
+import "server-only";
+import { admin, required } from "./supabase";
+import {
+ cosmeticShopStateSchema,
+ type CosmeticShopState,
+} from "@/shared/profile-cosmetics";
+
+export async function profileCosmeticsState(
+ userId: string,
+): Promise {
+ const result = required(
+ await admin().rpc("cosmetics_shop_state", { p_user: userId }),
+ );
+ return cosmeticShopStateSchema.parse(result);
+}
diff --git a/lib/profile.ts b/lib/profile.ts
index 95363cf..e138703 100644
--- a/lib/profile.ts
+++ b/lib/profile.ts
@@ -21,7 +21,7 @@ export async function accountProfile(account: User) {
await admin()
.from("profiles")
.select(
- "id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
+ "id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
)
.eq("id", account.id)
.single(),
diff --git a/lib/public-profile.ts b/lib/public-profile.ts
new file mode 100644
index 0000000..327b184
--- /dev/null
+++ b/lib/public-profile.ts
@@ -0,0 +1,41 @@
+import "server-only";
+import { z } from "zod";
+import { admin } from "./supabase";
+
+const uuidSchema = z.string().uuid();
+
+export async function publicProfile(userId: string) {
+ const validId = uuidSchema.safeParse(userId);
+ if (!validId.success) return null;
+
+ const result = await admin()
+ .from("profiles")
+ .select(
+ "id,display_name,bio,avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon,created_at",
+ )
+ .eq("id", validId.data)
+ .eq("account_status", "active")
+ .maybeSingle();
+ if (result.error) throw new Error(result.error.message);
+ if (!result.data) return null;
+
+ const [games, maps] = await Promise.all([
+ admin()
+ .from("game_sessions")
+ .select("id", { count: "exact", head: true })
+ .eq("owner_id", validId.data)
+ .eq("current_round", 5),
+ admin()
+ .from("datasets")
+ .select("id", { count: "exact", head: true })
+ .eq("owner_id", validId.data)
+ .eq("state", "published"),
+ ]);
+ if (games.error) throw new Error(games.error.message);
+ if (maps.error) throw new Error(maps.error.message);
+ return {
+ ...result.data,
+ completedGames: games.count ?? 0,
+ publishedMaps: maps.count ?? 0,
+ };
+}
diff --git a/shared/account.ts b/shared/account.ts
index c971fd7..8f099c2 100644
--- a/shared/account.ts
+++ b/shared/account.ts
@@ -43,13 +43,26 @@ export const profileUpdateSchema = z
displayName: displayNameSchema.optional(),
glowColor: glowColorSchema.optional(),
avatarPath: z.string().max(128).nullable().optional(),
+ bio: z
+ .string()
+ .trim()
+ .max(280, "Use at most 280 characters")
+ .refine(
+ (value) =>
+ !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069<>]/u.test(
+ value,
+ ),
+ "Use plain text without hidden controls or markup",
+ )
+ .optional(),
})
.strict()
.refine(
(value) =>
value.displayName !== undefined ||
value.glowColor !== undefined ||
- value.avatarPath !== undefined,
+ value.avatarPath !== undefined ||
+ value.bio !== undefined,
"Choose a profile change",
);
export const initialPasswordSchema = z
diff --git a/shared/database.ts b/shared/database.ts
index 47fb03c..0208b31 100644
--- a/shared/database.ts
+++ b/shared/database.ts
@@ -568,6 +568,8 @@ export type Database = {
profiles: {
Row: AccountStatusFields & {
avatar_path: string | null;
+ bio: string;
+ profile_effect: string | null;
glow_color: string;
bug_finder: boolean;
custom_prefix: string | null;
@@ -580,6 +582,8 @@ export type Database = {
};
Insert: Partial & {
avatar_path?: string | null;
+ bio?: string;
+ profile_effect?: string | null;
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
@@ -592,6 +596,8 @@ export type Database = {
};
Update: Partial & {
avatar_path?: string | null;
+ bio?: string;
+ profile_effect?: string | null;
glow_color?: string;
bug_finder?: boolean;
custom_prefix?: string | null;
@@ -782,6 +788,18 @@ export type Database = {
[_ in never]: never;
};
Functions: {
+ cosmetics_shop_state: {
+ Args: { p_user: string };
+ Returns: Json;
+ };
+ buy_profile_cosmetic: {
+ Args: { p_user: string; p_cosmetic_id: string };
+ Returns: Json;
+ };
+ equip_profile_cosmetic: {
+ Args: { p_user: string; p_cosmetic_id: string | null };
+ Returns: Json;
+ };
admin_account_action: {
Args: {
p_reviewer: string;
diff --git a/shared/locales/ru.json b/shared/locales/ru.json
index 100c4f3..4036143 100644
--- a/shared/locales/ru.json
+++ b/shared/locales/ru.json
@@ -10,6 +10,55 @@
"Guide": "Как играть",
"Docs": "Документация",
"My account": "Мой аккаунт",
+ "Profile cosmetics": "Косметика профиля",
+ "Browse profile cosmetics": "Открыть магазин косметики",
+ "View public profile": "Открыть публичный профиль",
+ "About you": "О себе",
+ "Tell other explorers a little about yourself.": "Расскажите другим исследователям немного о себе.",
+ "Shown on your public profile · Maximum 280 characters.": "Отображается в публичном профиле · Не более 280 символов.",
+ "Save public profile": "Сохранить публичный профиль",
+ "Public profile saved.": "Публичный профиль сохранён.",
+ "A name and description for other explorers to see.": "Имя и описание, которые увидят другие исследователи.",
+ "Back to leaderboard": "Назад к рейтингу",
+ "MINEGEO EXPLORER": "ИССЛЕДОВАТЕЛЬ MINEGEO",
+ "Exploring since {date}": "Исследует миры с {date}",
+ "About this explorer": "Об исследователе",
+ "This explorer hasn’t added a description yet.": "Исследователь пока не добавил описание.",
+ "Profile stats": "Статистика профиля",
+ "Completed journeys": "Завершённые экспедиции",
+ "Published maps": "Опубликованные карты",
+ "Only public account details and published maps appear here.": "Здесь отображаются только публичные данные аккаунта и опубликованные карты.",
+ "THE EXPLORER’S WARDROBE": "ГАРДЕРОБ ИССЛЕДОВАТЕЛЯ",
+ "Leave a little sparkle.": "Добавь немного сияния.",
+ "Earn GeoCoins in solo expeditions and dress up your profile portrait.": "Зарабатывай GeoCoins в одиночных экспедициях и укрась портрет профиля.",
+ "GeoCoins balance": "Баланс GeoCoins",
+ "YOUR BALANCE": "ТВОЙ БАЛАНС",
+ "GeoCoins": "GeoCoins",
+ "Complete a public solo game to earn 10–20 GeoCoins. Rewards are capped at 100 every 24 hours.": "Заверши одиночную игру на публичной карте и получи 10–20 GeoCoins. Максимум — 100 за каждые 24 часа.",
+ "common": "обычный",
+ "rare": "редкий",
+ "epic": "эпический",
+ "legendary": "легендарный",
+ "Equipped": "Надето",
+ "Cosmetic purchased and equipped.": "Эффект куплен и надет.",
+ "Cosmetic equipped.": "Эффект надет.",
+ "Profile effect removed.": "Эффект профиля снят.",
+ "Not enough GeoCoins": "Недостаточно GeoCoins.",
+ "Cosmetic is not available": "Эта косметика сейчас недоступна.",
+ "Cosmetic not owned": "Сначала купи этот эффект.",
+ "Buy · {price} GeoCoins": "Купить · {price} GeoCoins",
+ "Unequip effect": "Снять эффект",
+ "Equip effect": "Надеть эффект",
+ "Buying…": "Покупка…",
+ "Cosmetics use GeoCoins for now. Payment options may be added later.": "Пока косметика покупается за GeoCoins. Позже могут появиться другие способы оплаты.",
+ "Redstone Pulse": "Импульс редстоуна",
+ "A redstone signal ripples around your portrait.": "Вокруг портрета пробегает импульс редстоуна.",
+ "Ender Orbit": "Орбита Края",
+ "Two ender sparks circle your portrait.": "Вокруг портрета вращаются две искры Края.",
+ "Nether Flame": "Пламя Незера",
+ "A warm nether flame flickers along the frame.": "По рамке мерцает жаркое пламя Незера.",
+ "Aurora Crown": "Корона сияния",
+ "A living aurora sweeps around your portrait.": "Вокруг портрета переливается северное сияние.",
"Log in": "Войти",
"Tools & downloads": "Инструменты",
"Moderation": "Модерация",
diff --git a/shared/profile-appearance.ts b/shared/profile-appearance.ts
index ff4b718..f8079b3 100644
--- a/shared/profile-appearance.ts
+++ b/shared/profile-appearance.ts
@@ -36,6 +36,7 @@ export const prefixUpdateSchema = z
.strict();
export type ProfileAppearance = {
avatar_path?: string | null;
+ profile_effect?: string | null;
name_glow?: boolean;
glow_color?: string;
role?: string;
@@ -44,7 +45,7 @@ export type ProfileAppearance = {
custom_prefix_icon?: string;
};
export const appearanceColumns =
- "avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
+ "avatar_path,profile_effect,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
export type BadgeUser = ProfileAppearance & {
id: string;
display_name: string;
diff --git a/shared/profile-cosmetic-ids.ts b/shared/profile-cosmetic-ids.ts
new file mode 100644
index 0000000..70d13ae
--- /dev/null
+++ b/shared/profile-cosmetic-ids.ts
@@ -0,0 +1,8 @@
+export const profileCosmeticIds = [
+ "redstone-pulse",
+ "ender-orbit",
+ "nether-flame",
+ "aurora-crown",
+] as const;
+
+export type ProfileCosmeticId = (typeof profileCosmeticIds)[number];
diff --git a/shared/profile-cosmetics.ts b/shared/profile-cosmetics.ts
new file mode 100644
index 0000000..0490b79
--- /dev/null
+++ b/shared/profile-cosmetics.ts
@@ -0,0 +1,41 @@
+import { z } from "zod";
+import { profileCosmeticIds } from "./profile-cosmetic-ids";
+
+export const profileCosmeticIdSchema = z.enum(profileCosmeticIds);
+export type { ProfileCosmeticId } from "./profile-cosmetic-ids";
+
+export const profileCosmeticActionSchema = z.discriminatedUnion("action", [
+ z
+ .object({
+ action: z.literal("purchase"),
+ cosmeticId: profileCosmeticIdSchema,
+ })
+ .strict(),
+ z
+ .object({
+ action: z.literal("equip"),
+ cosmeticId: profileCosmeticIdSchema.nullable(),
+ })
+ .strict(),
+]);
+
+export const cosmeticShopStateSchema = z
+ .object({
+ balance: z.number().int().nonnegative(),
+ owned: z.array(profileCosmeticIdSchema),
+ equipped: profileCosmeticIdSchema.nullable(),
+ catalog: z.array(
+ z
+ .object({
+ id: profileCosmeticIdSchema,
+ name: z.string().min(1).max(48),
+ description: z.string().min(1).max(160),
+ price: z.number().int().positive(),
+ rarity: z.enum(["common", "rare", "epic", "legendary"]),
+ })
+ .strict(),
+ ),
+ })
+ .strict();
+
+export type CosmeticShopState = z.infer;
diff --git a/supabase/migrations/20261015121852_profile_cosmetics_and_currency.sql b/supabase/migrations/20261015121852_profile_cosmetics_and_currency.sql
new file mode 100644
index 0000000..cfbdca3
--- /dev/null
+++ b/supabase/migrations/20261015121852_profile_cosmetics_and_currency.sql
@@ -0,0 +1,333 @@
+-- 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;