feat(dm-dashboard): Wave 8 — spell generator + Open5e equipment/magicitems + UX polish

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>
This commit is contained in:
emil
2026-05-16 02:47:41 +03:00
co-authored by Claude Opus 4.7
parent e6dec1eb95
commit cce582df6c
16 changed files with 1299 additions and 68 deletions
+69
View File
@@ -0,0 +1,69 @@
---
import AiNpcForm from "./AiNpcForm.astro";
import AiSpellForm from "./AiSpellForm.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
---
<div data-testid="ai-kind-switcher">
<div class="inline-flex rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] p-1 mb-4" role="tablist" aria-label="Тип генератора">
<button
type="button"
role="tab"
id="ai-kind-npc"
aria-selected="true"
aria-controls="ai-kind-panel-npc"
class="px-4 py-1.5 text-sm font-semibold rounded-md transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]"
data-kind="npc"
data-testid="ai-kind-tab-npc"
>
{T.aiKindNpc}
</button>
<button
type="button"
role="tab"
id="ai-kind-spell"
aria-selected="false"
aria-controls="ai-kind-panel-spell"
class="px-4 py-1.5 text-sm font-semibold rounded-md transition-colors text-[var(--text-secondary)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]"
data-kind="spell"
data-testid="ai-kind-tab-spell"
>
{T.aiKindSpell}
</button>
</div>
<div id="ai-kind-panel-npc" role="tabpanel" aria-labelledby="ai-kind-npc">
<AiNpcForm />
</div>
<div id="ai-kind-panel-spell" role="tabpanel" aria-labelledby="ai-kind-spell" class="hidden">
<AiSpellForm />
</div>
</div>
<script is:inline>
(function () {
const npcTab = document.getElementById("ai-kind-npc");
const spellTab = document.getElementById("ai-kind-spell");
const npcPanel = document.getElementById("ai-kind-panel-npc");
const spellPanel = document.getElementById("ai-kind-panel-spell");
if (!npcTab || !spellTab || !npcPanel || !spellPanel) return;
const activeClasses =
"px-4 py-1.5 text-sm font-semibold rounded-md transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]";
const inactiveClasses =
"px-4 py-1.5 text-sm font-semibold rounded-md transition-colors text-[var(--text-secondary)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]";
function setKind(kind) {
const isNpc = kind === "npc";
npcTab.className = isNpc ? activeClasses : inactiveClasses;
spellTab.className = isNpc ? inactiveClasses : activeClasses;
npcTab.setAttribute("aria-selected", String(isNpc));
spellTab.setAttribute("aria-selected", String(!isNpc));
npcPanel.classList.toggle("hidden", !isNpc);
spellPanel.classList.toggle("hidden", isNpc);
}
npcTab.addEventListener("click", () => setKind("npc"));
spellTab.addEventListener("click", () => setKind("spell"));
})();
</script>
+33
View File
@@ -0,0 +1,33 @@
---
import DmCard from "./DmCard.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
export interface Props {
resetAt: Date | string | null;
}
const { resetAt } = Astro.props;
function minutesUntil(target: Date | string | null): number {
if (!target) return 60;
const t = typeof target === "string" ? new Date(target) : target;
const diffMs = t.getTime() - Date.now();
return Math.max(1, Math.ceil(diffMs / 60000));
}
const minutes = minutesUntil(resetAt);
const resetMsg = T.aiQuotaResetsIn.replace("{minutes}", String(minutes));
---
<DmCard padding="lg" dataTestid="ai-quota-exhausted">
<div class="text-center space-y-3">
<div class="text-4xl" aria-hidden="true">⏳</div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">
{T.aiQuotaExhausted}
</h3>
<p class="text-sm text-[var(--text-secondary)]">{resetMsg}</p>
<p class="text-sm text-[var(--text-cream)] pt-2 border-t border-[var(--border-color)]">
{T.aiQuotaUpgradeHint}
</p>
</div>
</DmCard>
+305
View File
@@ -0,0 +1,305 @@
---
import DmCard from "./DmCard.astro";
import DmButton from "./DmButton.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
export interface Props {
disabled?: boolean;
}
const { disabled = false } = Astro.props;
const selectClasses =
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] appearance-none cursor-pointer";
const inputClasses =
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)]";
const labelClasses = "block text-sm font-medium text-[var(--text-secondary)] mb-1";
---
<DmCard dataTestid="ai-spell-form-card">
<form id="ai-spell-form" class="space-y-4" data-testid="ai-spell-form">
<div>
<label for="spell-name" class={labelClasses}>{T.spellFormName}</label>
<input
id="spell-name"
name="name"
type="text"
placeholder=""
class={inputClasses}
disabled={disabled}
/>
</div>
<div>
<label for="spell-level" class={labelClasses}>{T.spellFormLevel}</label>
<select id="spell-level" name="level" class={selectClasses} required disabled={disabled}>
<option value="0">{T.spellCantripLabel}</option>
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
</select>
</div>
<div>
<label for="spell-school" class={labelClasses}>{T.spellFormSchool}</label>
<select id="spell-school" name="school" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Evocation">{T.schoolEvocation}</option>
<option value="Abjuration">{T.schoolAbjuration}</option>
<option value="Conjuration">{T.schoolConjuration}</option>
<option value="Divination">{T.schoolDivination}</option>
<option value="Enchantment">{T.schoolEnchantment}</option>
<option value="Illusion">{T.schoolIllusion}</option>
<option value="Necromancy">{T.schoolNecromancy}</option>
<option value="Transmutation">{T.schoolTransmutation}</option>
</select>
</div>
<div>
<label for="spell-classes" class={labelClasses}>{T.spellFormClasses}</label>
<input
id="spell-classes"
name="classes"
type="text"
placeholder="Wizard, Sorcerer"
class={inputClasses}
disabled={disabled}
/>
</div>
<div>
<label for="spell-tone" class={labelClasses}>{T.spellFormTone}</label>
<select id="spell-tone" name="tone" class={selectClasses} disabled={disabled}>
<option value="">{T.aiFormSelectOption}</option>
<option value="Serious">{T.toneSerious}</option>
<option value="Humorous">{T.toneHumorous}</option>
<option value="Dark">{T.toneDark}</option>
<option value="Heroic">{T.toneHeroic}</option>
<option value="Mysterious">{T.toneMysterious}</option>
</select>
</div>
<div class="flex items-center gap-3">
<input
id="spell-use-reference"
name="useOpen5eReference"
type="checkbox"
class="w-5 h-5 rounded border-[var(--border-color)] bg-[var(--bg-input)] text-[var(--accent)] accent-[var(--accent)] focus:ring-[var(--gold)] focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--bg-primary)] cursor-pointer"
disabled={disabled}
/>
<label for="spell-use-reference" class="text-sm text-[var(--text-secondary)] cursor-pointer">
{T.aiFormUseReference}
</label>
</div>
<div class="pt-2">
<DmButton
id="ai-spell-submit"
type="submit"
variant="primary"
size="md"
class="w-full"
disabled={disabled}
dataTestid="ai-spell-submit"
>
{T.spellFormSubmit}
</DmButton>
</div>
<div
id="ai-spell-status"
class="text-sm text-center min-h-[1.5rem]"
aria-live="polite"
data-testid="ai-spell-status"
></div>
</form>
</DmCard>
<div id="ai-spell-result" class="mt-4 hidden" data-testid="ai-spell-result"></div>
<script is:inline define:vars={{
generatingLabel: T.spellGenerating,
submitLabel: T.spellGenerate,
rateLimitTemplate: T.spellErrorRateLimit,
genericError: T.spellErrorGeneric,
labels: {
level: T.spellLabelLevel,
school: T.spellLabelSchool,
castingTime: T.spellLabelCastingTime,
range: T.spellLabelRange,
components: T.spellLabelComponents,
duration: T.spellLabelDuration,
classes: T.spellLabelClasses,
description: T.spellLabelDescription,
higherLevels: T.spellLabelHigherLevels,
cantrip: T.spellCantripLabel,
copy: T.spellCopyJson,
copied: T.spellCopied,
},
}}>
(function () {
const form = document.getElementById("ai-spell-form");
const submitBtn = document.getElementById("ai-spell-submit");
const statusEl = document.getElementById("ai-spell-status");
const resultEl = document.getElementById("ai-spell-result");
if (!form || !submitBtn || !statusEl || !resultEl) return;
let isSubmitting = false;
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = String(s ?? "");
return div.innerHTML;
}
function formatLevel(level) {
return level === 0 ? labels.cantrip : `${level} ${labels.level}`;
}
function renderSpell(spell) {
const json = JSON.stringify(spell, null, 2);
const higherLevelsBlock = spell.higher_levels
? `<div class="mt-3">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.higherLevels)}</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">${escapeHtml(spell.higher_levels)}</p>
</div>`
: "";
resultEl.innerHTML = `
<div class="rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5" data-testid="ai-spell-card">
<div class="flex items-start justify-between gap-4 mb-4">
<div class="min-w-0">
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">${escapeHtml(spell.name)}</h3>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(formatLevel(spell.level))} · ${escapeHtml(spell.school)}</p>
</div>
<button
type="button"
id="spell-copy-btn"
class="inline-flex items-center justify-center px-4 py-1.5 text-sm font-semibold rounded-lg bg-[var(--bg-card)] text-[var(--text-primary)] border border-[var(--border-color)] hover:border-[var(--gold)] hover:text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] transition-all"
data-testid="spell-copy-btn"
>${escapeHtml(labels.copy)}</button>
</div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.casting_time)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.castingTime)}</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.range)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.range)}</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.components)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.components)}</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.duration)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.duration)}</p>
</div>
</div>
<div class="mb-3">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.classes)}</div>
<p class="text-sm text-[var(--text-primary)]">${escapeHtml(spell.classes)}</p>
</div>
<div>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.description)}</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed whitespace-pre-line">${escapeHtml(spell.description)}</p>
</div>
${higherLevelsBlock}
</div>
`;
resultEl.classList.remove("hidden");
const copyBtn = document.getElementById("spell-copy-btn");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(json);
const originalText = copyBtn.textContent || "";
copyBtn.textContent = labels.copied;
setTimeout(() => { copyBtn.textContent = originalText; }, 1500);
} catch { /* ignore */ }
});
}
}
function setStatus(message, type) {
statusEl.textContent = message;
statusEl.className =
"text-sm text-center min-h-[1.5rem] " +
(type === "error" ? "text-[var(--danger)]" : "text-[var(--text-secondary)]");
}
function setLoading(loading) {
isSubmitting = loading;
submitBtn.disabled = loading;
submitBtn.textContent = loading ? generatingLabel : submitLabel;
submitBtn.setAttribute("aria-busy", loading ? "true" : "false");
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (isSubmitting) return;
const formData = new FormData(form);
const nameValue = String(formData.get("name") ?? "").trim();
const classesValue = String(formData.get("classes") ?? "").trim();
const toneValue = String(formData.get("tone") ?? "").trim();
const body = {
level: parseInt(String(formData.get("level")), 10),
school: String(formData.get("school")),
useOpen5eReference: formData.get("useOpen5eReference") === "on",
};
if (nameValue) body.name = nameValue;
if (classesValue) body.classes = classesValue;
if (toneValue) body.tone = toneValue;
setLoading(true);
setStatus("");
try {
const response = await fetch("/api/dm/ai/generate-spell", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const minutes = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 60) : 60;
setStatus(rateLimitTemplate.replace("{minutes}", String(minutes)), "error");
setLoading(false);
return;
}
if (!response.ok) {
setStatus(genericError, "error");
setLoading(false);
return;
}
const result = await response.json();
if (!result.spell) {
setStatus(genericError, "error");
setLoading(false);
return;
}
setStatus("");
renderSpell(result.spell);
window.dispatchEvent(new CustomEvent("spell-generated", { detail: result.spell, bubbles: true }));
} catch {
setStatus(genericError, "error");
} finally {
setLoading(false);
}
});
})();
</script>
+215 -27
View File
@@ -31,6 +31,30 @@ import DmButton from "./DmButton.astro";
>
Заклинания
</button>
<button
id="o5-tab-equipment"
type="button"
role="tab"
aria-selected="false"
aria-controls="o5-panel-equipment"
class="flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-tab="equipment"
data-testid="ref-tab-equipment"
>
Снаряжение
</button>
<button
id="o5-tab-magicitems"
type="button"
role="tab"
aria-selected="false"
aria-controls="o5-panel-magicitems"
class="flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-tab="magicitems"
data-testid="ref-tab-magicitems"
>
Магические
</button>
</div>
<!-- Search and filters -->
@@ -225,18 +249,41 @@ import DmButton from "./DmButton.astro";
desc?: string;
}
interface EquipmentItem {
key: string;
name: string;
category?: string;
cost?: string | number | Record<string, unknown>;
weight?: string | number;
damage?: string | Record<string, unknown>;
properties?: string[] | Record<string, unknown>[];
desc?: string;
}
interface MagicItem {
key: string;
name: string;
type?: string;
rarity?: string;
requires_attunement?: string;
desc?: string;
}
type AnyItem = Monster | Spell | EquipmentItem | MagicItem;
type ItemType = "creature" | "spell" | "equipment" | "magicitem";
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: Record<string, unknown> | null): T {
function mergeWithTranslation<T extends AnyItem>(item: T, translated: Record<string, unknown> | null): T {
if (!translated) return item;
return { ...item, ...translated } as T;
}
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
function translateButtonHtml(key: string, type: ItemType, isTranslated: boolean): string {
const label = isTranslated ? "Переведено" : "Перевести";
const disabled = isTranslated ? "disabled" : "";
return `
@@ -339,6 +386,91 @@ import DmButton from "./DmButton.astro";
return html;
}
function formatCost(cost: EquipmentItem["cost"]): string {
if (cost == null) return "—";
if (typeof cost === "string" || typeof cost === "number") return String(cost);
if (typeof cost === "object") {
const quantity = (cost as Record<string, unknown>).quantity;
const unit = (cost as Record<string, unknown>).unit;
if (quantity != null && unit != null) return `${quantity} ${unit}`;
}
return "—";
}
function renderEquipmentCard(item: EquipmentItem): string {
const cost = formatCost(item.cost);
return `
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5 hover:border-[var(--border-gold)] hover:-translate-y-0.5 transition-all duration-[var(--transition-base)] group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-key="${item.key}" role="button" tabindex="0" data-testid="ref-result-card">
<div class="min-w-0">
<p class="font-semibold text-[var(--gold)] group-hover:text-[var(--gold-hover)] transition-colors truncate">
${escapeHtml(item.name)}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
${escapeHtml(item.category || "—")} · ${escapeHtml(cost)}
</p>
</div>
<div class="shrink-0 text-right">
<p class="text-xs text-[var(--text-secondary)]">Вес ${escapeHtml(String(item.weight ?? "—"))}</p>
</div>
</div>
`;
}
function renderEquipmentDetail(item: EquipmentItem, isTranslated: boolean): string {
const cost = formatCost(item.cost);
let html = `
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(item.name)}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(item.category || "—")}</p>
${translateButtonHtml(item.key, "equipment", isTranslated)}
<div class="grid grid-cols-2 gap-3 mt-4">
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(cost)}</p>
<p class="text-xs text-[var(--text-secondary)]">Стоимость</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(String(item.weight ?? "—"))}</p>
<p class="text-xs text-[var(--text-secondary)]">Вес</p>
</div>
</div>
`;
if (item.desc) {
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(item.desc)}</p></div>`;
}
return html;
}
function renderMagicItemCard(item: MagicItem): string {
return `
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5 hover:border-[var(--border-gold)] hover:-translate-y-0.5 transition-all duration-[var(--transition-base)] group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-key="${item.key}" role="button" tabindex="0" data-testid="ref-result-card">
<div class="min-w-0">
<p class="font-semibold text-[var(--gold)] group-hover:text-[var(--gold-hover)] transition-colors truncate">
${escapeHtml(item.name)}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}
</p>
</div>
</div>
`;
}
function renderMagicItemDetail(item: MagicItem, isTranslated: boolean): string {
let html = `
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(item.name)}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}</p>
${translateButtonHtml(item.key, "magicitem", isTranslated)}
`;
if (item.requires_attunement) {
html += `<p class="text-sm text-[var(--gold)] mt-2 italic">Требует настройки: ${escapeHtml(item.requires_attunement)}</p>`;
}
if (item.desc) {
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(item.desc)}</p></div>`;
}
return html;
}
function renderSpellDetail(spell: Spell, isTranslated: boolean): string {
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
let html = `
@@ -391,27 +523,53 @@ import DmButton from "./DmButton.astro";
const detailBg = container.querySelector("#o5-detail-bg")!;
const tabMonsters = container.querySelector("#o5-tab-monsters")!;
const tabSpells = container.querySelector("#o5-tab-spells")!;
const tabEquipment = container.querySelector("#o5-tab-equipment")!;
const tabMagicitems = container.querySelector("#o5-tab-magicitems")!;
const retryBtn = container.querySelector("#o5-retry")!;
const tabTypeMap: Record<string, ItemType> = {
monsters: "creature",
spells: "spell",
equipment: "equipment",
magicitems: "magicitem",
};
const manager = new Open5eUIManager(render);
function buildFilterOptions() {
const options = manager.state.tab === "monsters" ? CR_OPTIONS : LEVEL_OPTIONS;
filterSelect.innerHTML = options
.map((o) => `<option value="${o.value}">${o.label}</option>`)
.join("");
const tab = manager.state.tab;
if (tab === "monsters") {
filterSelect.innerHTML = CR_OPTIONS.map(
(o) => `<option value="${o.value}">${o.label}</option>`,
).join("");
filterSelect.classList.remove("hidden");
} else if (tab === "spells") {
filterSelect.innerHTML = LEVEL_OPTIONS.map(
(o) => `<option value="${o.value}">${o.label}</option>`,
).join("");
filterSelect.classList.remove("hidden");
} else {
filterSelect.innerHTML = "";
filterSelect.classList.add("hidden");
}
}
function updateTabs() {
const isMonsters = manager.state.tab === "monsters";
tabMonsters.setAttribute("aria-selected", String(isMonsters));
tabSpells.setAttribute("aria-selected", String(!isMonsters));
const tab = manager.state.tab;
const activeClasses = "flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-[var(--gold)] text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
const inactiveClasses = "flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
tabMonsters.className = isMonsters ? activeClasses : inactiveClasses;
tabSpells.className = isMonsters ? inactiveClasses : activeClasses;
const tabs: Array<[Element, string]> = [
[tabMonsters, "monsters"],
[tabSpells, "spells"],
[tabEquipment, "equipment"],
[tabMagicitems, "magicitems"],
];
for (const [el, tabName] of tabs) {
const isActive = tab === tabName;
el.setAttribute("aria-selected", String(isActive));
el.className = isActive ? activeClasses : inactiveClasses;
}
}
function render() {
@@ -425,16 +583,34 @@ import DmButton from "./DmButton.astro";
errorEl.classList.toggle("hidden", !state.error);
if (state.error) errorTextEl.textContent = state.error;
emptyEl.classList.toggle("hidden", state.results.length > 0 || state.loading || !!state.error);
resultsContainer.setAttribute("data-testid", state.tab === "monsters" ? "ref-monsters-list" : "ref-spells-list");
const listTestid: Record<string, string> = {
monsters: "ref-monsters-list",
spells: "ref-spells-list",
equipment: "ref-equipment-list",
magicitems: "ref-magicitems-list",
};
resultsContainer.setAttribute("data-testid", listTestid[state.tab] ?? "ref-results-list");
const paginated = manager.getPaginatedResults();
resultsContainer.innerHTML = "";
function renderCard(tab: typeof state.tab, item: AnyItem): string {
if (tab === "monsters") return renderMonsterCard(item as Monster);
if (tab === "spells") return renderSpellCard(item as Spell);
if (tab === "equipment") return renderEquipmentCard(item as EquipmentItem);
return renderMagicItemCard(item as MagicItem);
}
function renderDetail(tab: typeof state.tab, item: AnyItem, translated: boolean): string {
if (tab === "monsters") return renderMonsterDetail(item as Monster, translated);
if (tab === "spells") return renderSpellDetail(item as Spell, translated);
if (tab === "equipment") return renderEquipmentDetail(item as EquipmentItem, translated);
return renderMagicItemDetail(item as MagicItem, translated);
}
if (paginated.length > 0) {
for (const item of paginated) {
const cardHtml = state.tab === "monsters"
? renderMonsterCard(item)
: renderSpellCard(item);
const cardHtml = renderCard(state.tab, item);
const wrapper = document.createElement("div");
wrapper.innerHTML = cardHtml;
const card = wrapper.firstElementChild as HTMLElement;
@@ -462,13 +638,11 @@ import DmButton from "./DmButton.astro";
}
if (state.selectedItem) {
const type = state.tab === "monsters" ? "creature" : "spell";
const type = tabTypeMap[state.tab];
const cached = getCachedTranslation(state.selectedItem.key, type);
const isTranslated = cached !== null;
const item = mergeWithTranslation(state.selectedItem, cached);
detailContent.innerHTML = state.tab === "monsters"
? renderMonsterDetail(item as Monster, isTranslated)
: renderSpellDetail(item as Spell, isTranslated);
const item = mergeWithTranslation(state.selectedItem as AnyItem, cached);
detailContent.innerHTML = renderDetail(state.tab, item, isTranslated);
detailOverlay.classList.remove("hidden");
detailClose.focus();
} else {
@@ -488,6 +662,14 @@ import DmButton from "./DmButton.astro";
manager.setTab("spells");
buildFilterOptions();
});
tabEquipment.addEventListener("click", () => {
manager.setTab("equipment");
buildFilterOptions();
});
tabMagicitems.addEventListener("click", () => {
manager.setTab("magicitems");
buildFilterOptions();
});
searchInput.addEventListener("input", () => {
manager.setQueryDebounced(searchInput.value.trim());
@@ -524,12 +706,20 @@ import DmButton from "./DmButton.astro";
const btn = (e.target as HTMLElement).closest("[data-translate-btn]") as HTMLButtonElement | null;
if (!btn || btn.disabled) return;
const slug = btn.dataset.slug;
const type = btn.dataset.type as "creature" | "spell";
const type = btn.dataset.type as ItemType;
if (!slug || !type) return;
handleTranslate(slug, type, btn);
});
async function handleTranslate(slug: string, type: "creature" | "spell", btn: HTMLButtonElement) {
function renderSelectedDetail(item: AnyItem, isTranslated: boolean): string {
const tab = manager.state.tab;
if (tab === "monsters") return renderMonsterDetail(item as Monster, isTranslated);
if (tab === "spells") return renderSpellDetail(item as Spell, isTranslated);
if (tab === "equipment") return renderEquipmentDetail(item as EquipmentItem, isTranslated);
return renderMagicItemDetail(item as MagicItem, isTranslated);
}
async function handleTranslate(slug: string, type: ItemType, btn: HTMLButtonElement) {
const originalText = btn.textContent?.trim() ?? "Перевести";
btn.textContent = "Перевод...";
btn.disabled = true;
@@ -543,10 +733,8 @@ import DmButton from "./DmButton.astro";
const state = manager.state;
if (state.selectedItem && state.selectedItem.key === slug) {
const isTranslated = translated !== null;
const item = mergeWithTranslation(state.selectedItem, translated);
detailContent.innerHTML = state.tab === "monsters"
? renderMonsterDetail(item as Monster, isTranslated)
: renderSpellDetail(item as Spell, isTranslated);
const item = mergeWithTranslation(state.selectedItem as AnyItem, translated);
detailContent.innerHTML = renderSelectedDetail(item, isTranslated);
}
} catch {
btn.textContent = "Ошибка перевода";
+44
View File
@@ -88,6 +88,9 @@ export const dmTranslations = {
aiTitle: "Генератор ИИ",
aiSignInCta: "Войдите, чтобы использовать ИИ",
aiPlaceholder: "Генератор ИИ появится здесь",
aiQuotaExhausted: "Лимит ИИ-генераций исчерпан.",
aiQuotaResetsIn: "Новые генерации будут доступны через {minutes} мин.",
aiQuotaUpgradeHint: "Хотите больше? Поддержите проект на Boosty — получите PRO с 100 генерациями/час.",
// AI Form
aiFormRace: "Раса",
@@ -141,6 +144,47 @@ export const dmTranslations = {
npcErrorRateLimit: "Превышен лимит генераций. Попробуйте через {minutes} мин.",
npcErrorValidation: "Проверьте введённые данные.",
// AI segmented control
aiKindNpc: "NPC",
aiKindSpell: "Заклинание",
// AI Spell Form
spellFormName: "Подсказка имени (опционально)",
spellFormLevel: "Уровень заклинания",
spellFormSchool: "Школа",
spellFormClasses: "Классы (опционально)",
spellFormTone: "Тон",
spellFormSubmit: "Сгенерировать заклинание",
spellGenerating: "Генерация...",
spellGenerate: "Сгенерировать заклинание",
spellErrorGeneric: "Ошибка генерации заклинания.",
spellErrorRateLimit: "Превышен лимит генераций. Попробуйте через {minutes} мин.",
// Spell schools
schoolAbjuration: "Ограждение",
schoolConjuration: "Призыв",
schoolDivination: "Прорицание",
schoolEnchantment: "Очарование",
schoolEvocation: "Воплощение",
schoolIllusion: "Иллюзия",
schoolNecromancy: "Некромантия",
schoolTransmutation: "Преобразование",
// Spell result card
spellLabelLevel: "Уровень",
spellLabelSchool: "Школа",
spellLabelCastingTime: "Время накладывания",
spellLabelRange: "Дистанция",
spellLabelComponents: "Компоненты",
spellLabelDuration: "Длительность",
spellLabelClasses: "Классы",
spellLabelDescription: "Описание",
spellLabelHigherLevels: "На больших уровнях",
spellCopyJson: "Копировать JSON",
spellCopied: "Скопировано",
spellRegenerate: "Перегенерировать",
spellCantripLabel: "Заговор",
// AI History
historyTitle: "История генераций",
historyEmptyPro: "Нет сохранённых NPC",
+181
View File
@@ -1,9 +1,14 @@
import {
npcParamsSchema,
npcResultSchema,
spellParamsSchema,
spellResultSchema,
type NPCParams,
type NPCResult,
type Open5eMonster,
type SpellParams,
type SpellResult,
type Open5eSpell,
} from "./types";
const KIMI_API_URL = "https://api.moonshot.ai/v1/chat/completions";
@@ -119,6 +124,182 @@ export class KimiClientError extends Error {
}
}
const spellJsonSchema = {
type: "object" as const,
properties: {
name: { type: "string" },
level: { type: "integer" },
school: { type: "string" },
casting_time: { type: "string" },
range: { type: "string" },
components: { type: "string" },
duration: { type: "string" },
classes: { type: "string" },
description: { type: "string" },
higher_levels: { type: "string" },
},
required: [
"name",
"level",
"school",
"casting_time",
"range",
"components",
"duration",
"classes",
"description",
],
};
function buildSpellSystemPrompt(): string {
return [
"You are a Dungeons & Dragons 5e spell designer.",
"You MUST respond with valid JSON only. Do NOT wrap the response in markdown code blocks.",
"Do NOT include any explanatory text outside the JSON object.",
"The JSON must exactly match the provided schema.",
].join(" ");
}
function formatSpellReference(spell: Open5eSpell): string {
const parts: string[] = [`Open5e Reference Spell: ${spell.name}`];
if (spell.level !== undefined) parts.push(`Level: ${spell.level}`);
if (spell.school) parts.push(`School: ${spell.school}`);
if (spell.casting_time) parts.push(`Casting time: ${spell.casting_time}`);
if (spell.range) parts.push(`Range: ${spell.range}`);
if (spell.duration) parts.push(`Duration: ${spell.duration}`);
if (spell.components) parts.push(`Components: ${spell.components}`);
if (spell.desc) {
const desc = Array.isArray(spell.desc) ? spell.desc.join(" ") : spell.desc;
parts.push(`Description: ${desc}`);
}
return parts.join("\n");
}
function buildSpellUserPrompt(params: SpellParams, reference?: Open5eSpell): string {
const lines: string[] = [
`Generate a D&D 5e spell with the following parameters:`,
`- Level: ${params.level}`,
`- School: ${params.school}`,
];
if (params.classes) lines.push(`- Classes: ${params.classes}`);
if (params.tone) lines.push(`- Tone: ${params.tone}`);
if (params.name) lines.push(`- Suggested name: ${params.name}`);
if (reference) {
lines.push("");
lines.push("Use the following Open5e spell as a balance reference. Do not exceed its power band:");
lines.push(formatSpellReference(reference));
}
lines.push("");
lines.push("Respond with a single JSON object matching this schema:");
lines.push(JSON.stringify(spellJsonSchema, null, 2));
return lines.join("\n");
}
export async function generateSpell(
params: SpellParams,
reference?: Open5eSpell
): Promise<SpellResult> {
spellParamsSchema.parse(params);
const apiKey = process.env.KIMI_API_KEY;
if (!apiKey) {
throw new KimiClientError("KIMI_API_KEY is not configured");
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(KIMI_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: KIMI_MODEL,
messages: [
{ role: "system", content: buildSpellSystemPrompt() },
{ role: "user", content: buildSpellUserPrompt(params, reference) },
],
response_format: {
type: "json_schema",
json_schema: {
name: "spell_result",
strict: true,
schema: spellJsonSchema,
},
},
temperature: 0.7,
}),
signal: abortController.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => undefined);
if (response.status === 429) {
throw new KimiClientError("Kimi API rate limit exceeded", response.status, body);
}
throw new KimiClientError(
`Kimi API error: ${response.status} ${response.statusText}`,
response.status,
body,
);
}
const responseText = await response.text();
let data: {
choices?: Array<{ message?: { content?: string | null } }>;
error?: { message?: string };
};
try {
data = JSON.parse(responseText);
} catch {
throw new KimiClientError("Kimi API returned invalid JSON", undefined, responseText);
}
if (data.error?.message) {
throw new KimiClientError(`Kimi API error: ${data.error.message}`);
}
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new KimiClientError("Kimi API returned empty content");
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new KimiClientError("Kimi API returned invalid JSON", undefined, content);
}
const validation = spellResultSchema.safeParse(parsed);
if (!validation.success) {
throw new KimiClientError(
`Kimi API spell validation failed: ${validation.error.message}`,
undefined,
content,
);
}
return validation.data;
} catch (error) {
if (error instanceof KimiClientError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new KimiClientError("Kimi API request timed out after 10s");
}
throw new KimiClientError(
error instanceof Error ? error.message : "Unknown Kimi API error",
);
} finally {
clearTimeout(timeoutId);
}
}
export async function generateNPC(
params: NPCParams,
reference?: Open5eMonster
+129 -2
View File
@@ -1,4 +1,13 @@
import { npcResultSchema, type NPCResult, type NPCParams, type Open5eMonster } from "@/lib/ai/types";
import {
npcResultSchema,
spellResultSchema,
type NPCResult,
type NPCParams,
type Open5eMonster,
type SpellResult,
type SpellParams,
type Open5eSpell,
} from "@/lib/ai/types";
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL = "llama-3.3-70b:free";
@@ -45,9 +54,51 @@ function buildUserPrompt(params: NPCParams, reference?: Open5eMonster): string {
return prompt;
}
function buildSpellSystemPrompt(): string {
return "You are a creative D&D spell designer. Respond with valid JSON only, no markdown, no code fences, no explanatory text.";
}
function buildSpellUserPrompt(params: SpellParams, reference?: Open5eSpell): string {
const schema = JSON.stringify({
name: "string (unique evocative spell name)",
level: "integer 0-9 (0 = cantrip)",
school: "string (Evocation, Abjuration, Conjuration, Divination, Enchantment, Illusion, Necromancy, Transmutation)",
casting_time: "string (e.g. '1 action', '1 bonus action', '1 reaction')",
range: "string (e.g. 'Self', '30 feet', '120 feet')",
components: "string (e.g. 'V, S', 'V, S, M (a pinch of dust)')",
duration: "string (e.g. 'Instantaneous', 'Concentration, up to 1 minute')",
classes: "string (comma-separated: e.g. 'Wizard, Sorcerer')",
description: "string (2-4 sentences, mechanically clear, evocative)",
higher_levels: "string (optional, scaling description; omit or empty if not applicable)",
});
let prompt = `Generate a D&D 5e spell with the following parameters:\n`;
prompt += `- Level: ${params.level}\n`;
prompt += `- School: ${params.school}\n`;
if (params.classes) prompt += `- Classes: ${params.classes}\n`;
if (params.tone) prompt += `- Tone: ${params.tone}\n`;
if (params.name) prompt += `- Suggested name: ${params.name}\n`;
if (reference) {
prompt += `\nUse this reference SRD spell for balance and structure:\n`;
prompt += `- Name: ${reference.name}\n`;
if (reference.level !== undefined) prompt += `- Level: ${reference.level}\n`;
if (reference.school) prompt += `- School: ${reference.school}\n`;
if (reference.casting_time) prompt += `- Casting time: ${reference.casting_time}\n`;
if (reference.range) prompt += `- Range: ${reference.range}\n`;
if (reference.duration) prompt += `- Duration: ${reference.duration}\n`;
prompt += `Stay within the same power band; do not exceed reference damage/effect.\n`;
}
prompt += `\nRespond with valid JSON matching this schema:\n${schema}`;
return prompt;
}
export type Open5eContentType = "creature" | "spell" | "equipment" | "magicitem";
export async function translateOpen5eContent(
content: Record<string, unknown>,
type: "creature" | "spell",
type: Open5eContentType,
model: string = DEFAULT_MODEL
): Promise<Record<string, unknown>> {
const apiKey = process.env.OPENROUTER_API_KEY;
@@ -202,3 +253,79 @@ export async function generateNPC(
clearTimeout(timeoutId);
}
}
export async function generateSpell(
params: SpellParams,
reference?: Open5eSpell,
model: string = DEFAULT_MODEL
): Promise<SpellResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": process.env.PUBLIC_APP_URL || "https://randify.pro",
"X-Title": "Randify.pro DM Dashboard",
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: buildSpellSystemPrompt() },
{ role: "user", content: buildSpellUserPrompt(params, reference) },
],
temperature: 0.8,
}),
signal: controller.signal,
});
if (response.status === 429) {
throw new Error("Rate limited by OpenRouter (429)");
}
if (!response.ok) {
const text = await response.text();
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("Empty response from OpenRouter");
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error(`Invalid JSON in OpenRouter response: ${content}`);
}
const validated = spellResultSchema.safeParse(parsed);
if (!validated.success) {
throw new Error(
`Spell schema validation failed: ${validated.error.message}`
);
}
return validated.data;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("OpenRouter request timed out after 10s", { cause: err });
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
+40
View File
@@ -29,6 +29,46 @@ export const npcResultSchema = z.object({
export type NPCResult = z.infer<typeof npcResultSchema>;
export const spellParamsSchema = z.object({
name: z.string().optional(),
level: z.number().int().min(0).max(9),
school: z.string(),
classes: z.string().optional(),
tone: z.string().optional(),
});
export type SpellParams = z.infer<typeof spellParamsSchema>;
export const spellResultSchema = z.object({
name: z.string(),
level: z.number().int().min(0).max(9),
school: z.string(),
casting_time: z.string(),
range: z.string(),
components: z.string(),
duration: z.string(),
classes: z.string(),
description: z.string(),
higher_levels: z.string().optional(),
});
export type SpellResult = z.infer<typeof spellResultSchema>;
export interface Open5eSpell {
name: string;
level?: number;
school?: string;
casting_time?: string;
range?: string;
components?: string;
duration?: string;
desc?: string | string[];
higher_level?: string | string[];
ritual?: boolean;
concentration?: boolean;
classes?: Array<{ name: string }> | string[];
}
export interface Open5eMonster {
name: string;
size?: string;
+15 -3
View File
@@ -1,11 +1,15 @@
import {
searchMonsters,
searchSpells,
searchEquipment,
searchMagicItems,
getMonster,
getSpell,
getEquipmentItem,
getMagicItem,
} from "@/lib/open5e/client";
export type Tab = "monsters" | "spells";
export type Tab = "monsters" | "spells" | "equipment" | "magicitems";
export interface Open5eItem {
key: string;
@@ -101,11 +105,15 @@ export class Open5eUIManager {
? { challenge_rating_decimal: this.state.monsterCrFilter }
: undefined;
results = await searchMonsters(this.state.query, filters);
} else {
} else if (this.state.tab === "spells") {
const filters = this.state.spellLevelFilter
? { level: this.state.spellLevelFilter }
: undefined;
results = await searchSpells(this.state.query, filters);
} else if (this.state.tab === "equipment") {
results = await searchEquipment(this.state.query);
} else {
results = await searchMagicItems(this.state.query);
}
this.state.results = results;
this.state.totalPages = Math.max(
@@ -148,8 +156,12 @@ export class Open5eUIManager {
try {
if (this.state.tab === "monsters") {
this.state.selectedItem = await getMonster(key);
} else {
} else if (this.state.tab === "spells") {
this.state.selectedItem = await getSpell(key);
} else if (this.state.tab === "equipment") {
this.state.selectedItem = await getEquipmentItem(key);
} else {
this.state.selectedItem = await getMagicItem(key);
}
} catch (err: unknown) {
this.state.error = err instanceof Error ? err.message : "Failed to load details";
+6 -4
View File
@@ -1,6 +1,8 @@
export type TranslationType = "creature" | "spell" | "equipment" | "magicitem";
export interface TranslationEntry {
slug: string;
type: "creature" | "spell";
type: TranslationType;
data: Record<string, unknown>;
timestamp: number;
}
@@ -21,14 +23,14 @@ if (CHANNEL) {
export function getCachedTranslation(
slug: string,
type: "creature" | "spell",
type: TranslationType,
): Record<string, unknown> | null {
return CACHE.get(`${type}:${slug}`)?.data ?? null;
}
export function setCachedTranslation(
slug: string,
type: "creature" | "spell",
type: TranslationType,
data: Record<string, unknown>,
): void {
const entry: TranslationEntry = {
@@ -43,7 +45,7 @@ export function setCachedTranslation(
export async function fetchTranslation(
slug: string,
type: "creature" | "spell",
type: TranslationType,
): Promise<Record<string, unknown> | null> {
const cached = getCachedTranslation(slug, type);
if (cached) return cached;
+102
View File
@@ -170,3 +170,105 @@ export async function getSpell(key: string): Promise<Spell> {
throw err;
}
}
export interface EquipmentItem {
name: string;
key: string;
category?: string;
cost?: string | number | Record<string, unknown>;
weight?: string | number;
damage?: string | Record<string, unknown>;
properties?: string[] | Record<string, unknown>[];
desc?: string;
}
export async function searchEquipment(
query: string,
filters?: SearchFilters
): Promise<EquipmentItem[]> {
const cacheKey = `open5e:v2:equipment:${query}`;
const cached = getCached<EquipmentItem[]>(cacheKey);
if (cached) return cached;
const url = buildUrl("equipment/", query, filters);
const urlObj = new URL(url);
urlObj.searchParams.set("fields", "name,key,category,cost,weight");
try {
const data = await apiFetch<{ results: EquipmentItem[] }>(urlObj.toString());
setCached(cacheKey, data.results);
return data.results;
} catch (err) {
const stale = getCached<EquipmentItem[]>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function getEquipmentItem(key: string): Promise<EquipmentItem> {
const cacheKey = `open5e:v2:equipment:item:${key}`;
const cached = getCached<EquipmentItem>(cacheKey);
if (cached) return cached;
const url = `${API_BASE}equipment/${key}/`;
try {
const data = await apiFetch<EquipmentItem>(url);
setCached(cacheKey, data);
return data;
} catch (err) {
const stale = getCached<EquipmentItem>(cacheKey);
if (stale) return stale;
throw err;
}
}
export interface MagicItem {
name: string;
key: string;
type?: string;
rarity?: string;
requires_attunement?: string;
desc?: string;
}
export async function searchMagicItems(
query: string,
filters?: SearchFilters
): Promise<MagicItem[]> {
const cacheKey = `open5e:v2:magicitems:${query}`;
const cached = getCached<MagicItem[]>(cacheKey);
if (cached) return cached;
const url = buildUrl("magicitems/", query, filters);
const urlObj = new URL(url);
urlObj.searchParams.set("fields", "name,key,type,rarity");
try {
const data = await apiFetch<{ results: MagicItem[] }>(urlObj.toString());
setCached(cacheKey, data.results);
return data.results;
} catch (err) {
const stale = getCached<MagicItem[]>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function getMagicItem(key: string): Promise<MagicItem> {
const cacheKey = `open5e:v2:magicitem:${key}`;
const cached = getCached<MagicItem>(cacheKey);
if (cached) return cached;
const url = `${API_BASE}magicitems/${key}/`;
try {
const data = await apiFetch<MagicItem>(url);
setCached(cacheKey, data);
return data;
} catch (err) {
const stale = getCached<MagicItem>(cacheKey);
if (stale) return stale;
throw err;
}
}
+1 -15
View File
@@ -8,28 +8,17 @@ import { eq } from 'drizzle-orm';
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = null;
const cookieHeader = context.request.headers.get('cookie');
console.log('[Middleware] Cookie header:', cookieHeader ? cookieHeader.slice(0, 50) + '...' : 'none');
const token = context.cookies.get('auth_token')?.value;
if (!token) {
console.log('[Middleware] No auth_token cookie found');
return next();
}
console.log('[Middleware] auth_token cookie found');
if (!token) return next();
try {
await verifyToken(token);
const session = await getSession(token);
if (!session) {
console.log('[Middleware] Session validation failed: no session in DB');
context.cookies.delete('auth_token', { path: '/' });
return next();
}
console.log('[Middleware] Session validation succeeded');
const userResult = await db
.select()
.from(users)
@@ -42,13 +31,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
...user,
tier: (user.tier ?? 'free') as 'free' | 'pro',
};
console.log('[Middleware] User attached to locals:', { userId: user.id, name: user.name });
} else {
console.log('[Middleware] No user found for session');
context.cookies.delete('auth_token', { path: '/' });
}
} catch {
console.log('[Middleware] Session validation failed: invalid token');
context.cookies.delete('auth_token', { path: '/' });
}
+124
View File
@@ -0,0 +1,124 @@
import type { APIRoute } from "astro";
import { jsonResponse, handleCorsPreflight, createCorsResponse } from "@/lib/cors";
import { checkRateLimit, incrementGenerationCounter, getRetryAfterSeconds } from "@/lib/rate-limit";
import { generateSpell as generateOpenRouterSpell } from "@/lib/ai/openrouter";
import { generateSpell as generateKimiSpell } from "@/lib/ai/kimi";
import type { Open5eSpell } from "@/lib/ai/types";
import { searchSpells } from "@/lib/open5e/client";
import { spellParamsSchema, spellResultSchema, type SpellResult } from "@/lib/ai/types";
import { z } from "zod";
export const prerender = false;
const generateRequestSchema = spellParamsSchema.extend({
useOpen5eReference: z.boolean().optional(),
});
function getOrigin(request: Request): string | null {
return request.headers.get("origin");
}
function requireAuth(locals: App.Locals, origin: string | null): Response | null {
if (!locals.user) {
return jsonResponse({ error: "Unauthorized" }, 401, origin);
}
return null;
}
async function fetchOpen5eReference(level: number, school: string): Promise<Open5eSpell | null> {
try {
const spells = await searchSpells(school, { level: String(level) });
if (spells.length > 0) {
return spells[0] as unknown as Open5eSpell;
}
const fallback = await searchSpells("", { level: String(level) });
if (fallback.length > 0) {
return fallback[0] as unknown as Open5eSpell;
}
} catch {
return null;
}
return null;
}
export const POST: APIRoute = async ({ request, locals }) => {
const origin = getOrigin(request);
const authError = requireAuth(locals, origin);
if (authError) return authError;
const user = locals.user!;
const tier = user.tier;
const rateLimit = await checkRateLimit(user.id, tier);
if (!rateLimit.allowed) {
return createCorsResponse(
JSON.stringify({ error: "Rate limit exceeded" }),
429,
origin,
{ "Content-Type": "application/json", "Retry-After": String(getRetryAfterSeconds()) },
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse({ error: "Invalid JSON" }, 400, origin);
}
const parsed = generateRequestSchema.safeParse(body);
if (!parsed.success) {
return jsonResponse(
{ error: "Validation failed", details: parsed.error.format() },
400,
origin,
);
}
const { name, level, school, classes, tone, useOpen5eReference } = parsed.data;
let reference: Open5eSpell | null = null;
if (useOpen5eReference) {
reference = await fetchOpen5eReference(level, school);
}
const aiClient = tier === "pro" ? generateKimiSpell : generateOpenRouterSpell;
const modelName = tier === "pro" ? "moonshot-v1-8k" : "llama-3.3-70b:free";
let spell: SpellResult;
try {
spell = await aiClient({ name, level, school, classes, tone }, reference ?? undefined);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse(
{ error: "AI generation failed", details: message },
502,
origin,
);
}
const validation = spellResultSchema.safeParse(spell);
if (!validation.success) {
return jsonResponse(
{ error: "Invalid AI response", details: validation.error.message },
502,
origin,
);
}
try {
await incrementGenerationCounter(user.id, modelName);
} catch {
// best-effort: counter increment failures should not block the response
}
const response: { spell: SpellResult; reference?: Open5eSpell } = { spell };
if (reference) response.reference = reference;
return jsonResponse(response, 200, origin);
};
export const OPTIONS: APIRoute = async ({ request }) => {
const origin = getOrigin(request);
return handleCorsPreflight(origin);
};
+19 -9
View File
@@ -3,12 +3,20 @@ import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
import { db } from "@/db/client";
import { translations } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { getMonster, getSpell } from "@/lib/open5e/client";
import { translateOpen5eContent } from "@/lib/ai/openrouter";
import {
getMonster,
getSpell,
getEquipmentItem,
getMagicItem,
} from "@/lib/open5e/client";
import {
translateOpen5eContent,
type Open5eContentType,
} from "@/lib/ai/openrouter";
export const prerender = false;
const ALLOWED_TYPES = new Set(["creature", "spell"]);
const ALLOWED_TYPES = new Set(["creature", "spell", "equipment", "magicitem"]);
function getOrigin(request: Request): string | null {
return request.headers.get("origin");
@@ -30,7 +38,7 @@ export const GET: APIRoute = async ({ request }) => {
if (!ALLOWED_TYPES.has(type)) {
return jsonResponse(
{ error: `Invalid type. Allowed: creature, spell` },
{ error: `Invalid type. Allowed: creature, spell, equipment, magicitem` },
400,
origin
);
@@ -59,11 +67,13 @@ export const GET: APIRoute = async ({ request }) => {
let original: Record<string, unknown>;
try {
if (type === "creature") {
const monster = await getMonster(slug);
original = monster as unknown as Record<string, unknown>;
original = (await getMonster(slug)) as unknown as Record<string, unknown>;
} else if (type === "spell") {
original = (await getSpell(slug)) as unknown as Record<string, unknown>;
} else if (type === "equipment") {
original = (await getEquipmentItem(slug)) as unknown as Record<string, unknown>;
} else {
const spell = await getSpell(slug);
original = spell as unknown as Record<string, unknown>;
original = (await getMagicItem(slug)) as unknown as Record<string, unknown>;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -76,7 +86,7 @@ export const GET: APIRoute = async ({ request }) => {
let translated: Record<string, unknown>;
try {
translated = await translateOpen5eContent(original, type as "creature" | "spell");
translated = await translateOpen5eContent(original, type as Open5eContentType);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse(
+8 -4
View File
@@ -8,7 +8,8 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
import AiKindSwitcher from "@/components/dm/AiKindSwitcher.astro";
import AiQuotaExhausted from "@/components/dm/AiQuotaExhausted.astro";
import ImportModal from "@/components/dm/ImportModal.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
@@ -18,6 +19,7 @@ let quota = null;
if (user) {
quota = await getRemainingQuota(user.id, user.tier);
}
const quotaExhausted = !!user && user.tier === "free" && !!quota && quota.remaining <= 0;
---
<DmLayout>
@@ -58,9 +60,7 @@ if (user) {
{T.ai}
</h2>
</div>
{Astro.locals.user ? (
<AiNpcForm />
) : (
{!user ? (
<DmCard>
<div class="p-6 text-center">
<p class="text-[var(--text-secondary)] mb-4">{T.aiSignInCta}</p>
@@ -72,6 +72,10 @@ if (user) {
</a>
</div>
</DmCard>
) : quotaExhausted ? (
<AiQuotaExhausted resetAt={quota!.resetAt} />
) : (
<AiKindSwitcher />
)}
</section>
</div>
+8 -4
View File
@@ -8,7 +8,8 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
import AiKindSwitcher from "@/components/dm/AiKindSwitcher.astro";
import AiQuotaExhausted from "@/components/dm/AiQuotaExhausted.astro";
import ImportModal from "@/components/dm/ImportModal.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
@@ -18,6 +19,7 @@ let quota = null;
if (user) {
quota = await getRemainingQuota(user.id, user.tier);
}
const quotaExhausted = !!user && user.tier === "free" && !!quota && quota.remaining <= 0;
---
<DmLayout>
@@ -58,14 +60,16 @@ if (user) {
{T.ai}
</h2>
</div>
{Astro.locals.user ? (
<AiNpcForm />
) : (
{!user ? (
<DmCard>
<div class="p-6 text-center">
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
</div>
</DmCard>
) : quotaExhausted ? (
<AiQuotaExhausted resetAt={quota!.resetAt} />
) : (
<AiKindSwitcher />
)}
</section>
</div>