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:
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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 été 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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`.
|
||||
@@ -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";
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user