feat(tcg): Botsu Emoji TCG MVP — boosters, collection, graveyard

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
This commit is contained in:
2026-08-06 03:50:14 +02:00
parent d07dc35c03
commit f42134e1d0
22 changed files with 3901 additions and 5 deletions
+4 -1
View File
@@ -4,4 +4,7 @@ node_modules
devAssets
.DS_Store
.idea
.idea
# Emoji Kitchen stickers (symlinked from /srv/botsu-assets, not tracked in Git)
public/emoji-kitchen
+3
View File
@@ -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)
<Route path={_BOTSU_APPS_PATH} element={<BotsuLauncher />} />
<Route path={_BOTSU_DOCUMENTS_PATH} element={<BotsuDocuments />} />
<Route path={_BOTSU_DOCUMENT_PATH} element={<BotsuDocumentEditor />} />
<Route path={_BOTSU_TCG_PATH} element={<BotsuTcgPage />} />
<Route path={_BOTSU_SERVICES_PATH} element={<BotsuServices />} />
<Route path={_BOTSU_EMBED_PATH} element={<BotsuEmbed />} />
</Route>
+1
View File
@@ -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}`;
+1 -1
View File
@@ -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'),
+10 -1
View File
@@ -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 = (
+2 -1
View File
@@ -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;
};
+1 -1
View File
@@ -261,7 +261,7 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /<BotsuNavLabel icon=\{getAppNavIcon\(app\.id\)\}>/);
assert.match(nav, /<BotsuNavLabel icon=\{getServiceNavIcon\(service\.label\)\}>/);
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/);
+294
View File
@@ -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<TcgRarity, string> = {
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 (
<div className="botsu-tcg-card" data-rarity={card.rarity} data-shiny={card.shiny}>
<img
className="botsu-tcg-card-img"
src={`/presence/tcg/images/${card.imageUrl.replace('/emoji-kitchen/', '')}`}
alt={card.displayName}
loading="lazy"
/>
<span className="botsu-tcg-card-name">{card.displayName}</span>
<span className="botsu-tcg-card-rarity">{RARITY_LABELS[card.rarity]}</span>
{card.shiny && <span className="botsu-tcg-card-badge">Brillante</span>}
{card.isFirstDiscovery && <span className="botsu-tcg-discovery-badge">1ère découverte</span>}
</div>
);
}
function BoosterView() {
const mx = useMatrixClient();
const controller = useMemo<TcgController>(
() => createTcgController({ getOpenIdToken: () => mx.getOpenIdToken() }),
[mx],
);
const [status, setStatus] = useState<TcgDailyBoosterStatus | null>(null);
const [result, setResult] = useState<TcgBoosterResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="botsu-tcg-booster">
{error && <div className="botsu-tcg-error">{error}</div>}
{status && (
<div className="botsu-tcg-booster-status">
<div className="label">Booster quotidien</div>
<div className="value">
{status.available ? 'Disponible' : `Prochain dans ${formatResetCountdown(status.nextResetAt)}`}
</div>
</div>
)}
<button
type="button"
className="botsu-tcg-booster-btn"
onClick={openDaily}
disabled={loading || !status?.available}
>
{loading ? '…' : 'Ouvrir le booster'}
</button>
<button
type="button"
className="botsu-tcg-booster-purchase"
onClick={purchase}
disabled={loading}
>
Acheter un booster (1000 cookies)
</button>
{result && (
<div className="botsu-tcg-booster-reveal">
<div className="botsu-tcg-booster-reveal-cards">
{result.cards.map((card) => (
<CardView key={card.instanceId} card={card} />
))}
</div>
</div>
)}
</div>
);
}
function CollectionView() {
const mx = useMatrixClient();
const controller = useMemo<TcgController>(
() => createTcgController({ getOpenIdToken: () => mx.getOpenIdToken() }),
[mx],
);
const [page, setPage] = useState<TcgCollectionPage | null>(null);
const [pageNum, setPageNum] = useState(1);
const [rarity, setRarity] = useState<TcgRarity | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="botsu-tcg-filters">
<button
type="button"
className="botsu-tcg-filter-btn"
data-active={rarity === undefined}
onClick={() => { setRarity(undefined); setPageNum(1); }}
>
Toutes
</button>
{RARITIES.map((r) => (
<button
type="button"
key={r}
className="botsu-tcg-filter-btn"
data-active={rarity === r}
onClick={() => { setRarity(r); setPageNum(1); }}
>
{RARITY_LABELS[r]}
</button>
))}
</div>
{error && <div className="botsu-tcg-error">{error}</div>}
{loading && <div className="botsu-tcg-loading">Chargement</div>}
{page && !loading && (
<>
{page.cards.length === 0 ? (
<div className="botsu-tcg-empty">Aucune carte. Ouvre un booster pour commencer ta collection.</div>
) : (
<div className="botsu-tcg-cards">
{page.cards.map((card) => (
<CardView key={card.instanceId} card={card} />
))}
</div>
)}
<div className="botsu-tcg-pagination">
<button type="button" onClick={() => setPageNum((p) => Math.max(1, p - 1))} disabled={pageNum <= 1}>
Précédent
</button>
<span>{pageNum} / {Math.max(1, Math.ceil(page.total / 24))}</span>
<button type="button" onClick={() => setPageNum((p) => p + 1)} disabled={!page.hasNext}>
Suivant
</button>
</div>
</>
)}
</div>
);
}
function GraveyardView() {
const mx = useMatrixClient();
const controller = useMemo<TcgController>(
() => createTcgController({ getOpenIdToken: () => mx.getOpenIdToken() }),
[mx],
);
const [page, setPage] = useState<TcgCollectionPage | null>(null);
const [pageNum, setPageNum] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div>
{error && <div className="botsu-tcg-error">{error}</div>}
{loading && <div className="botsu-tcg-loading">Chargement</div>}
{page && !loading && (
<>
{page.cards.length === 0 ? (
<div className="botsu-tcg-empty">Le cimetière est vide. Aucune carte na é brûlée.</div>
) : (
<div className="botsu-tcg-cards">
{page.cards.map((card) => (
<CardView key={card.instanceId} card={card} />
))}
</div>
)}
<div className="botsu-tcg-pagination">
<button type="button" onClick={() => setPageNum((p) => Math.max(1, p - 1))} disabled={pageNum <= 1}>
Précédent
</button>
<span>{pageNum}</span>
<button type="button" onClick={() => setPageNum((p) => p + 1)} disabled={!page.hasNext}>
Suivant
</button>
</div>
</>
)}
</div>
);
}
export function BotsuTcgPage() {
const [tab, setTab] = useState<'booster' | 'collection' | 'graveyard'>('booster');
return (
<div className="botsu-tcg botsu-theme">
<div className="botsu-tcg-header">
<h1>Emoji TCG</h1>
<p>Collectionne les combinaisons Emoji Kitchen. Ouvre un booster quotidien gratuit ou achète-en avec tes cookies.</p>
</div>
<div className="botsu-tcg-tabs">
<button type="button" className="botsu-tcg-tab" data-active={tab === 'booster'} onClick={() => setTab('booster')}>
Booster
</button>
<button type="button" className="botsu-tcg-tab" data-active={tab === 'collection'} onClick={() => setTab('collection')}>
Collection
</button>
<button type="button" className="botsu-tcg-tab" data-active={tab === 'graveyard'} onClick={() => setTab('graveyard')}>
Cimetière
</button>
</div>
{tab === 'booster' && <BoosterView />}
{tab === 'collection' && <CollectionView />}
{tab === 'graveyard' && <GraveyardView />}
</div>
);
}
+149
View File
@@ -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<string, string> = {}): Record<string, string> => ({
authorization: `Bearer ${token.access_token}`,
...extra,
});
export type TcgControllerOptions = {
getOpenIdToken: () => Promise<TcgOpenIdToken>;
fetchImpl?: typeof fetch;
};
export type TcgController = {
getDailyStatus: () => Promise<TcgDailyBoosterStatus>;
openDailyBooster: () => Promise<TcgBoosterResult>;
purchaseBooster: (idempotencyKey?: string) => Promise<TcgBoosterResult>;
getCollection: (params?: { page?: number; pageSize?: number; rarity?: TcgRarity }) => Promise<TcgCollectionPage>;
getGraveyard: (params?: { page?: number; pageSize?: number }) => Promise<TcgCollectionPage>;
getDiscoveries: (params?: { page?: number; pageSize?: number }) => Promise<{ discoveries: unknown[]; total: number; hasNext: boolean }>;
burnCards: (instanceIds: string[], reason?: string) => Promise<TcgBurnResult>;
getCatalogStats: () => Promise<TcgCatalogStats>;
adminGrantBooster: (userId: string, reason: string) => Promise<TcgBoosterResult>;
getEconomyLog: (limit?: number) => Promise<{ entries: unknown[] }>;
};
export const createTcgController = (options: TcgControllerOptions): TcgController => {
const fetchImpl = options.fetchImpl ?? fetch;
const { getOpenIdToken } = options;
const get = async <T>(path: string): Promise<T> => {
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<T>;
};
const post = async <T>(path: string, body?: unknown): Promise<T> => {
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<T>;
};
return {
getDailyStatus: () => get<TcgDailyBoosterStatus>('/booster/daily/status'),
openDailyBooster: () => post<TcgBoosterResult>('/booster/daily/open'),
purchaseBooster: (idempotencyKey) =>
post<TcgBoosterResult>('/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<TcgCollectionPage>(`/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<TcgCollectionPage>(`/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<TcgBurnResult>('/burn', { instanceIds, reason: reason ?? 'burn_five_commons' }),
getCatalogStats: () => get<TcgCatalogStats>('/catalog/stats'),
adminGrantBooster: (userId, reason) =>
post<TcgBoosterResult>('/admin/grant-booster', { userId, reason }),
getEconomyLog: (limit) => get<{ entries: unknown[] }>(`/admin/economy-log?limit=${limit ?? 50}`),
};
};
+332
View File
@@ -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;
}
+50
View File
@@ -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<number> {
const playersDir = join(this.root, "players");
let total = 0;
+419
View File
@@ -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<string>;
};
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<string>();
// 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<boolean> => {
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<string, unknown>).discoveredBy = discovery.discoveredBy;
}
if (discovery?.discoveredByDisplayName !== undefined) {
(summary as Record<string, unknown>).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<string, unknown>).discoveredBy = discovery.discoveredBy;
}
if (discovery?.discoveredByDisplayName !== undefined) {
(summary as Record<string, unknown>).discoveredByDisplayName =
discovery.discoveredByDisplayName;
}
if (card.burnedAt !== undefined) {
(summary as Record<string, unknown>).burnedAt = card.burnedAt;
}
if (card.burnReason !== undefined) {
(summary as Record<string, unknown>).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",
+388
View File
@@ -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<string, Promise<unknown>>();
// Recently opened boosters keyed by userId — prevents replay from page refresh
private readonly recentBoosters = new Map<string, PendingBooster>();
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<T>(userId: string, fn: () => Promise<T>): Promise<T> {
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<TcgDailyBoosterStatus> {
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<TcgBoosterResult> {
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<TcgBoosterResult> {
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<TcgBoosterResult> {
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<TcgCardInstance[]> {
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<TcgBoosterResult> {
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,
};
}
}
+516
View File
@@ -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/<instanceId>.json one file per card instance
* discoveries/<combinationId>.json first-discovery records (unique constraint)
* daily/<userId>.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<void> => {
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<unknown> => {
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<TcgCardInstance> {
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<TcgCardInstance | undefined> {
const parsed = await tryReadJsonFile(this.cardPath(instanceId));
if (typeof parsed !== "object" || parsed === null) return undefined;
const record = parsed as Record<string, unknown>;
if (
record.version !== CARD_VERSION &&
typeof record.instanceId !== "string"
) {
return undefined;
}
return this.parseCardRecord(record);
}
private parseCardRecord(record: Record<string, unknown>): 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<string, unknown>);
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<string, unknown>);
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<TcgCardInstance[]> {
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<string, unknown>;
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<string, unknown>;
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<TcgDiscoveryRecord | undefined> {
const parsed = await tryReadJsonFile(this.discoveryPath(combinationId));
if (typeof parsed !== "object" || parsed === null) return undefined;
const record = parsed as Record<string, unknown>;
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<string, unknown>;
if (
typeof record.discoveredBy !== "string" &&
typeof (record as Record<string, unknown>).discoveredBy !== "string"
) {
continue;
}
const r = record as Record<string, unknown>;
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<DailyTrackerRecord | undefined> {
const parsed = await tryReadJsonFile(this.dailyPath(userId));
if (typeof parsed !== "object" || parsed === null) return undefined;
const record = parsed as Record<string, unknown>;
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<void> {
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,
};
}
}
+117
View File
@@ -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<string, TcgCatalogEntry>();
private byEmoji = new Map<string, TcgCatalogEntry[]>();
private loaded = false;
constructor(options: TcgCatalogStoreOptions) {
this.indexPath = options.indexPath;
this.emojiKitchenRoot = options.emojiKitchenRoot;
}
async load(): Promise<void> {
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;
}
}
+252
View File
@@ -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<string, string | undefined> = 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
};
+98
View File
@@ -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<void> = 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<void> {
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<TcgEconomyLogEntry[]> {
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 };
+217
View File
@@ -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<TcgCatalogIndex> => {
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<string, TcgCatalogEntry>();
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<string>();
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`);
}
}
+728
View File
@@ -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<string, unknown> = {}) => ({
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<string, unknown>; 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<any>[];
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<string>();
// 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 {};
+53
View File
@@ -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`.
+23
View File
@@ -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";
+243
View File
@@ -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<string, unknown>;
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}`;
};