Wave 8 closes the high-value gaps from the original spec while staying
inside MVP scope (encounters/weapons/items still future work).
Spell generator (mirrors the NPC pattern end-to-end)
- src/lib/ai/types.ts: spellParamsSchema, spellResultSchema, Open5eSpell
- src/lib/ai/openrouter.ts: generateSpell using llama-3.3-70b:free for FREE
- src/lib/ai/kimi.ts: generateSpell using moonshot-v1-8k with json_schema
response_format for PRO
- src/pages/api/dm/ai/generate-spell.ts: auth → rate-limit → optional
/spells/ Open5e reference for balance → AI generate → Zod validate →
increment counter. Spells are returned to client and not persisted
(spec only requires NPC persistence for PRO).
- src/components/dm/AiSpellForm.astro: form (level, school, classes,
tone, suggested name) + inline result card rendering with copy-JSON
- src/components/dm/AiKindSwitcher.astro: segmented NPC | Spell control
wrapping both forms; default tab is NPC
Open5e Reference: equipment + magic items tabs
- src/lib/open5e/client.ts: searchEquipment, getEquipmentItem,
searchMagicItems, getMagicItem (plus EquipmentItem / MagicItem types)
- src/lib/client/open5e-ui.ts: Tab type extended to four, search() and
selectItem() dispatch all four
- src/components/dm/Open5eReference.astro: two more tab buttons; new
renderEquipmentCard/Detail and renderMagicItemCard/Detail; filter
dropdown hidden for new tabs (search-by-name only)
- src/lib/client/translation.ts: TranslationType union now includes
"equipment" | "magicitem"
- src/lib/ai/openrouter.ts: translateOpen5eContent type widened (export
Open5eContentType)
- src/pages/api/dm/translate.ts: ALLOWED_TYPES adds equipment/magicitem
and dispatches to the right Open5e fetcher
UX polish
- src/components/dm/AiQuotaExhausted.astro: dedicated state shown to
authenticated FREE users with remaining=0 — replaces the form with
reset-time message and Boosty upgrade hint
- src/pages/{dm,ru/dm}/index.astro: render AiQuotaExhausted when the
guard passes; otherwise render the kind switcher
- src/middleware/index.ts: remove [Middleware] debug console.logs that
were noisy in prod logs (one line per request)
Verified locally: tsc 0 errors, lint 0 errors, vitest 339/339 passing,
npm run build succeeds without auth env vars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
79 lines
1.8 KiB
TypeScript
79 lines
1.8 KiB
TypeScript
export type TranslationType = "creature" | "spell" | "equipment" | "magicitem";
|
|
|
|
export interface TranslationEntry {
|
|
slug: string;
|
|
type: TranslationType;
|
|
data: Record<string, unknown>;
|
|
timestamp: number;
|
|
}
|
|
|
|
const CACHE = new Map<string, TranslationEntry>();
|
|
|
|
const CHANNEL =
|
|
typeof BroadcastChannel !== "undefined"
|
|
? new BroadcastChannel("dm-translations")
|
|
: null;
|
|
|
|
if (CHANNEL) {
|
|
CHANNEL.addEventListener("message", (e) => {
|
|
const entry = e.data as TranslationEntry;
|
|
CACHE.set(`${entry.type}:${entry.slug}`, entry);
|
|
});
|
|
}
|
|
|
|
export function getCachedTranslation(
|
|
slug: string,
|
|
type: TranslationType,
|
|
): Record<string, unknown> | null {
|
|
return CACHE.get(`${type}:${slug}`)?.data ?? null;
|
|
}
|
|
|
|
export function setCachedTranslation(
|
|
slug: string,
|
|
type: TranslationType,
|
|
data: Record<string, unknown>,
|
|
): void {
|
|
const entry: TranslationEntry = {
|
|
slug,
|
|
type,
|
|
data,
|
|
timestamp: Date.now(),
|
|
};
|
|
CACHE.set(`${type}:${slug}`, entry);
|
|
CHANNEL?.postMessage(entry);
|
|
}
|
|
|
|
export async function fetchTranslation(
|
|
slug: string,
|
|
type: TranslationType,
|
|
): Promise<Record<string, unknown> | null> {
|
|
const cached = getCachedTranslation(slug, type);
|
|
if (cached) return cached;
|
|
|
|
const res = await fetch(
|
|
`/api/dm/translate?slug=${encodeURIComponent(slug)}&type=${type}`,
|
|
);
|
|
const data = (await res.json().catch(() => ({}))) as {
|
|
error?: string;
|
|
translated?: Record<string, unknown>;
|
|
};
|
|
|
|
if (!res.ok || data.error) {
|
|
throw new Error(data.error || "Translation failed");
|
|
}
|
|
|
|
const translated = data.translated ?? null;
|
|
if (translated) {
|
|
setCachedTranslation(slug, type, translated);
|
|
}
|
|
return translated;
|
|
}
|
|
|
|
export function clearTranslationCache(): void {
|
|
CACHE.clear();
|
|
}
|
|
|
|
export function getTranslationCacheSize(): number {
|
|
return CACHE.size;
|
|
}
|