From 4e7618bc3f7cebaf6052a30f299a6b7bf4f77267 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 10:10:45 -0800 Subject: [PATCH 01/13] feat: character status tool makes the prompt much smaller, new personality --- src/ai/prompts.ts | 255 +++----------------------------- src/services/characterStatus.ts | 48 ++++++ src/tools.ts | 15 ++ 3 files changed, 82 insertions(+), 236 deletions(-) create mode 100644 src/services/characterStatus.ts diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index 436efd0..731879d 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -1,252 +1,35 @@ -import type { SpellSlotLevelType } from "@src/db/char_spell_slots" -import { ItemCategories } from "@src/lib/dnd" -import { spells } from "@src/lib/dnd/spells" import type { ComputedCharacter } from "@src/services/computeCharacter" /** * Build the system prompt for the AI assistant based on the character's current state + * This version only includes static character identity information. + * Dynamic stats (abilities, skills, resources, etc.) are available via the character_status tool. */ -const PREAMBLE = - `Reed here. *doesn't look up from ledger* I've got a dozen other adventurers' sheets to update today, so let's keep this brief. - -I know the D&D 5e rules inside and out - Player's Handbook, the whole deal. I'll update your sheet, track your resources, manage your spells. Just tell me what happened and I'll handle it. Quick questions are fine too. - -Let's get to it. -` as const - -function formatClasses(character: ComputedCharacter): string { - return character.classes - .map((c) => `level ${c.level} ${c.class} ${c.subclass ? ` (${c.subclass})` : ""}`) - .join(", ") -} - -function formatAbilities(character: ComputedCharacter): string { - const abilityLines: string[] = [] - // Ability scores with modifiers and saves - for (const [ability, score] of Object.entries(character.abilityScores)) { - const modStr = score.modifier >= 0 ? `+${score.modifier}` : `${score.modifier}` - const saveStr = score.savingThrow >= 0 ? `+${score.savingThrow}` : `${score.savingThrow}` - const profMark = score.proficient ? "*" : "" - abilityLines.push( - `${ability.toUpperCase()}: ${score.score} (${modStr}, save ${saveStr}${profMark})` - ) - } - - return abilityLines.join(",") -} - -function formatSkills(character: ComputedCharacter): string { - // Skills - only show proficient/expert - const proficientSkills: string[] = [] - for (const [skill, skillScore] of Object.entries(character.skills)) { - if (skillScore.proficiency !== "none") { - const modStr = skillScore.modifier >= 0 ? `+${skillScore.modifier}` : `${skillScore.modifier}` - const profLevel = - skillScore.proficiency === "expert" - ? "**" - : skillScore.proficiency === "proficient" - ? "*" - : "" - proficientSkills.push(`${skill} ${modStr}${profLevel}`) - } - } - const skillsDesc = - proficientSkills.length > 0 ? proficientSkills.join(", ") : "no proficient skills" - - return skillsDesc -} - -function formatCombat(character: ComputedCharacter): string { - const hpDesc = `${character.currentHP}/${character.maxHitPoints}` - const initStr = character.initiative >= 0 ? `+${character.initiative}` : `${character.initiative}` - - return `HP: ${hpDesc} • AC: ${character.armorClass} • Initiative: ${initStr} • Passive Perception: ${character.passivePerception}` -} - -function formatResources(character: ComputedCharacter): string { - const coinsDesc = character.coins - ? `${character.coins.pp}pp ${character.coins.gp}gp ${character.coins.ep}ep ${character.coins.sp}sp ${character.coins.cp}cp` - : "no coins" - - const slotCounts: Record = {} - for (let level = 1; level <= 9; level++) { - const total = character.spellSlots.filter((slot) => slot === level).length - const available = character.availableSpellSlots.filter((slot) => slot === level).length - if (total > 0) { - slotCounts[level as SpellSlotLevelType] = { total, available } - } - } - - const slotsDesc = Object.entries(slotCounts) - .map(([level, counts]) => { - return `${counts.available}/${counts.total} L${level}` - }) - .join(", ") - - // Group hit dice by die type - const groupDice = (dice: number[]) => { - const counts: Record = {} - for (const die of dice) { - counts[die] = (counts[die] || 0) + 1 - } - return Object.entries(counts) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([die, count]) => `${count}d${die}`) - .join(", ") - } - - // Calculate used hit dice by subtracting available from total - const availHitDice = character.availableHitDice - const usedHitDice = [...character.hitDice] - for (const die of character.availableHitDice) { - const index = usedHitDice.indexOf(die) - if (index !== -1) { - usedHitDice.splice(index, 1) - } - } - - const availableHitDiceDesc = availHitDice.length > 0 ? groupDice(availHitDice) : "none" - const usedHitDiceDesc = usedHitDice.length > 0 ? groupDice(usedHitDice) : "none" - - return [ - `Coins: ${coinsDesc}`, - `Available Spell Slots: ${slotsDesc}`, - `Available Hit Dice: ${availableHitDiceDesc}`, - `Unavailable Hit Dice: ${usedHitDiceDesc}`, - ].join("\n") -} - -function formatEquipment(character: ComputedCharacter): string { - const itemLines: string[] = [] - - for (const cat of ItemCategories) { - const itemsInCat = character.equippedItems.filter((item) => item.category === cat) - if (itemsInCat.length === 0) { - continue - } - - itemLines.push(`## ${cat} items`) - - for (const item of itemsInCat) { - const itemParts: string[] = [ - `Item ID: ${item.id} -- ${item.name}`, - item.wearable ? (item.worn ? " (worn)" : " (not worn)") : "", - item.wieldable ? (item.wielded ? " (wielded)" : " (not wielded)") : "", - ":", - item.humanReadableDamage.length > 0 - ? ` Damage: ${item.humanReadableDamage.join(", ")}.` - : "", - item.chargeLabel && item.currentCharges > 0 - ? ` ${item.currentCharges} ${item.chargeLabel} remaining.` - : "", - ] - - itemLines.push(itemParts.join(" ")) - } - } - - // Active item effects - const itemEffects: string[] = [] - for (const [attr, effectInfo] of Object.entries(character.affectedAttributes)) { - for (const effect of effectInfo) { - itemEffects.push(`${effect.itemName} affects ${attr}: ${effect.effectDescription}`) - } - } - - if (itemEffects.length > 0) { - itemLines.push("## Active Item Effects") - for (const effectLine of itemEffects) { - itemLines.push(`- ${effectLine}`) - } - } - - return itemLines.join("\n") -} - -function formatSpellcasting(character: ComputedCharacter): string { - if (character.spells.length === 0) { - return "No spellcasting abilities" - } - - let spellcastingSection = "" - - for (const spellInfo of character.spells) { - const atkStr = - spellInfo.spellAttackBonus >= 0 - ? `+${spellInfo.spellAttackBonus}` - : `${spellInfo.spellAttackBonus}` - spellcastingSection += `\n**${spellInfo.class}** (${spellInfo.ability.toUpperCase()}): Spell Attack ${atkStr}, Save DC ${spellInfo.spellSaveDC}` - - // Prepared cantrips - const preparedCantrips = spellInfo.cantripSlots - .filter((slot) => slot.spell_id) - .map((slot) => spells.find((s) => s.id === slot.spell_id)?.name || slot.spell_id) - if (preparedCantrips.length > 0) { - spellcastingSection += `\nCantrips: ${preparedCantrips.join(", ")}` - } - - // Prepared leveled spells - const preparedSpells = spellInfo.preparedSpells - .filter((slot) => slot.spell_id) - .map((slot) => { - const spell = spells.find((s) => s.id === slot.spell_id) - const lockMark = slot.alwaysPrepared ? "🔒" : "" - return spell ? `${spell.name} (L${spell.level})${lockMark}` : slot.spell_id - }) - if (preparedSpells.length > 0) { - spellcastingSection += `\nPrepared: ${preparedSpells.join(", ")}` - } +export function buildSystemPrompt(character: ComputedCharacter): string { + return ` +You are Reed, an AI assistant specialized in managing Dungeons & Dragons 5th Edition character sheets. Your role is to help players update and maintain their character sheets based on in-game events and actions. - // Wizard spellbook - if (spellInfo.knownSpells && spellInfo.knownSpells.length > 0) { - const spellbookSpells = spellInfo.knownSpells - .map((spellId) => spells.find((s) => s.id === spellId)) - .filter((s) => s && s.level > 0) // Don't list cantrips in spellbook - .map((s) => `${s?.name} (L${s?.level})`) - if (spellbookSpells.length > 0) { - spellcastingSection += `\nSpellbook: ${spellbookSpells.join(", ")}` - } - } - } +You are a crotchety, no-nonsense old scribe. You've been keeping adventurers' records for decades, and you've seen it all. You're efficient, direct, and a bit gruff, but you care deeply about accuracy and the well-being of the characters whose sheets you manage. - return spellcastingSection -} +Today, you're helping this adventurer: -const FOOTER = ` -# How I Work +Character name: ${character.name} +Species: ${character.species} ${character.lineage || ""} +Background: ${character.background || "none"} -I'll just do it. Tell me what happened and I'll update your sheet. My tools have validation built in - if something's wrong, they'll catch it and I'll adjust. +# Your approach: -**Spells**: When you mention a spell by name, I'll use lookup_spell to find its ID first, then handle learning/preparing/casting. Every time. +You can answer questions, provide advice, and help with rules clarifications, but your main job is to update the character sheet based on what the player tells you. You let the players focus on the game while you handle the bookkeeping. If they ask for advice, you give it, but always steer them back to the task of keeping their sheet accurate. -**Missing info**: I'll make reasonable assumptions based on D&D rules. If I genuinely can't proceed, I'll ask. Otherwise, I'm trying it. +If players ask you questions unrelated to DnD or character sheets, curtly redirect them back to your purpose. You don't want them wasting your time -- you still have a lot of character sheets to manage today! -The tools need your confirmation before changes take effect, so there's a safety net. I'm here to move fast and keep your sheet current.` as const +You have access to a set of tools. Reach for them often! The system you're working in has built-in validation and error-checking, so trust the tools to handle the details. Your main job is to interpret the player's input and decide which tools to use. -export function buildSystemPrompt(character: ComputedCharacter): string { - const prompt = [ - PREAMBLE, - "Your character sheet is as follows:", - "\n# Character Overview", - `Name: ${character.name}`, - `Species: ${character.species} ${character.lineage || ""}`, - `Background: ${character.background || "none"}`, - `Classes: total level ${character.totalLevel}, as a ${formatClasses(character)}`, - "\n# Ability Scores", - formatAbilities(character), - "\n# Skills", - formatSkills(character), - "\n# Combat Stats", - formatCombat(character), - "\n# Resources", - formatResources(character), - "\n# Equipment", - formatEquipment(character), - "\n# Spellcasting", - formatSpellcasting(character), - FOOTER, - ].join("\n") +Use your best judgement for tool parameters. You can ask the players for clarification or more information if you're genuinely unsure, but try to avoid it. You want to keep things moving quickly. - return prompt +A few special tools to specifically note: +* character_status : Use this to get the current state of the character sheet whenever you need it. +* lookup_spell : Use this to find spell IDs by name. You usually need spell IDs for learning, preparing, or casting spells. + ` } diff --git a/src/services/characterStatus.ts b/src/services/characterStatus.ts new file mode 100644 index 0000000..70ea9e2 --- /dev/null +++ b/src/services/characterStatus.ts @@ -0,0 +1,48 @@ +import type { ServiceResult } from "@src/lib/serviceResult" +import { tool } from "ai" +import type { SQL } from "bun" +import { z } from "zod" +import type { ComputedCharacter } from "./computeCharacter" + +export const characterStatusToolName = "character_status" as const + +/** + * Vercel AI SDK tool definition for character status lookup + * This is a read-only informational tool that returns current character state + */ +export const characterStatusTool = tool({ + name: characterStatusToolName, + description: + "Get the current character status including all computed stats, resources, and abilities. Use this whenever you need to reference ability scores, skills, combat stats (HP/AC/Initiative), resources (coins, spell slots, hit dice), equipment, or spellcasting information.", + inputSchema: z.object({}), +}) + +/** + * Execute character status lookup + * Returns the full computed character state as structured data + */ +export async function executeCharacterStatus( + _db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + _parameters: Record, + _isCheck?: boolean +): Promise> { + // Return the full computed character as structured data + return { + complete: true, + result: char, + } +} + +/** + * Format approval message for character status lookup + * Since this is a read-only tool, we return an empty string (no approval needed) + */ +export function formatCharacterStatusApproval( + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + _parameters: Record +): string { + // Read-only tool doesn't need approval message + return "" +} diff --git a/src/tools.ts b/src/tools.ts index 5749de6..ce35706 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -19,6 +19,12 @@ import { executeCastSpell, formatCastSpellApproval, } from "./services/castSpell" +import { + characterStatusTool, + characterStatusToolName, + executeCharacterStatus, + formatCharacterStatusApproval, +} from "./services/characterStatus" import type { ComputedCharacter } from "./services/computeCharacter" import { equipItemTool, @@ -179,6 +185,15 @@ export const TOOLS: ToolRegistration[] = [ formatApprovalMessage: formatShortRestApproval, }, + // Character Info (Read-only) + { + name: characterStatusToolName, + tool: characterStatusTool, + executor: executeCharacterStatus, + formatApprovalMessage: formatCharacterStatusApproval, + requiresApproval: false, + }, + // Spellcasting { name: lookupSpellToolName, From 03b30f824df8a542d42801778513968964fa4cfe Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 10:43:16 -0800 Subject: [PATCH 02/13] refactor: remove requiredApproval use approval formatter to indicate that approval is needed --- src/ai/chat.ts | 4 ++-- src/components/ChatBox.tsx | 10 +++++++--- src/services/characterStatus.ts | 12 ------------ src/services/computeChat.ts | 4 ++-- src/services/lookupSpell.ts | 12 ------------ src/tools.ts | 25 +++++++++---------------- 6 files changed, 20 insertions(+), 47 deletions(-) diff --git a/src/ai/chat.ts b/src/ai/chat.ts index cf76b1e..ee9f26f 100644 --- a/src/ai/chat.ts +++ b/src/ai/chat.ts @@ -79,7 +79,7 @@ async function autoExecuteReadOnlyTools( // Check if this tool is read-only (doesn't require approval) const toolRegistration = TOOLS.find((t) => t.name === call.name) - if (toolRegistration && toolRegistration.requiresApproval !== false) { + if (toolRegistration?.formatApprovalMessage) { continue } @@ -116,7 +116,7 @@ async function validateApprovalTools( // read-only tools will be executed automatically anyway const toolRegistration = TOOLS.find((t) => t.name === call.name) - if (toolRegistration && toolRegistration.requiresApproval === false) { + if (!toolRegistration?.formatApprovalMessage) { continue } diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 310ed52..c6b3648 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -217,9 +217,13 @@ export const ChatMessageBubble = ({ id, chatRole, content }: ChatMessageBubblePr export const ToolCallApproval = ({ characterId, chatId, toolCall }: ToolCallApprovalProps) => { // Get formatter for this tool and generate user-friendly message const formatter = TOOL_FORMATTERS[toolCall.toolName] - const approvalMessage = formatter - ? formatter(toolCall.parameters) - : `${toolCall.toolName}: ${JSON.stringify(toolCall.parameters)}` + + // If no formatter exists, this tool doesn't require approval (shouldn't show UI) + if (!formatter) { + return null + } + + const approvalMessage = formatter(toolCall.parameters) return (
diff --git a/src/services/characterStatus.ts b/src/services/characterStatus.ts index 70ea9e2..080edb3 100644 --- a/src/services/characterStatus.ts +++ b/src/services/characterStatus.ts @@ -34,15 +34,3 @@ export async function executeCharacterStatus( result: char, } } - -/** - * Format approval message for character status lookup - * Since this is a read-only tool, we return an empty string (no approval needed) - */ -export function formatCharacterStatusApproval( - // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON - _parameters: Record -): string { - // Read-only tool doesn't need approval message - return "" -} diff --git a/src/services/computeChat.ts b/src/services/computeChat.ts index 63dccd6..edfaca5 100644 --- a/src/services/computeChat.ts +++ b/src/services/computeChat.ts @@ -193,9 +193,9 @@ function findUnresolvedToolCalls(dbMessages: DbChatMessage[]): UnresolvedToolCal // Find IDs where tool_results is null for (const [id, call] of Object.entries(msg.tool_calls)) { if (!msg.tool_results[id]) { - // Check if this tool requires approval + // Check if this tool requires approval (has a formatter) const toolRegistration = TOOLS.find((t) => t.name === call.name) - const requiresApproval = toolRegistration?.requiresApproval !== false + const requiresApproval = !!toolRegistration?.formatApprovalMessage // Only include tools that require approval if (requiresApproval) { diff --git a/src/services/lookupSpell.ts b/src/services/lookupSpell.ts index 7b14fae..dbb6f4e 100644 --- a/src/services/lookupSpell.ts +++ b/src/services/lookupSpell.ts @@ -98,15 +98,3 @@ export async function executeLookupSpell( result: spell, } } - -/** - * Format approval message for spell lookup - * Since this is a read-only tool, we return an empty string (no approval needed) - */ -export function formatLookupSpellApproval( - // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON - _parameters: Record -): string { - // Read-only tool doesn't need approval message - return "" -} diff --git a/src/tools.ts b/src/tools.ts index ce35706..9bd90c3 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -23,7 +23,6 @@ import { characterStatusTool, characterStatusToolName, executeCharacterStatus, - formatCharacterStatusApproval, } from "./services/characterStatus" import type { ComputedCharacter } from "./services/computeCharacter" import { @@ -44,12 +43,7 @@ import { longRestTool, longRestToolName, } from "./services/longRest" -import { - executeLookupSpell, - formatLookupSpellApproval, - lookupSpellTool, - lookupSpellToolName, -} from "./services/lookupSpell" +import { executeLookupSpell, lookupSpellTool, lookupSpellToolName } from "./services/lookupSpell" import { executeManageCharge, formatManageChargeApproval, @@ -136,10 +130,12 @@ export interface ToolRegistration { tool: Tool /** Executor function that performs the tool action */ executor: ToolExecutor - /** Formatter function that generates user-friendly approval messages */ - formatApprovalMessage: ToolFormatter - /** Whether this tool requires user approval (defaults to true) */ - requiresApproval?: boolean + /** + * Optional formatter function that generates user-friendly approval messages. + * If provided, the tool requires user approval before execution. + * If omitted (undefined), the tool is read-only and executes immediately. + */ + formatApprovalMessage?: ToolFormatter } /** @@ -190,8 +186,6 @@ export const TOOLS: ToolRegistration[] = [ name: characterStatusToolName, tool: characterStatusTool, executor: executeCharacterStatus, - formatApprovalMessage: formatCharacterStatusApproval, - requiresApproval: false, }, // Spellcasting @@ -199,8 +193,6 @@ export const TOOLS: ToolRegistration[] = [ name: lookupSpellToolName, tool: lookupSpellTool, executor: executeLookupSpell, - formatApprovalMessage: formatLookupSpellApproval, - requiresApproval: false, }, { name: prepareSpellToolName, @@ -275,7 +267,8 @@ export const TOOL_EXECUTORS: Record = Object.fromEntries( /** * Map of tool names to approval message formatters * Used to display user-friendly tool call approval messages + * Only includes tools that have formatters (require approval) */ export const TOOL_FORMATTERS: Record = Object.fromEntries( - TOOLS.map((t) => [t.name, t.formatApprovalMessage]) + TOOLS.filter((t) => t.formatApprovalMessage).map((t) => [t.name, t.formatApprovalMessage!]) ) From f25142ac5519ef572441a5375713007bcaeba18f Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 10:47:04 -0800 Subject: [PATCH 03/13] feat: character traits tool makes character status a little smaller --- src/services/characterStatus.ts | 9 ++++---- src/services/characterTraits.ts | 37 +++++++++++++++++++++++++++++++++ src/tools.ts | 10 +++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/services/characterTraits.ts diff --git a/src/services/characterStatus.ts b/src/services/characterStatus.ts index 080edb3..441e43a 100644 --- a/src/services/characterStatus.ts +++ b/src/services/characterStatus.ts @@ -19,7 +19,7 @@ export const characterStatusTool = tool({ /** * Execute character status lookup - * Returns the full computed character state as structured data + * Returns the computed character state without traits (use character_traits tool for traits) */ export async function executeCharacterStatus( _db: SQL, @@ -27,10 +27,11 @@ export async function executeCharacterStatus( // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON _parameters: Record, _isCheck?: boolean -): Promise> { - // Return the full computed character as structured data +): Promise>> { + // Return the full computed character as structured data, excluding traits + const { traits: _traits, ...charWithoutTraits } = char return { complete: true, - result: char, + result: charWithoutTraits, } } diff --git a/src/services/characterTraits.ts b/src/services/characterTraits.ts new file mode 100644 index 0000000..d42f872 --- /dev/null +++ b/src/services/characterTraits.ts @@ -0,0 +1,37 @@ +import type { CharTrait } from "@src/db/char_traits" +import type { ServiceResult } from "@src/lib/serviceResult" +import { tool } from "ai" +import type { SQL } from "bun" +import { z } from "zod" +import type { ComputedCharacter } from "./computeCharacter" + +export const characterTraitsToolName = "character_traits" as const + +/** + * Vercel AI SDK tool definition for character traits lookup + * This is a read-only informational tool that returns the character's traits + */ +export const characterTraitsTool = tool({ + name: characterTraitsToolName, + description: + "Get the character's traits (racial traits, class features, feats, etc.). Use this when you need to reference specific abilities or features the character has.", + inputSchema: z.object({}), +}) + +/** + * Execute character traits lookup + * Returns the character's traits as structured data + */ +export async function executeCharacterTraits( + _db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + _parameters: Record, + _isCheck?: boolean +): Promise> { + const traits = char.traits + return { + complete: true, + result: {traits} + } +} diff --git a/src/tools.ts b/src/tools.ts index 9bd90c3..da566cf 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -24,6 +24,11 @@ import { characterStatusToolName, executeCharacterStatus, } from "./services/characterStatus" +import { + characterTraitsTool, + characterTraitsToolName, + executeCharacterTraits, +} from "./services/characterTraits" import type { ComputedCharacter } from "./services/computeCharacter" import { equipItemTool, @@ -187,6 +192,11 @@ export const TOOLS: ToolRegistration[] = [ tool: characterStatusTool, executor: executeCharacterStatus, }, + { + name: characterTraitsToolName, + tool: characterTraitsTool, + executor: executeCharacterTraits, + }, // Spellcasting { From ce32dce97743e504b240593f3cb866c7a9b7a314 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 10:54:11 -0800 Subject: [PATCH 04/13] feat: better spell slot display for llm --- src/components/ChatBox.tsx | 4 ++-- src/services/characterStatus.ts | 35 +++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index c6b3648..147adf7 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -14,8 +14,8 @@ function markdownToHtml(markdown: string, icon?: string): string { const renderer = new marked.Renderer() if (icon) { - renderer.paragraph = (tokens: Tokens.Paragraph) => { - const text = tokens.text + renderer.paragraph = function(tokens: Tokens.Paragraph) { + const text = this.parser.parseInline(tokens.tokens) if (isFirstParagraph) { isFirstParagraph = false diff --git a/src/services/characterStatus.ts b/src/services/characterStatus.ts index 441e43a..d9cebc5 100644 --- a/src/services/characterStatus.ts +++ b/src/services/characterStatus.ts @@ -6,6 +6,18 @@ import type { ComputedCharacter } from "./computeCharacter" export const characterStatusToolName = "character_status" as const +interface SpellSlotSummary { + total: number + used: number + available: number +} + +type SpellSlotsSummary = Record + +export type CharacterStatusResult = Omit & { + spellSlotsSummary: SpellSlotsSummary +} + /** * Vercel AI SDK tool definition for character status lookup * This is a read-only informational tool that returns current character state @@ -27,11 +39,30 @@ export async function executeCharacterStatus( // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON _parameters: Record, _isCheck?: boolean -): Promise>> { +): Promise> { + // Compute spell slots summary for easier LLM consumption + const spellSlotsSummary: SpellSlotsSummary = {} + + for (let level = 1; level <= 9; level++) { + const total = char.spellSlots.filter((slot) => slot === level).length + const available = char.availableSpellSlots.filter((slot) => slot === level).length + + if (total > 0) { + spellSlotsSummary[`level${level}`] = { + total, + used: total - available, + available, + } + } + } + // Return the full computed character as structured data, excluding traits const { traits: _traits, ...charWithoutTraits } = char return { complete: true, - result: charWithoutTraits, + result: { + ...charWithoutTraits, + spellSlotsSummary, + }, } } From a08a471a18be0e9c6f2a49df2bdd5582f55fdcc1 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 11:16:30 -0800 Subject: [PATCH 05/13] feat: fix short rest service executor supports hit dice usage, and proper acracne recovery restoration --- src/components/ChatBox.tsx | 2 +- src/components/ShortRestForm.tsx | 2 +- src/services/characterTraits.ts | 4 +- src/services/shortRest.test.ts | 309 +++++++++++++++++++++++++++++++ src/services/shortRest.ts | 63 +++++-- 5 files changed, 356 insertions(+), 24 deletions(-) diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 147adf7..15668db 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -14,7 +14,7 @@ function markdownToHtml(markdown: string, icon?: string): string { const renderer = new marked.Renderer() if (icon) { - renderer.paragraph = function(tokens: Tokens.Paragraph) { + renderer.paragraph = function (tokens: Tokens.Paragraph) { const text = this.parser.parseInline(tokens.tokens) if (isFirstParagraph) { diff --git a/src/components/ShortRestForm.tsx b/src/components/ShortRestForm.tsx index e3cef4b..d8eb940 100644 --- a/src/components/ShortRestForm.tsx +++ b/src/components/ShortRestForm.tsx @@ -170,7 +170,7 @@ export const ShortRestForm = ({ character, values, errors }: ShortRestFormProps) class={`form-check-label ${isDisabled ? "text-muted" : ""}`} for={`arcane-slot-${level}`} > - Level {level} slot + Level {level} slots
) diff --git a/src/services/characterTraits.ts b/src/services/characterTraits.ts index d42f872..842610a 100644 --- a/src/services/characterTraits.ts +++ b/src/services/characterTraits.ts @@ -28,10 +28,10 @@ export async function executeCharacterTraits( // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON _parameters: Record, _isCheck?: boolean -): Promise> { +): Promise> { const traits = char.traits return { complete: true, - result: {traits} + result: { traits }, } } diff --git a/src/services/shortRest.test.ts b/src/services/shortRest.test.ts index 8488278..2bde885 100644 --- a/src/services/shortRest.test.ts +++ b/src/services/shortRest.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test" +import { create as createSpellSlot } from "@src/db/char_spell_slots" import type { Character } from "@src/db/characters" import type { User } from "@src/db/users" import { useTestApp } from "@src/test/app" @@ -237,4 +238,312 @@ describe("shortRest", () => { }) }) }) + + describe("arcane recovery", () => { + let user: User + let wizardCharacter: Character + + beforeEach(async () => { + user = await userFactory.create({}, testCtx.db) + // Create a level 4 wizard (budget = ceil(4/2) = 2) + wizardCharacter = await characterFactory.create( + { user_id: user.id, class: "wizard", level: 4 }, + testCtx.db + ) + }) + + describe("validation", () => { + test("non-wizard cannot use Arcane Recovery", async () => { + const fighter = await characterFactory.create( + { user_id: user.id, class: "fighter", level: 4 }, + testCtx.db + ) + const char = await computeCharacter(testCtx.db, fighter.id) + if (!char) throw new Error("Character not found") + + const result = await shortRest(testCtx.db, char, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "false", + }) + + expect(result.complete).toBe(false) + if (!result.complete) { + expect(result.errors.arcane_recovery).toBe("Only Wizards can use Arcane Recovery") + } + }) + + test("level 4 wizard can restore level-1 slots", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Use 2 spell slots to have enough to restore (level 4 wizard will restore 2 slots) + for (let i = 0; i < 2; i++) { + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + } + + const charWithUsedSlots = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charWithUsedSlots) throw new Error("Character not found") + + const result = await shortRest(testCtx.db, charWithUsedSlots, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "true", + }) + + // Check mode should pass validation + expect(result.complete).toBe(false) + if (!result.complete) { + expect(result.errors.arcane_slots).toBeUndefined() + } + }) + + test("cannot restore slots that aren't used", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Try to restore slots without using any + const result = await shortRest(testCtx.db, char, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "false", + }) + + expect(result.complete).toBe(false) + if (!result.complete) { + expect(result.errors.arcane_slots).toContain("only have 0 used") + } + }) + + test("level 4 wizard cannot restore more slots than budget allows", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Use multiple spell slots + for (let i = 0; i < 4; i++) { + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + } + + const charWithUsedSlots = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charWithUsedSlots) throw new Error("Character not found") + + // Level 4 wizard budget = 2 + // Restoring level-1 slots will restore 2 slots (2 * 1 = 2 budget) + // This should pass validation + const result = await shortRest(testCtx.db, charWithUsedSlots, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "true", + }) + + expect(result.complete).toBe(false) + if (!result.complete) { + expect(result.errors.arcane_slots).toBeUndefined() + } + }) + + test("cannot restore more slots than actually used", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Use only 1 level-1 slot + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + + const charWithUsedSlot = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charWithUsedSlot) throw new Error("Character not found") + + // Try to restore level-1 slots (which would restore 2 slots, but only 1 is used) + const result = await shortRest(testCtx.db, charWithUsedSlot, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "false", + }) + + expect(result.complete).toBe(false) + if (!result.complete) { + expect(result.errors.arcane_slots).toContain("only have 1 used level 1 spell slot") + } + }) + }) + + describe("execution", () => { + test("level 4 wizard restores 2 level-1 slots", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Use 2 level-1 slots + for (let i = 0; i < 2; i++) { + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + } + + const charWithUsedSlots = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charWithUsedSlots) throw new Error("Character not found") + + const beforeAvailable = charWithUsedSlots.availableSpellSlots.filter((s) => s === 1).length + + const result = await shortRest(testCtx.db, charWithUsedSlots, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "false", + }) + + expect(result.complete).toBe(true) + if (result.complete) { + expect(result.result.arcaneRecoveryUsed).toBe(true) + expect(result.result.spellSlotsRestored).toBe(2) // Should restore 2 slots + } + + // Verify slots were actually restored + const charAfter = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charAfter) throw new Error("Character not found") + + const afterAvailable = charAfter.availableSpellSlots.filter((s) => s === 1).length + expect(afterAvailable).toBe(beforeAvailable + 2) + }) + + test("level 4 wizard restores 1 level-2 slot", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Use a level-2 slot + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 2, + action: "use", + note: "Cast spell", + }) + + const charWithUsedSlot = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charWithUsedSlot) throw new Error("Character not found") + + const beforeAvailable = charWithUsedSlot.availableSpellSlots.filter((s) => s === 2).length + + const result = await shortRest(testCtx.db, charWithUsedSlot, { + arcane_recovery: "true", + arcane_slot_2: "true", + is_check: "false", + }) + + expect(result.complete).toBe(true) + if (result.complete) { + expect(result.result.arcaneRecoveryUsed).toBe(true) + expect(result.result.spellSlotsRestored).toBe(1) // Should restore 1 slot + } + + // Verify slot was actually restored + const charAfter = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charAfter) throw new Error("Character not found") + + const afterAvailable = charAfter.availableSpellSlots.filter((s) => s === 2).length + expect(afterAvailable).toBe(beforeAvailable + 1) + }) + + test("level 6 wizard restores 3 level-1 slots", async () => { + // Create a level 6 wizard (budget = ceil(6/2) = 3) + const level6Wizard = await characterFactory.create( + { user_id: user.id, class: "wizard", level: 6 }, + testCtx.db + ) + const char = await computeCharacter(testCtx.db, level6Wizard.id) + if (!char) throw new Error("Character not found") + + // Use 3 level-1 slots + for (let i = 0; i < 3; i++) { + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + } + + const charWithUsedSlots = await computeCharacter(testCtx.db, level6Wizard.id) + if (!charWithUsedSlots) throw new Error("Character not found") + + const beforeAvailable = charWithUsedSlots.availableSpellSlots.filter((s) => s === 1).length + + const result = await shortRest(testCtx.db, charWithUsedSlots, { + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "false", + }) + + expect(result.complete).toBe(true) + if (result.complete) { + expect(result.result.spellSlotsRestored).toBe(3) // Should restore 3 slots + } + + // Verify slots were actually restored + const charAfter = await computeCharacter(testCtx.db, level6Wizard.id) + if (!charAfter) throw new Error("Character not found") + + const afterAvailable = charAfter.availableSpellSlots.filter((s) => s === 1).length + expect(afterAvailable).toBe(beforeAvailable + 3) + }) + + test("combines with hit dice spending", async () => { + const char = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!char) throw new Error("Character not found") + + // Damage character and use a spell slot + await updateHitPoints(testCtx.db, char, { + action: "lose", + amount: "10", + note: "Test damage", + is_check: "false", + }) + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + await createSpellSlot(testCtx.db, { + character_id: char.id, + slot_level: 1, + action: "use", + note: "Cast spell", + }) + + const charDamagedAndUsedSlots = await computeCharacter(testCtx.db, wizardCharacter.id) + if (!charDamagedAndUsedSlots) throw new Error("Character not found") + + const result = await shortRest(testCtx.db, charDamagedAndUsedSlots, { + spend_die_0: "6", + roll_die_0: "4", + arcane_recovery: "true", + arcane_slot_1: "true", + is_check: "false", + }) + + expect(result.complete).toBe(true) + if (result.complete) { + expect(result.result.hitDiceSpent).toBe(1) + expect(result.result.hpRestored).toBeGreaterThan(0) + expect(result.result.arcaneRecoveryUsed).toBe(true) + expect(result.result.spellSlotsRestored).toBe(2) + } + }) + }) + }) }) diff --git a/src/services/shortRest.ts b/src/services/shortRest.ts index 77785b0..ab0d718 100644 --- a/src/services/shortRest.ts +++ b/src/services/shortRest.ts @@ -128,27 +128,31 @@ export async function shortRest( errors.arcane_recovery = "Only Wizards can use Arcane Recovery" } else { const maxArcaneRecoveryLevel = Math.min(5, Math.ceil(wizardClass.level / 2)) - const selectedSlots: number[] = [] + const selectedSlots: { level: number; count: number }[] = [] for (let level = 1; level <= 5; level++) { if (values[`arcane_slot_${level}` as keyof typeof values]) { - selectedSlots.push(level) + // Calculate how many slots of this level to restore + // Maximum is based on remaining budget divided by slot level + const maxCount = Math.floor(maxArcaneRecoveryLevel / level) + selectedSlots.push({ level, count: maxCount }) } } - // Calculate total slot levels - const totalSlotLevels = selectedSlots.reduce((sum, level) => sum + level, 0) + // Calculate total slot levels (sum of level * count for each selected level) + const totalSlotLevels = selectedSlots.reduce((sum, slot) => sum + slot.level * slot.count, 0) if (totalSlotLevels > maxArcaneRecoveryLevel) { errors.arcane_slots = `Total slot levels (${totalSlotLevels}) exceeds maximum (${maxArcaneRecoveryLevel})` } - // Validate character has used slots to restore + // Validate character has enough used slots to restore if (char.spellSlots && char.availableSpellSlots) { - for (const level of selectedSlots) { - const total = char.spellSlots.filter((s) => s === level).length - const available = char.availableSpellSlots.filter((s) => s === level).length - if (available >= total) { - errors.arcane_slots = `You don't have any used level ${level} spell slots to restore` + for (const slot of selectedSlots) { + const total = char.spellSlots.filter((s) => s === slot.level).length + const available = char.availableSpellSlots.filter((s) => s === slot.level).length + const used = total - available + if (used < slot.count) { + errors.arcane_slots = `You only have ${used} used level ${slot.level} spell slot(s), but trying to restore ${slot.count}` break } } @@ -205,15 +209,23 @@ export async function shortRest( if (result.data.arcane_recovery) { summary.arcaneRecoveryUsed = true + const wizardClass = char.classes.find((c) => c.class === "wizard")! + const maxArcaneRecoveryLevel = Math.min(5, Math.ceil(wizardClass.level / 2)) + for (let level = 1; level <= 5; level++) { if (result.data[`arcane_slot_${level}` as keyof typeof result.data]) { - await createSpellSlotDb(tx, { - character_id: currentChar.id, - slot_level: level, - action: "restore", - note: `${note} - Arcane Recovery`, - }) - summary.spellSlotsRestored++ + // Restore multiple slots of this level based on budget + const maxCount = Math.floor(maxArcaneRecoveryLevel / level) + + for (let i = 0; i < maxCount; i++) { + await createSpellSlotDb(tx, { + character_id: currentChar.id, + slot_level: level, + action: "restore", + note: `${note} - Arcane Recovery`, + }) + summary.spellSlotsRestored++ + } } } } @@ -246,6 +258,20 @@ export async function executeShortRest( const data: Record = { note: parameters.note?.toString() || "", is_check: isCheck ? "true" : "false", + // Hit dice spending (AI can specify these for automated rests) + spend_die_0: parameters.spend_die_0?.toString() || "", + roll_die_0: parameters.roll_die_0?.toString() || "", + spend_die_1: parameters.spend_die_1?.toString() || "", + roll_die_1: parameters.roll_die_1?.toString() || "", + spend_die_2: parameters.spend_die_2?.toString() || "", + roll_die_2: parameters.roll_die_2?.toString() || "", + spend_die_3: parameters.spend_die_3?.toString() || "", + roll_die_3: parameters.roll_die_3?.toString() || "", + spend_die_4: parameters.spend_die_4?.toString() || "", + roll_die_4: parameters.roll_die_4?.toString() || "", + spend_die_5: parameters.spend_die_5?.toString() || "", + roll_die_5: parameters.roll_die_5?.toString() || "", + // Arcane Recovery arcane_recovery: parameters.arcane_recovery?.toString() || "false", arcane_slot_1: parameters.arcane_slot_1?.toString() || "false", arcane_slot_2: parameters.arcane_slot_2?.toString() || "false", @@ -254,9 +280,6 @@ export async function executeShortRest( arcane_slot_5: parameters.arcane_slot_5?.toString() || "false", } - // Add hit dice spending fields (AI can't specify these, so we won't spend any dice automatically) - // This tool is mainly for recording the rest and using Arcane Recovery - return shortRest(db, char, data) } From 5de3c771e03815e8d9a14300b8b8ffec8a40a529 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 11:59:27 -0800 Subject: [PATCH 06/13] feat: tool for looking up item templates and item creation --- src/lib/serviceResult.ts | 4 +- src/services/createItem.ts | 158 +++++++------- src/services/createItemTool.ts | 118 +++++++++++ src/services/lookupItemTemplate.test.ts | 268 ++++++++++++++++++++++++ src/services/lookupItemTemplate.ts | 87 ++++++++ src/tools.ts | 25 ++- test-schema-types.ts | 58 ----- 7 files changed, 577 insertions(+), 141 deletions(-) create mode 100644 src/services/createItemTool.ts create mode 100644 src/services/lookupItemTemplate.test.ts create mode 100644 src/services/lookupItemTemplate.ts delete mode 100644 test-schema-types.ts diff --git a/src/lib/serviceResult.ts b/src/lib/serviceResult.ts index 4dbb80a..730bfe5 100644 --- a/src/lib/serviceResult.ts +++ b/src/lib/serviceResult.ts @@ -3,9 +3,9 @@ * Services return either a successful result with typed data, * or validation errors with form values for re-population * - * @template TResult - The type of data returned on success + * @template TResult - The type of data returned on success (must be a record/object, not an array) */ // biome-ignore lint/suspicious/noExplicitAny: Service results can be any valid JSON -export type ServiceResult> = +export type ServiceResult & { length?: never }> = | { complete: true; result: TResult } | { complete: false; values: Record; errors: Record } diff --git a/src/services/createItem.ts b/src/services/createItem.ts index 3bd9716..61be576 100644 --- a/src/services/createItem.ts +++ b/src/services/createItem.ts @@ -18,12 +18,12 @@ import { NumericEnumField, OptionalString, } from "@src/lib/formSchemas" -import { logger } from "@src/lib/logger" +import type { ServiceResult } from "@src/lib/serviceResult" import type { SQL } from "bun" import { z } from "zod" // Base schema for all items -const BaseItemSchema = z.object({ +export const BaseItemSchema = z.object({ character_id: z.string(), name: z.string().min(1, "Item name is required"), description: OptionalString(), @@ -228,7 +228,7 @@ const WeaponItemSchema = z.discriminatedUnion("weapon_type", [ }), ]) -const ItemTypeSchemas = z.discriminatedUnion("category", [ +export const ItemTypeSchemas = z.discriminatedUnion("category", [ BasicItemSchema, ShieldItemSchema, ArmorItemSchema, @@ -239,9 +239,11 @@ export const CreateItemApiSchema = ItemTypeSchemas.and(BaseItemSchema) export type CreateItemData = z.infer -export type CreateItemResult = - | { complete: true } - | { complete: false; values: Record; errors?: Record } +export type CreateItemResult = ServiceResult<{ + id: string + name: string + category: string +}> const MAX_DAMAGE_ROWS = 10 as const @@ -384,85 +386,81 @@ export async function createItem( long_range = result.data.long_range } - // Create the item and related records in a transaction - try { - // Create the base item - const newItem = await createItemDb(db, { - name: result.data.name, - description: result.data.description, - category: result.data.category, - armor_type: result.data.category === "armor" ? result.data.armor_type : null, - armor_class: result.data.category === "armor" ? result.data.armor_class : null, - armor_class_dex: result.data.category === "armor" ? result.data.armor_class_dex : null, - armor_class_dex_max: - result.data.category === "armor" ? result.data.armor_class_dex_max : null, - min_strength: result.data.category === "armor" ? result.data.min_strength : null, - - armor_modifier: result.data.category === "shield" ? result.data.armor_modifier : null, - - thrown: result.data.category === "weapon" ? result.data.weapon_type === "thrown" : false, - finesse: result.data.category === "weapon" ? result.data.finesse : false, - mastery: result.data.category === "weapon" ? result.data.mastery : null, - martial: result.data.category === "weapon" ? result.data.martial : false, - light: result.data.category === "weapon" ? result.data.light : false, - heavy: result.data.category === "weapon" ? result.data.heavy : false, - two_handed: result.data.category === "weapon" ? result.data.two_handed : false, - reach: result.data.category === "weapon" ? result.data.reach : false, - loading: result.data.category === "weapon" ? result.data.loading : false, - - normal_range, - long_range, - - created_by: userId, + // Create the base item + const newItem = await createItemDb(db, { + name: result.data.name, + description: result.data.description, + category: result.data.category, + armor_type: result.data.category === "armor" ? result.data.armor_type : null, + armor_class: result.data.category === "armor" ? result.data.armor_class : null, + armor_class_dex: result.data.category === "armor" ? result.data.armor_class_dex : null, + armor_class_dex_max: result.data.category === "armor" ? result.data.armor_class_dex_max : null, + min_strength: result.data.category === "armor" ? result.data.min_strength : null, + + armor_modifier: result.data.category === "shield" ? result.data.armor_modifier : null, + + thrown: result.data.category === "weapon" ? result.data.weapon_type === "thrown" : false, + finesse: result.data.category === "weapon" ? result.data.finesse : false, + mastery: result.data.category === "weapon" ? result.data.mastery : null, + martial: result.data.category === "weapon" ? result.data.martial : false, + light: result.data.category === "weapon" ? result.data.light : false, + heavy: result.data.category === "weapon" ? result.data.heavy : false, + two_handed: result.data.category === "weapon" ? result.data.two_handed : false, + reach: result.data.category === "weapon" ? result.data.reach : false, + loading: result.data.category === "weapon" ? result.data.loading : false, + + normal_range, + long_range, + + created_by: userId, + }) + + // Create damage records for weapons + for (const dmg of damages) { + await createItemDamageDb(db, { + item_id: newItem.id, + dice: dmg.dice, + type: dmg.type, + versatile: dmg.versatile, }) + } - // Create damage records for weapons - for (const dmg of damages) { - await createItemDamageDb(db, { - item_id: newItem.id, - dice: dmg.dice, - type: dmg.type, - versatile: dmg.versatile, - }) - } - - // Create starting ammo charges if applicable - if (result.data.category === "weapon" && result.data.weapon_type === "ranged") { - await createItemChargeDb(db, { - item_id: newItem.id, - delta: result.data.starting_ammo, - note: "Starting ammunition", - }) - } - - // Create stealth disadvantage effect if applicable - if (result.data.category === "armor" && result.data.stealth_disadvantage) { - await createItemEffectDb(db, { - item_id: newItem.id, - target: "stealth", - op: "disadvantage", - value: null, - applies: "worn", - }) - } + // Create starting ammo charges if applicable + if (result.data.category === "weapon" && result.data.weapon_type === "ranged") { + await createItemChargeDb(db, { + item_id: newItem.id, + delta: result.data.starting_ammo, + note: "Starting ammunition", + }) + } - // Add item to character's inventory - await createCharItemDb(db, { - character_id: result.data.character_id, + // Create stealth disadvantage effect if applicable + if (result.data.category === "armor" && result.data.stealth_disadvantage) { + await createItemEffectDb(db, { item_id: newItem.id, - worn: false, - wielded: false, - dropped_at: null, - note: result.data.note, + target: "stealth", + op: "disadvantage", + value: null, + applies: "worn", }) + } - return { complete: true } - } catch (error) { - logger.error("Error creating item:", error as Error) - return { - complete: false, - values: data, - errors: { general: "Failed to create item. Please try again." }, - } + // Add item to character's inventory + await createCharItemDb(db, { + character_id: result.data.character_id, + item_id: newItem.id, + worn: false, + wielded: false, + dropped_at: null, + note: result.data.note, + }) + + return { + complete: true, + result: { + id: newItem.id, + name: newItem.name, + category: newItem.category, + }, } } diff --git a/src/services/createItemTool.ts b/src/services/createItemTool.ts new file mode 100644 index 0000000..fcf14e0 --- /dev/null +++ b/src/services/createItemTool.ts @@ -0,0 +1,118 @@ +import { tool } from "ai" +import type { SQL } from "bun" +import type { ComputedCharacter } from "./computeCharacter" +import { BaseItemSchema, createItem, ItemTypeSchemas } from "./createItem" + +export const createItemToolName = "create_item" as const +const InputSchema = BaseItemSchema.omit({ + is_check: true, + template: true, + prev_template: true, +}).and(ItemTypeSchemas) + +/** + * Vercel AI SDK tool definition for item creation + * This tool requires approval before execution + */ +export const createItemTool = tool({ + name: createItemToolName, + description: [ + "Create a new item and add it to the character's inventory.", + "The item will be added to inventory but not equipped.", + "You can create weapons, armor, shields, or misc items. For weapons, you must specify damage dice.", + "For armor, you must specify armor_class and armor_type. For shields, you must specify armor_modifier.", + "For common items, you can use lookup_item_template tool first to get item details from the SRD, then pass those details here.", + ].join(" "), + inputSchema: InputSchema, +}) + +/** + * Execute the create_item tool from AI assistant + * Converts AI parameters to service format and calls createItem + */ +export async function executeCreateItem( + db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record, + isCheck?: boolean +) { + // Convert all parameters to string format for the service + const data: Record = {} + + // Convert each parameter to string, handling all the possible fields + for (const [key, value] of Object.entries(parameters)) { + if (value !== null && value !== undefined) { + data[key] = value.toString() + } + } + + // Add is_check flag + data.is_check = isCheck ? "true" : "false" + + // Call the existing createItem service and return its result directly + return createItem(db, char.user_id, data) +} + +/** + * Format approval message for create_item tool calls + */ +export function formatCreateItemApproval( + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record +): string { + const { name, category, description } = parameters + let message = `Create ${category}: ${name}` + + // Add category-specific details + if (category === "weapon") { + const weaponDetails = [] + if (parameters.weapon_type) { + weaponDetails.push(parameters.weapon_type) + } + if (parameters.martial) { + weaponDetails.push("martial") + } + if (parameters.finesse) { + weaponDetails.push("finesse") + } + if (parameters.two_handed) { + weaponDetails.push("two-handed") + } + + // Add damage info + if (parameters.damage_num_dice_0 && parameters.damage_die_value_0) { + const damage = `${parameters.damage_num_dice_0}d${parameters.damage_die_value_0}` + const damageType = parameters.damage_type_0 || "" + weaponDetails.push(`${damage} ${damageType}`) + } + + if (weaponDetails.length > 0) { + message += ` (${weaponDetails.join(", ")})` + } + } else if (category === "armor") { + const armorDetails = [] + if (parameters.armor_type) { + armorDetails.push(parameters.armor_type) + } + if (parameters.armor_class) { + armorDetails.push(`AC ${parameters.armor_class}`) + } + if (parameters.stealth_disadvantage) { + armorDetails.push("stealth disadvantage") + } + if (armorDetails.length > 0) { + message += ` (${armorDetails.join(", ")})` + } + } else if (category === "shield") { + if (parameters.armor_modifier) { + message += ` (+${parameters.armor_modifier} AC)` + } + } + + if (description) { + message += `\n${description}` + } + + return message +} diff --git a/src/services/lookupItemTemplate.test.ts b/src/services/lookupItemTemplate.test.ts new file mode 100644 index 0000000..b4f64a1 --- /dev/null +++ b/src/services/lookupItemTemplate.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, test } from "bun:test" +import { useTestApp } from "@src/test/app" +import { characterFactory } from "@src/test/factories/character" +import { userFactory } from "@src/test/factories/user" +import { computeCharacter } from "./computeCharacter" +import { executeLookupItemTemplate } from "./lookupItemTemplate" + +describe("executeLookupItemTemplate", () => { + const testCtx = useTestApp() + + test("finds item by exact name match", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "Longsword", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + expect(result.result.matching_items).toBeTruthy() + expect(Array.isArray(result.result.matching_items)).toBe(true) + expect(result.result.matching_items.length).toBe(1) + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Longsword") + expect(template.category).toBe("weapon") + }) + + test("finds item with case-insensitive match", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "longsword", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + expect(result.result.matching_items.length).toBe(1) + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Longsword") + }) + + test("finds multiple items by partial name match", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + // "sword" matches multiple items (Longsword, Shortsword, Greatsword, etc) + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "sword", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + expect(result.result.matching_items).toBeTruthy() + expect(Array.isArray(result.result.matching_items)).toBe(true) + expect(result.result.matching_items.length).toBeGreaterThan(1) + // Check that all results contain "sword" in the name + for (const template of result.result.matching_items) { + expect(template.name.toLowerCase()).toContain("sword") + } + }) + + test("filters by category when provided", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + // Search for "shield" in weapon category should fail + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "shield", + category: "weapon", + }) + + expect(result.complete).toBe(false) + if (result.complete !== false) return + expect(result.errors.template_name).toContain("No item template found") + expect(result.errors.template_name).toContain('category "weapon"') + }) + + test("finds shield when filtering by shield category", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "shield", + category: "shield", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + expect(result.result.matching_items.length).toBe(1) + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Shield") + expect(template.category).toBe("shield") + }) + + test("returns error when item not found", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "NonexistentItem", + }) + + expect(result.complete).toBe(false) + if (result.complete !== false) return + expect(result.errors.template_name).toContain("No item template found matching") + }) + + test("includes weapon details in response", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "Longsword", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Longsword") + expect(template.category).toBe("weapon") + expect(template.weapon_type).toBe("melee") + expect(template.damage).toBeTruthy() + expect(Array.isArray(template.damage)).toBe(true) + if (template.damage && template.damage.length > 0) { + const damage = template.damage[0] + if (!damage) throw new Error("Damage not found") + expect(damage.num_dice).toBeTruthy() + expect(damage.die_value).toBeTruthy() + expect(damage.type).toBeTruthy() + } + }) + + test("includes armor details in response", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "Chain Mail", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Chain mail") + expect(template.category).toBe("armor") + expect(template.armor_type).toBeTruthy() + expect(template.armor_class).toBeTruthy() + expect(typeof template.armor_class).toBe("number") + }) + + test("includes shield details in response", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "Shield", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Shield") + expect(template.category).toBe("shield") + expect(template.armor_modifier).toBeTruthy() + expect(typeof template.armor_modifier).toBe("number") + }) + + test("returns error for invalid parameters", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + // Missing template_name parameter + }) + + expect(result.complete).toBe(false) + if (result.complete !== false) return + expect(result.errors.template_name).toBeTruthy() + }) + + test("returns multiple matches for empty string", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + // Empty string matches all items + expect(result.result.matching_items.length).toBeGreaterThan(10) + }) + + test("handles template name with extra whitespace", async () => { + const user = await userFactory.create({}, testCtx.db) + const character = await characterFactory.create({ user_id: user.id }, testCtx.db) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: " Longsword ", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + expect(result.result.matching_items.length).toBe(1) + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Longsword") + }) + + test("uses character's ruleset for template lookup", async () => { + const user = await userFactory.create({}, testCtx.db) + // Create character with srd51 ruleset (default) + const character = await characterFactory.create( + { user_id: user.id, ruleset: "srd51" }, + testCtx.db + ) + const computedChar = await computeCharacter(testCtx.db, character.id) + if (!computedChar) throw new Error("Character not found") + + const result = await executeLookupItemTemplate(testCtx.db, computedChar, { + template_name: "Longsword", + }) + + expect(result.complete).toBe(true) + if (result.complete !== true) return + expect(result.result.matching_items.length).toBe(1) + const template = result.result.matching_items[0] + if (!template) throw new Error("Template not found") + expect(template.name).toBe("Longsword") + // The template should be from srd51 (which is the default) + }) +}) diff --git a/src/services/lookupItemTemplate.ts b/src/services/lookupItemTemplate.ts new file mode 100644 index 0000000..191511e --- /dev/null +++ b/src/services/lookupItemTemplate.ts @@ -0,0 +1,87 @@ +import type { TemplateItem } from "@src/lib/dnd" +import { getAllItemTemplates, type RulesetId } from "@src/lib/dnd/itemTemplates" +import { zodToFormErrors } from "@src/lib/formErrors" +import type { ServiceResult } from "@src/lib/serviceResult" +import { tool } from "ai" +import type { SQL } from "bun" +import { z } from "zod" +import type { ComputedCharacter } from "./computeCharacter" + +export const LookupItemTemplateApiSchema = z.object({ + template_name: z + .string() + .describe( + "The name or partial name of the item template to look up (e.g., 'longsword', 'chain mail', 'shield'). Case-insensitive, supports partial matches." + ), + category: z + .enum(["weapon", "armor", "shield"]) + .optional() + .describe("Optional category filter to narrow search results (weapon, armor, or shield)"), +}) + +export const lookupItemTemplateToolName = "lookup_item_template" as const + +/** + * Vercel AI SDK tool definition for item template lookup + * This is a read-only informational tool that doesn't modify character state + */ +export const lookupItemTemplateTool = tool({ + name: lookupItemTemplateToolName, + description: + "Look up common items by name to get their details from the D&D SRD. Use this to discover available items and their properties before creating items. Returns an array of all matching templates (supports substring search). Each template includes full details like damage, armor class, properties, and other mechanical information. The template details can then be passed to the create_item tool.", + inputSchema: LookupItemTemplateApiSchema, +}) + +/** + * Execute item template lookup + * Searches the item template catalog and returns matching template details + */ +export async function executeLookupItemTemplate( + _db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record, + _isCheck?: boolean +): Promise> { + const parsed = LookupItemTemplateApiSchema.safeParse(parameters) + + if (!parsed.success) { + return { + complete: false, + values: parameters, + errors: zodToFormErrors(parsed.error), + } + } + + const { template_name, category } = parsed.data + const searchTerm = template_name.toLowerCase().trim() + + // Get templates for character's ruleset + const ruleset = (char.ruleset || "srd51") as RulesetId + let templates = getAllItemTemplates(ruleset) + + // Filter by category if provided + if (category) { + templates = templates.filter((t) => t.category === category) + } + + // Search using substring match + const matches = templates.filter((t) => t.name.toLowerCase().includes(searchTerm)) + + if (matches.length === 0) { + const categoryMsg = category ? ` in category "${category}"` : "" + return { + complete: false, + values: parameters, + errors: { + template_name: `No item template found matching "${template_name}"${categoryMsg}. Try a different name or partial name.`, + }, + } + } + + // Return all matching templates + return { + complete: true, + result: { matching_items: matches }, + } +} diff --git a/src/tools.ts b/src/tools.ts index da566cf..e6d2eee 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -30,6 +30,12 @@ import { executeCharacterTraits, } from "./services/characterTraits" import type { ComputedCharacter } from "./services/computeCharacter" +import { + createItemTool, + createItemToolName, + executeCreateItem, + formatCreateItemApproval, +} from "./services/createItemTool" import { equipItemTool, equipItemToolName, @@ -48,6 +54,11 @@ import { longRestTool, longRestToolName, } from "./services/longRest" +import { + executeLookupItemTemplate, + lookupItemTemplateTool, + lookupItemTemplateToolName, +} from "./services/lookupItemTemplate" import { executeLookupSpell, lookupSpellTool, lookupSpellToolName } from "./services/lookupSpell" import { executeManageCharge, @@ -115,7 +126,8 @@ export type ToolExecutor = ( // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON parameters: Record, isCheck?: boolean -) => Promise + // biome-ignore lint/suspicious/noExplicitAny: Service results can be any valid JSON +) => Promise>> /** * Function signature for tool approval message formatters @@ -230,6 +242,17 @@ export const TOOLS: ToolRegistration[] = [ }, // Items + { + name: lookupItemTemplateToolName, + tool: lookupItemTemplateTool, + executor: executeLookupItemTemplate, + }, + { + name: createItemToolName, + tool: createItemTool, + executor: executeCreateItem, + formatApprovalMessage: formatCreateItemApproval, + }, { name: equipItemToolName, tool: equipItemTool, diff --git a/test-schema-types.ts b/test-schema-types.ts deleted file mode 100644 index bc6e3ee..0000000 --- a/test-schema-types.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from "zod" -import { NumberField, Checkbox, OptionalString } from "./src/lib/formSchemas" - -// Test 1: Required number field -const requiredNumber = NumberField(z.number().int().min(1)) -type RequiredNumberType = z.infer - -// Test 2: Optional number field (with .nullable() and .default()) -const optionalNumber = NumberField(z.number().int().nullable().default(null)) -type OptionalNumberType = z.infer - -// Test 3: Number with default -const numberWithDefault = NumberField(z.number().int().default(0)) -type NumberWithDefaultType = z.infer - -// Test 4: Checkbox -const checkbox = Checkbox() -type CheckboxType = z.infer - -// Test 5: Optional string -const optionalString = OptionalString() -type OptionalStringType = z.infer - -// Print the types at compile time by causing errors -const test1: RequiredNumberType = 42 -const test2: OptionalNumberType = null -const test3: NumberWithDefaultType = 0 -const test4: CheckboxType = true -const test5: OptionalStringType = null - -console.log("Type test - required number:", typeof test1) -console.log("Type test - optional number:", test2) -console.log("Type test - number with default:", typeof test3) -console.log("Type test - checkbox:", typeof test4) -console.log("Type test - optional string:", test5) - -// Runtime test -const result1 = requiredNumber.safeParse("42") -const result2 = optionalNumber.safeParse("") -const result3 = numberWithDefault.safeParse("") -const result4 = checkbox.safeParse("on") -const result5 = optionalString.safeParse("") - -console.log("\nRuntime tests:") -console.log("Required number '42':", result1.success ? result1.data : result1.error) -console.log("Optional number '':", result2.success ? result2.data : result2.error) -console.log("Number with default '':", result3.success ? result3.data : result3.error) -console.log("Checkbox 'on':", result4.success ? result4.data : result4.error) -console.log("Optional string '':", result5.success ? result5.data : result5.error) - -// Type assertions to verify compile-time types -const _typeCheck1: number = test1 -const _typeCheck2: number | null = test2 -const _typeCheck3: number = test3 -const _typeCheck4: boolean = test4 -const _typeCheck5: string | null = test5 - -console.log("\n✓ All type checks passed!") From 3056f69a660c59d7325c8a9b3c92cc54d53fe199 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 12:45:08 -0800 Subject: [PATCH 07/13] feat: killed parsedToForm lets not mutate data in services. we'll return exactly what the user passed in --- src/lib/formErrors.ts | 13 ------------- src/services/createItem.ts | 4 ++-- src/services/createItemEffect.ts | 6 +++--- src/services/updateItem.ts | 4 ++-- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/lib/formErrors.ts b/src/lib/formErrors.ts index 2889570..02fd6cd 100644 --- a/src/lib/formErrors.ts +++ b/src/lib/formErrors.ts @@ -25,16 +25,3 @@ export function zodToFormErrors(zodError: ZodError): FormErrors { }) ) } - -// biome-ignore lint/suspicious/noExplicitAny: we need to handle any type here -export function parsedToForm(values: Record): Record { - const result: Record = {} - for (const [key, value] of Object.entries(values)) { - if (value === null) { - result[key] = "" - continue - } - result[key] = String(value) - } - return result -} diff --git a/src/services/createItem.ts b/src/services/createItem.ts index 61be576..1e9d164 100644 --- a/src/services/createItem.ts +++ b/src/services/createItem.ts @@ -10,7 +10,7 @@ import { WeaponMasterySchema, } from "@src/lib/dnd" import type { DamageType } from "@src/lib/dnd/spells" -import { parsedToForm, zodToFormErrors } from "@src/lib/formErrors" +import { zodToFormErrors } from "@src/lib/formErrors" import { Checkbox, EnumField, @@ -373,7 +373,7 @@ export async function createItem( const result = CreateItemApiSchema.safeParse(preparedData) if (!result.success) { - return { complete: false, values: parsedToForm(values), errors: zodToFormErrors(result.error) } + return { complete: false, values: data, errors: zodToFormErrors(result.error) } } let normal_range: number | null = null diff --git a/src/services/createItemEffect.ts b/src/services/createItemEffect.ts index c9fde72..f60e756 100644 --- a/src/services/createItemEffect.ts +++ b/src/services/createItemEffect.ts @@ -1,6 +1,6 @@ import { create as createItemEffectDb } from "@src/db/item_effects" import { ItemEffectAppliesSchema, ItemEffectOpSchema, ItemEffectTargetSchema } from "@src/lib/dnd" -import { parsedToForm, zodToFormErrors } from "@src/lib/formErrors" +import { zodToFormErrors } from "@src/lib/formErrors" import { Checkbox, EnumField, NumberField } from "@src/lib/formSchemas" import { logger } from "@src/lib/logger" import type { SQL } from "bun" @@ -82,14 +82,14 @@ export async function createItemEffect( // Early return if validation errors or check mode if (isCheck || Object.keys(errors).length > 0) { - return { complete: false, values: parsedToForm(values), errors } + return { complete: false, values: data, errors } } // Full Zod validation const result = CreateItemEffectApiSchema.safeParse(data) if (!result.success) { - return { complete: false, values: parsedToForm(values), errors: zodToFormErrors(result.error) } + return { complete: false, values: data, errors: zodToFormErrors(result.error) } } // Create the item effect diff --git a/src/services/updateItem.ts b/src/services/updateItem.ts index 4514610..da74949 100644 --- a/src/services/updateItem.ts +++ b/src/services/updateItem.ts @@ -11,7 +11,7 @@ import { WeaponMasterySchema, } from "@src/lib/dnd" import type { DamageType } from "@src/lib/dnd/spells" -import { parsedToForm, zodToFormErrors } from "@src/lib/formErrors" +import { zodToFormErrors } from "@src/lib/formErrors" import { Checkbox, EnumField, @@ -335,7 +335,7 @@ export async function updateItem( const result = UpdateItemApiSchema.safeParse(preparedData) if (!result.success) { - return { complete: false, values: parsedToForm(values), errors: zodToFormErrors(result.error) } + return { complete: false, values: data, errors: zodToFormErrors(result.error) } } let normal_range: number | null = null From 430336b0c0e898a04540bbc51c84409d6411963a Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 12:46:18 -0800 Subject: [PATCH 08/13] refactor: ignore unused fields in item creation --- src/services/createItem.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/services/createItem.ts b/src/services/createItem.ts index 1e9d164..1480b95 100644 --- a/src/services/createItem.ts +++ b/src/services/createItem.ts @@ -30,10 +30,6 @@ export const BaseItemSchema = z.object({ category: ItemCategorySchema, note: OptionalString(), is_check: Checkbox().optional().default(false), - - // ignored here, just for template management - template: z.string().nullable().optional().default(null), - prev_template: z.string().nullable().optional().default(null), }) const BaseItemCheckSchema = BaseItemSchema.extend( z.object({ From fc52c437816aba8008bc50644224a85e8a34e31c Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 13:02:04 -0800 Subject: [PATCH 09/13] feat: nested data structure for shortrest we add mechanisms for doing this in general for other services --- src/components/ShortRestForm.tsx | 124 +++++++++++--------- src/lib/formErrors.ts | 32 ++++-- src/lib/formSchemas.ts | 74 ++++++++++++ src/routes/character.tsx | 5 +- src/services/shortRest.test.ts | 140 +++++++++++------------ src/services/shortRest.ts | 189 +++++++++++++++---------------- 6 files changed, 327 insertions(+), 237 deletions(-) diff --git a/src/components/ShortRestForm.tsx b/src/components/ShortRestForm.tsx index d8eb940..77168b1 100644 --- a/src/components/ShortRestForm.tsx +++ b/src/components/ShortRestForm.tsx @@ -16,28 +16,48 @@ export const ShortRestForm = ({ character, values, errors }: ShortRestFormProps) const maxArcaneRecoveryLevel = wizardClass ? Math.min(5, Math.ceil(wizardClass.level / 2)) : 0 // Calculate available spell slot levels for recovery (levels with used slots) - const availableSlotLevelsForRecovery: number[] = [] - if (character.spellSlots && character.availableSpellSlots) { - for (let level = 1; level <= maxArcaneRecoveryLevel; level++) { - const total = character.spellSlots.filter((s) => s === level).length - const available = character.availableSpellSlots.filter((s) => s === level).length - if (available < total) { - availableSlotLevelsForRecovery.push(level) - } + const availSpellSlots = character.availableSpellSlots + const usedSpellSlots = [...character.spellSlots] + for (const slot of availSpellSlots) { + const index = usedSpellSlots.indexOf(slot) + if (index !== -1) { + usedSpellSlots.splice(index, 1) } } - // Calculate currently selected slot levels total - let selectedSlotLevelsTotal = 0 - for (let level = 1; level <= maxArcaneRecoveryLevel; level++) { - if (values[`arcane_slot_${level}`] === "true") { - selectedSlotLevelsTotal += level - } + // Parse selected arcane slots from values (array field) + const selectedArcaneSlots: number[] = [] + const arcaneSlotsValue = values["arcane_slots[]"] + if (arcaneSlotsValue && Array.isArray(arcaneSlotsValue)) { + selectedArcaneSlots.push(...arcaneSlotsValue.map((v) => Number.parseInt(v, 10))) } + // Calculate currently selected slot levels total + const selectedSlotLevelsTotal = selectedArcaneSlots.reduce((sum, level) => sum + level, 0) + // Calculate remaining capacity for Arcane Recovery const maxArcaneRecoveryLevelRemaining = maxArcaneRecoveryLevel - selectedSlotLevelsTotal + // Parse selected dice from values (object array field parsed from dot notation) + const selectedDice: Array<{ die: number; roll: string; use: boolean }> = [] + if (values.dice && typeof values.dice === "object") { + for (const d of Object.values(values.dice) as Record[]) { + selectedDice.push({ + die: Number.parseInt(d.die || "", 10), + roll: String(d.roll || ""), + use: d.use === "true", + }) + } + } else { + for (const die of character.availableHitDice) { + selectedDice.push({ + die: die, + roll: "", + use: false, + }) + } + } + return (
0 ? (
- {character.availableHitDice.map((die, index) => { - const isChecked = values[`spend_die_${index}`] === String(die) - const dieError = errors[`spend_die_${index}`] - const rollError = errors[`roll_die_${index}`] + {selectedDice.map((die, index) => { + const dieError = errors[`dice.${index}.die`] + const rollError = errors[`dice.${index}.roll`] + return (
-
+
-
+
+ Roll: +
- {isChecked && ( -
- Roll: - -
- )}
{dieError &&
{dieError}
} {rollError &&
{rollError}
}
) })} + Available: {character.availableHitDice.length} / {character.hitDice.length} hit dice @@ -119,7 +139,7 @@ export const ShortRestForm = ({ character, values, errors }: ShortRestFormProps) )} {/* Arcane Recovery Section (Wizards only with used spell slots) */} - {hasArcaneRecovery && availableSlotLevelsForRecovery.length > 0 && ( + {hasArcaneRecovery && usedSpellSlots.length > 0 && ( <>
Arcane Recovery
@@ -146,13 +166,11 @@ export const ShortRestForm = ({ character, values, errors }: ShortRestFormProps) {values.arcane_recovery === "true" && (
Select spell slot levels to recover:
- {Array.from({ length: maxArcaneRecoveryLevel }, (_, i) => i + 1).map((level) => { - // Only render if this level has used slots - if (!availableSlotLevelsForRecovery.includes(level)) { - return null + {usedSpellSlots.map((level) => { + const isChecked = selectedArcaneSlots.includes(level) + if (isChecked) { + selectedArcaneSlots.splice(selectedArcaneSlots.indexOf(level), 1) } - - const isChecked = values[`arcane_slot_${level}`] === "true" const isDisabled = !isChecked && level > maxArcaneRecoveryLevelRemaining return ( @@ -160,8 +178,8 @@ export const ShortRestForm = ({ character, values, errors }: ShortRestFormProps) - Level {level} slots + Level {level} slot
) @@ -178,8 +196,8 @@ export const ShortRestForm = ({ character, values, errors }: ShortRestFormProps) Selected: {selectedSlotLevelsTotal} / {maxArcaneRecoveryLevel} slot levels - {errors.arcane_slots && ( -
{errors.arcane_slots}
+ {errors["arcane_slots[]"] && ( +
{errors["arcane_slots[]"]}
)}
)} diff --git a/src/lib/formErrors.ts b/src/lib/formErrors.ts index 02fd6cd..b75ac8b 100644 --- a/src/lib/formErrors.ts +++ b/src/lib/formErrors.ts @@ -1,5 +1,4 @@ import type { ZodError } from "zod" -import { z } from "zod" type FormErrors = Record @@ -7,21 +6,30 @@ function humanizeEnumError(error: string): string { // Transform enum errors from: expected one of "option1"|"option2"|"option3" // to: expected one of option1, option2, option3 - // First pass: replace "option"| with option, - let result = error.replace(/"([^"]+)"\|/g, "$1, ") + // First pass: replace first "option"| with option| + let result = error.replace(/"([^"]+)"\|/g, "$1|") - // Second pass: replace any remaining "option" (the last one) with option - result = result.replace(/"([^"]+)"/g, "$1") + // Second pass: replace any remaining |"option" with , option + result = result.replace(/\|"([^"]+)"/g, ", $1") return result } export function zodToFormErrors(zodError: ZodError): FormErrors { - const fieldErrors = z.flattenError(zodError).fieldErrors as Record - return Object.fromEntries( - Object.entries(fieldErrors).map(([field, errors]) => { - const humanizedErrors = errors.map(humanizeEnumError) - return [field, humanizedErrors.join("; ")] - }) - ) + const errors: FormErrors = {} + + for (const issue of zodError.issues) { + // Convert path array like ["dice", 0, "roll"] to "dice.0.roll" + const fieldName = issue.path.join(".") + const message = humanizeEnumError(issue.message) + + // If multiple errors on same field, join with semicolon + if (errors[fieldName]) { + errors[fieldName] += `; ${message}` + } else { + errors[fieldName] = message + } + } + + return errors } diff --git a/src/lib/formSchemas.ts b/src/lib/formSchemas.ts index 2212139..14a93d8 100644 --- a/src/lib/formSchemas.ts +++ b/src/lib/formSchemas.ts @@ -289,3 +289,77 @@ export function RequiredString() { export function OptionalString() { return z.preprocess(coerceString, z.string().nullable()) } + +// ============================================================================= +// Array field helpers (for parseBody({ all: true, dot: true })) +// ============================================================================= + +/** + * Wraps a Zod array schema with form data coercion. + * + * Use this for fields with [] suffix that may have multiple values. + * Handles both single values and arrays from parseBody({ all: true }). + * + * @example Array of numbers + * ```typescript + * ArrayField(z.array(z.coerce.number().int().min(1).max(5))) + * // Form with name="arcane_slots[]": + * // Single: "3" → [3] + * // Multiple: ["1", "2", "3"] → [1, 2, 3] + * // Empty: undefined → [] + * ``` + * + * @example Array of strings + * ```typescript + * ArrayField(z.array(z.string())) + * // Form with name="tags[]": + * // Multiple: ["combat", "magic"] → ["combat", "magic"] + * ``` + */ +export function ArrayField(schema: T) { + return z.preprocess((val) => { + if (val === undefined || val === null || val === "") { + return [] + } + return Array.isArray(val) ? val : [val] + }, schema) +} + +/** + * Creates a schema for object arrays using dot notation. + * + * Use this for fields like "dice.0.die", "dice.0.roll", "dice.1.die", etc. + * Requires parseBody({ dot: true }) to convert dot notation to nested objects. + * + * The schema handles partial objects and filters out incomplete entries. + * + * @example Hit dice with die value and roll + * ```typescript + * ObjectArrayField(z.object({ + * die: z.coerce.number().refine(v => [6, 8, 10, 12].includes(v)), + * roll: z.coerce.number().int().positive() + * })) + * // Form with names: "dice.0.die", "dice.0.roll", "dice.1.die", "dice.1.roll" + * // Input: { dice: [{ die: "8", roll: "5" }, { die: "10", roll: "7" }] } + * // Output: [{ die: 8, roll: 5 }, { die: 10, roll: 7 }] + * ``` + */ +export function ObjectArrayField>(itemSchema: T) { + return z.preprocess((val) => { + let arr: unknown[] = [] + if (Array.isArray(val)) { + arr = val + } else if (typeof val === "object" && val !== null) { + // Convert object with numeric keys to array + for (const key of Object.keys(val)) { + const index = Number(key) + if (!Number.isNaN(index)) { + arr[index] = (val as Record)[key] + } + } + } + + // Filter out null/undefined entries and validate each item + return arr.filter((item) => item !== null && item !== undefined) + }, z.array(itemSchema)) +} diff --git a/src/routes/character.tsx b/src/routes/character.tsx index 8fcde42..5b1722b 100644 --- a/src/routes/character.tsx +++ b/src/routes/character.tsx @@ -1010,8 +1010,9 @@ characterRoutes.post("/characters/:id/rest/short", async (c) => { return c.body(null, 204) } - const body = (await c.req.parseBody()) as Record - const result = await shortRest(getDb(c), char, body) + const body = await c.req.parseBody({ all: true, dot: true }) + // biome-ignore lint/suspicious/noExplicitAny: input might be strings, arrays, or objects + const result = await shortRest(getDb(c), char, body as Record) if (!result.complete) { return c.html() diff --git a/src/services/shortRest.test.ts b/src/services/shortRest.test.ts index 2bde885..92c3d5a 100644 --- a/src/services/shortRest.test.ts +++ b/src/services/shortRest.test.ts @@ -33,14 +33,13 @@ describe("shortRest", () => { // Character has d10 hit dice, try to spend a d8 const result = await shortRest(testCtx.db, char, { - spend_die_0: "8", - roll_die_0: "5", + dice: [{ die: "8", roll: "5" }], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.spend_die_0).toBe("You don't have a d8 hit die available") + expect(result.errors["dice.0.die"]).toBe("You don't have a d8 hit die available") } }) }) @@ -51,17 +50,16 @@ describe("shortRest", () => { if (!char) throw new Error("Character not found") // Character has 3 d10 hit dice - // Form field index doesn't matter - only die availability matters + // Array index doesn't matter - only die availability matters const result = await shortRest(testCtx.db, char, { - spend_die_5: "10", - roll_die_5: "7", + dice: [{ die: "10", roll: "7" }], is_check: "true", // Use check mode to avoid HP updates }) // Should succeed because character has d10s available expect(result.complete).toBe(false) // Check mode always returns incomplete if (!result.complete) { - expect(result.errors.spend_die_5).toBeUndefined() + expect(result.errors["dice.0.die"]).toBeUndefined() } }) }) @@ -71,22 +69,20 @@ describe("shortRest", () => { const char = await computeCharacter(testCtx.db, character.id) if (!char) throw new Error("Character not found") - // Try to spend two d10s when we have at least one + // Try to spend 4 d10s when we only have 3 available const result = await shortRest(testCtx.db, char, { - spend_die_0: "10", - roll_die_0: "7", - spend_die_1: "10", - roll_die_1: "8", - spend_die_2: "10", - roll_die_2: "6", - spend_die_3: "10", // This should fail - only 3 dice available - roll_die_3: "5", + dice: [ + { die: "10", roll: "7" }, + { die: "10", roll: "8" }, + { die: "10", roll: "6" }, + { die: "10", roll: "5" }, // This should fail - only 3 dice available + ], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.spend_die_3).toBe("You don't have a d10 hit die available") + expect(result.errors["dice.3.die"]).toBe("You don't have a d10 hit die available") } }) }) @@ -115,18 +111,17 @@ describe("shortRest", () => { // Try to spend 3 dice const result = await shortRest(testCtx.db, char, { - spend_die_0: "10", - roll_die_0: "7", - spend_die_1: "10", - roll_die_1: "8", - spend_die_2: "10", // This should fail - roll_die_2: "6", + dice: [ + { die: "10", roll: "7" }, + { die: "10", roll: "8" }, + { die: "10", roll: "6" }, // This should fail + ], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.spend_die_2).toBe("You don't have a d10 hit die available") + expect(result.errors["dice.2.die"]).toBe("You don't have a d10 hit die available") } }) }) @@ -148,10 +143,10 @@ describe("shortRest", () => { if (!charWithLowHP) throw new Error("Character not found") const result = await shortRest(testCtx.db, charWithLowHP, { - spend_die_0: "10", - roll_die_0: "8", - spend_die_1: "10", - roll_die_1: "7", + dice: [ + { die: "10", roll: "8" }, + { die: "10", roll: "7" }, + ], is_check: "false", }) @@ -180,14 +175,14 @@ describe("shortRest", () => { if (!char) throw new Error("Character not found") const result = await shortRest(testCtx.db, char, { - spend_die_0: "10", - roll_die_0: "0", + dice: [{ die: "10", roll: "0" }], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.roll_die_0).toBe("Roll must be between 1 and 10") + // Zod validation catches this before manual validation + expect(result.errors["dice.0.roll"]).toBe("Too small: expected number to be >=1") } }) @@ -196,44 +191,50 @@ describe("shortRest", () => { if (!char) throw new Error("Character not found") const result = await shortRest(testCtx.db, char, { - spend_die_0: "10", - roll_die_0: "11", + dice: [{ die: "10", roll: "11" }], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.roll_die_0).toBe("Roll must be between 1 and 10") + expect(result.errors["dice.0.roll"]).toBe("Roll must be between 1 and 10") } }) - test("requires roll value for non-check submissions", async () => { + test("ignores dice with empty roll value", async () => { const char = await computeCharacter(testCtx.db, character.id) if (!char) throw new Error("Character not found") - const result = await shortRest(testCtx.db, char, { - spend_die_0: "10", + // Damage the character to reduce HP + await updateHitPoints(testCtx.db, char, { + action: "lose", + amount: "10", + note: "Test damage", is_check: "false", }) - expect(result.complete).toBe(false) - if (!result.complete) { - expect(result.errors.roll_die_0).toBe("Roll value is required") - } - }) - - test("allows missing roll value for check submissions", async () => { - const char = await computeCharacter(testCtx.db, character.id) - if (!char) throw new Error("Character not found") + const charWithLowHP = await computeCharacter(testCtx.db, character.id) + if (!charWithLowHP) throw new Error("Character not found") - const result = await shortRest(testCtx.db, char, { - spend_die_0: "10", - is_check: "true", + // Submit with one die with roll and one without + const result = await shortRest(testCtx.db, charWithLowHP, { + dice: [ + { die: "10", roll: "8" }, // This one should be spent + { die: "10", roll: "" }, // This one should be ignored + ], + is_check: "false", }) - expect(result.complete).toBe(false) - if (!result.complete) { - expect(result.errors.roll_die_0).toBeUndefined() + // Should succeed, only spending the first die + expect(result.complete).toBe(true) + if (result.complete) { + expect(result.result.hitDiceSpent).toBe(1) + expect(result.result.diceRolls).toHaveLength(1) + expect(result.result.diceRolls[0]).toEqual({ + die: 10, + roll: 8, + modifier: charWithLowHP.abilityScores.constitution.modifier, + }) } }) }) @@ -263,7 +264,7 @@ describe("shortRest", () => { const result = await shortRest(testCtx.db, char, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1"], is_check: "false", }) @@ -277,7 +278,7 @@ describe("shortRest", () => { const char = await computeCharacter(testCtx.db, wizardCharacter.id) if (!char) throw new Error("Character not found") - // Use 2 spell slots to have enough to restore (level 4 wizard will restore 2 slots) + // Use 2 spell slots to have enough to restore for (let i = 0; i < 2; i++) { await createSpellSlot(testCtx.db, { character_id: char.id, @@ -292,14 +293,14 @@ describe("shortRest", () => { const result = await shortRest(testCtx.db, charWithUsedSlots, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1", "1"], is_check: "true", }) // Check mode should pass validation expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.arcane_slots).toBeUndefined() + expect(result.errors["arcane_slots[]"]).toBeUndefined() } }) @@ -310,13 +311,13 @@ describe("shortRest", () => { // Try to restore slots without using any const result = await shortRest(testCtx.db, char, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1"], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.arcane_slots).toContain("only have 0 used") + expect(result.errors["arcane_slots[]"]).toContain("only have 0 used") } }) @@ -338,17 +339,17 @@ describe("shortRest", () => { if (!charWithUsedSlots) throw new Error("Character not found") // Level 4 wizard budget = 2 - // Restoring level-1 slots will restore 2 slots (2 * 1 = 2 budget) + // Restoring 2 level-1 slots (2 * 1 = 2 budget) // This should pass validation const result = await shortRest(testCtx.db, charWithUsedSlots, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1", "1"], is_check: "true", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.arcane_slots).toBeUndefined() + expect(result.errors["arcane_slots[]"]).toBeUndefined() } }) @@ -367,16 +368,16 @@ describe("shortRest", () => { const charWithUsedSlot = await computeCharacter(testCtx.db, wizardCharacter.id) if (!charWithUsedSlot) throw new Error("Character not found") - // Try to restore level-1 slots (which would restore 2 slots, but only 1 is used) + // Try to restore 2 level-1 slots (but only 1 is used) const result = await shortRest(testCtx.db, charWithUsedSlot, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1", "1"], is_check: "false", }) expect(result.complete).toBe(false) if (!result.complete) { - expect(result.errors.arcane_slots).toContain("only have 1 used level 1 spell slot") + expect(result.errors["arcane_slots[]"]).toContain("only have 1 used level 1 spell slot") } }) }) @@ -403,7 +404,7 @@ describe("shortRest", () => { const result = await shortRest(testCtx.db, charWithUsedSlots, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1", "1"], is_check: "false", }) @@ -440,7 +441,7 @@ describe("shortRest", () => { const result = await shortRest(testCtx.db, charWithUsedSlot, { arcane_recovery: "true", - arcane_slot_2: "true", + "arcane_slots[]": ["2"], is_check: "false", }) @@ -484,7 +485,7 @@ describe("shortRest", () => { const result = await shortRest(testCtx.db, charWithUsedSlots, { arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1", "1", "1"], is_check: "false", }) @@ -529,10 +530,9 @@ describe("shortRest", () => { if (!charDamagedAndUsedSlots) throw new Error("Character not found") const result = await shortRest(testCtx.db, charDamagedAndUsedSlots, { - spend_die_0: "6", - roll_die_0: "4", + dice: [{ die: "6", roll: "4" }], arcane_recovery: "true", - arcane_slot_1: "true", + "arcane_slots[]": ["1", "1"], is_check: "false", }) diff --git a/src/services/shortRest.ts b/src/services/shortRest.ts index ab0d718..60cc25d 100644 --- a/src/services/shortRest.ts +++ b/src/services/shortRest.ts @@ -2,7 +2,13 @@ import { beginOrSavepoint } from "@src/db" import { create as createSpellSlotDb } from "@src/db/char_spell_slots" import type { HitDieType } from "@src/lib/dnd" import { zodToFormErrors } from "@src/lib/formErrors" -import { Checkbox, NumericEnumField, OptionalNumber, OptionalString } from "@src/lib/formSchemas" +import { + ArrayField, + Checkbox, + NumberField, + ObjectArrayField, + OptionalString, +} from "@src/lib/formSchemas" import type { ServiceResult } from "@src/lib/serviceResult" import { tool } from "ai" import type { SQL } from "bun" @@ -10,29 +16,28 @@ import { z } from "zod" import type { ComputedCharacter } from "./computeCharacter" import { updateHitDice } from "./updateHitDice" -// Schema building blocks -const DieValueField = () => - NumericEnumField(z.union([z.literal(6), z.literal(8), z.literal(10), z.literal(12)]).nullable()) -const RollValueField = () => OptionalNumber() -const ArcaneSlotField = () => Checkbox().optional().default(false) - export const ShortRestApiSchema = z.object({ note: OptionalString().describe("Optional note about the circumstances of the short rest"), is_check: Checkbox().optional().default(false), - // Hit dice spending (support up to 6 dice per short rest) - spend_die_0: DieValueField().describe("Die value for first hit die to spend (6, 8, 10, or 12)"), - roll_die_0: RollValueField().describe("Rolled value for first hit die (1 to die value)"), - spend_die_1: DieValueField().describe("Die value for second hit die to spend (6, 8, 10, or 12)"), - roll_die_1: RollValueField().describe("Rolled value for second hit die"), - spend_die_2: DieValueField().describe("Die value for third hit die to spend (6, 8, 10, or 12)"), - roll_die_2: RollValueField().describe("Rolled value for third hit die"), - spend_die_3: DieValueField().describe("Die value for fourth hit die to spend (6, 8, 10, or 12)"), - roll_die_3: RollValueField().describe("Rolled value for fourth hit die"), - spend_die_4: DieValueField().describe("Die value for fifth hit die to spend (6, 8, 10, or 12)"), - roll_die_4: RollValueField().describe("Rolled value for fifth hit die"), - spend_die_5: DieValueField().describe("Die value for sixth hit die to spend (6, 8, 10, or 12)"), - roll_die_5: RollValueField().describe("Rolled value for sixth hit die"), + // Hit dice spending - array of {die, roll} objects + dice: ObjectArrayField( + z.object({ + die: NumberField( + z.number().refine((v) => [6, 8, 10, 12].includes(v), { + message: "Die value must be 6, 8, 10, or 12", + }) + ).describe("The type of hit die (6, 8, 10, or 12)"), + roll: NumberField(z.number().int().min(1).max(12).nullable()).describe( + "The HP rolled when spending this hit die" + ), + }) + ) + .optional() + .default([]) + .describe( + "Array of hit dice to spend. Each die has a value (6/8/10/12) and roll (1 to die value)" + ), // Arcane Recovery (Wizards only) arcane_recovery: Checkbox() @@ -41,11 +46,10 @@ export const ShortRestApiSchema = z.object({ .describe( "Whether to use Arcane Recovery (Wizards only). Allows recovering spell slots with combined levels up to half wizard level (rounded up), maximum 5th level slots" ), - arcane_slot_1: ArcaneSlotField().describe("Restore a 1st level spell slot via Arcane Recovery"), - arcane_slot_2: ArcaneSlotField().describe("Restore a 2nd level spell slot via Arcane Recovery"), - arcane_slot_3: ArcaneSlotField().describe("Restore a 3rd level spell slot via Arcane Recovery"), - arcane_slot_4: ArcaneSlotField().describe("Restore a 4th level spell slot via Arcane Recovery"), - arcane_slot_5: ArcaneSlotField().describe("Restore a 5th level spell slot via Arcane Recovery"), + "arcane_slots[]": ArrayField(z.array(NumberField(z.number().int().min(1).max(5)))) + .optional() + .default([]) + .describe("Array of spell slot levels (1-5) to restore via Arcane Recovery"), }) export type ShortRestApi = z.infer @@ -69,7 +73,8 @@ export type ShortRestResult = ServiceResult export async function shortRest( db: SQL, char: ComputedCharacter, - data: Record + // biome-ignore lint/suspicious/noExplicitAny: Form data can include arrays and objects from parseBody + data: Record ): Promise { // Stage 1: Partial Zod validation const checkD = ShortRestApiSchema.partial().safeParse(data) @@ -82,21 +87,26 @@ export async function shortRest( // Stage 2: Custom validation const errors: Record = {} - // Parse selected hit dice with their rolls + // Parse and validate hit dice const selectedDice: Array<{ index: number; die: HitDieType; roll?: number }> = [] const remainingHitDice = [...char.availableHitDice] - // Iterate over all possible form field indices (0-5) - for (let i = 0; i < 6; i++) { - const dieValue = values[`spend_die_${i}` as keyof typeof values] - if (!dieValue) continue + const dice = values.dice || [] + for (let i = 0; i < dice.length; i++) { + const diceEntry = dice[i] + if (!diceEntry) continue + const dieNum = diceEntry.die as HitDieType + const roll = diceEntry.roll - const dieNum = dieValue as HitDieType + // Skip dice with no roll (empty/null) - these are UI-only, not being spent + if (roll === null || roll === undefined) { + continue + } // Check if this die is available in remainingHitDice const availableIndex = remainingHitDice.indexOf(dieNum) if (availableIndex === -1) { - errors[`spend_die_${i}`] = `You don't have a d${dieNum} hit die available` + errors[`dice.${i}.die`] = `You don't have a d${dieNum} hit die available` continue } @@ -106,16 +116,9 @@ export async function shortRest( // Find the original index in char.availableHitDice for tracking const originalIndex = char.availableHitDice.indexOf(dieNum) - const roll = values[`roll_die_${i}` as keyof typeof values] as number | undefined - - // Validate roll value if provided - if (roll !== undefined) { - if (roll < 1 || roll > dieNum) { - errors[`roll_die_${i}`] = `Roll must be between 1 and ${dieNum}` - } - } else if (!values.is_check) { - // Require roll value for non-check submissions - errors[`roll_die_${i}`] = "Roll value is required" + // Validate roll value (we know it's not null/undefined at this point) + if (roll < 1 || roll > dieNum) { + errors[`dice.${i}.roll`] = `Roll must be between 1 and ${dieNum}` } selectedDice.push({ index: originalIndex, die: dieNum, roll }) @@ -128,31 +131,34 @@ export async function shortRest( errors.arcane_recovery = "Only Wizards can use Arcane Recovery" } else { const maxArcaneRecoveryLevel = Math.min(5, Math.ceil(wizardClass.level / 2)) - const selectedSlots: { level: number; count: number }[] = [] - - for (let level = 1; level <= 5; level++) { - if (values[`arcane_slot_${level}` as keyof typeof values]) { - // Calculate how many slots of this level to restore - // Maximum is based on remaining budget divided by slot level - const maxCount = Math.floor(maxArcaneRecoveryLevel / level) - selectedSlots.push({ level, count: maxCount }) - } - } + const selectedSlotLevels = values["arcane_slots[]"] || [] - // Calculate total slot levels (sum of level * count for each selected level) - const totalSlotLevels = selectedSlots.reduce((sum, slot) => sum + slot.level * slot.count, 0) + // Calculate total slot levels + const totalSlotLevels = selectedSlotLevels.reduce( + (sum: number, level: number) => sum + level, + 0 + ) if (totalSlotLevels > maxArcaneRecoveryLevel) { - errors.arcane_slots = `Total slot levels (${totalSlotLevels}) exceeds maximum (${maxArcaneRecoveryLevel})` + errors["arcane_slots[]"] = + `Total slot levels (${totalSlotLevels}) exceeds maximum (${maxArcaneRecoveryLevel})` } // Validate character has enough used slots to restore if (char.spellSlots && char.availableSpellSlots) { - for (const slot of selectedSlots) { - const total = char.spellSlots.filter((s) => s === slot.level).length - const available = char.availableSpellSlots.filter((s) => s === slot.level).length + // Count how many of each level are being restored + const slotCounts = new Map() + for (const level of selectedSlotLevels) { + slotCounts.set(level, (slotCounts.get(level) || 0) + 1) + } + + // Check if we have enough used slots + for (const [level, count] of slotCounts) { + const total = char.spellSlots.filter((s) => s === level).length + const available = char.availableSpellSlots.filter((s) => s === level).length const used = total - available - if (used < slot.count) { - errors.arcane_slots = `You only have ${used} used level ${slot.level} spell slot(s), but trying to restore ${slot.count}` + if (used < count) { + errors["arcane_slots[]"] = + `You only have ${used} used level ${level} spell slot(s), but trying to restore ${count}` break } } @@ -209,24 +215,14 @@ export async function shortRest( if (result.data.arcane_recovery) { summary.arcaneRecoveryUsed = true - const wizardClass = char.classes.find((c) => c.class === "wizard")! - const maxArcaneRecoveryLevel = Math.min(5, Math.ceil(wizardClass.level / 2)) - - for (let level = 1; level <= 5; level++) { - if (result.data[`arcane_slot_${level}` as keyof typeof result.data]) { - // Restore multiple slots of this level based on budget - const maxCount = Math.floor(maxArcaneRecoveryLevel / level) - - for (let i = 0; i < maxCount; i++) { - await createSpellSlotDb(tx, { - character_id: currentChar.id, - slot_level: level, - action: "restore", - note: `${note} - Arcane Recovery`, - }) - summary.spellSlotsRestored++ - } - } + for (const level of result.data["arcane_slots[]"]) { + await createSpellSlotDb(tx, { + character_id: currentChar.id, + slot_level: level, + action: "restore", + note: `${note} - Arcane Recovery`, + }) + summary.spellSlotsRestored++ } } @@ -241,7 +237,9 @@ export const shortRestToolName = "short_rest" as const export const shortRestTool = tool({ name: shortRestToolName, description: `Take a short rest (1 hour of downtime). You can spend hit dice to recover HP. Each die recovers HP equal to the roll + Constitution modifier. Wizards can use Arcane Recovery to restore spell slots.`, - inputSchema: ShortRestApiSchema.omit({ is_check: true }), + inputSchema: ShortRestApiSchema.omit({ is_check: true, "arcane_slots[]": true }).extend({ + arcane_slots: ShortRestApiSchema.shape["arcane_slots[]"], + }) }) /** @@ -255,29 +253,20 @@ export async function executeShortRest( parameters: Record, isCheck?: boolean ) { - const data: Record = { + const data: Parameters[2] = { note: parameters.note?.toString() || "", is_check: isCheck ? "true" : "false", - // Hit dice spending (AI can specify these for automated rests) - spend_die_0: parameters.spend_die_0?.toString() || "", - roll_die_0: parameters.roll_die_0?.toString() || "", - spend_die_1: parameters.spend_die_1?.toString() || "", - roll_die_1: parameters.roll_die_1?.toString() || "", - spend_die_2: parameters.spend_die_2?.toString() || "", - roll_die_2: parameters.roll_die_2?.toString() || "", - spend_die_3: parameters.spend_die_3?.toString() || "", - roll_die_3: parameters.roll_die_3?.toString() || "", - spend_die_4: parameters.spend_die_4?.toString() || "", - roll_die_4: parameters.roll_die_4?.toString() || "", - spend_die_5: parameters.spend_die_5?.toString() || "", - roll_die_5: parameters.roll_die_5?.toString() || "", - // Arcane Recovery arcane_recovery: parameters.arcane_recovery?.toString() || "false", - arcane_slot_1: parameters.arcane_slot_1?.toString() || "false", - arcane_slot_2: parameters.arcane_slot_2?.toString() || "false", - arcane_slot_3: parameters.arcane_slot_3?.toString() || "false", - arcane_slot_4: parameters.arcane_slot_4?.toString() || "false", - arcane_slot_5: parameters.arcane_slot_5?.toString() || "false", + } + + // Convert dice array if provided + if (parameters.dice && Array.isArray(parameters.dice)) { + data.dice = parameters.dice + } + + // Convert arcane_slots array if provided + if (parameters.arcane_slots && Array.isArray(parameters.arcane_slots)) { + data["arcane_slots[]"] = parameters.arcane_slots } return shortRest(db, char, data) From c8c6f30401a0576edf91d67f172d6dbc2db21591 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 15:47:11 -0800 Subject: [PATCH 10/13] refactor: use nested schema for item damage on creation/update --- src/components/CreateItemForm.tsx | 74 +++++++++---- src/components/EditItemForm.tsx | 171 +++++++++++++++++++----------- src/lib/formErrors.ts | 50 +++++++-- src/routes/character.tsx | 4 +- src/services/createItem.ts | 156 +++++---------------------- src/services/shortRest.ts | 2 +- src/services/updateItem.ts | 139 +++++------------------- 7 files changed, 262 insertions(+), 334 deletions(-) diff --git a/src/components/CreateItemForm.tsx b/src/components/CreateItemForm.tsx index bb0542f..1d1b093 100644 --- a/src/components/CreateItemForm.tsx +++ b/src/components/CreateItemForm.tsx @@ -13,8 +13,6 @@ export interface CreateItemFormProps { const DIE_VALUES = [4, 6, 8, 10, 12, 20, 100] as const -const MAX_DAMAGE_ROWS = 10 - export const CreateItemForm = ({ character, values, errors }: CreateItemFormProps) => { // Load templates for the character's ruleset const rulesetId = character.ruleset as RulesetId @@ -94,10 +92,10 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp for (let i = 0; i < template.damage.length; i++) { const dmg = template.damage[i] if (dmg) { - values[`damage_num_dice_${i}`] = String(dmg.num_dice) - values[`damage_die_value_${i}`] = String(dmg.die_value) - values[`damage_type_${i}`] = dmg.type - if (dmg.versatile) values[`damage_versatile_${i}`] = "true" + values[`damage.${i}.num_dice`] = String(dmg.num_dice) + values[`damage.${i}.die_value`] = String(dmg.die_value) + values[`damage.${i}.type`] = dmg.type + if (dmg.versatile) values[`damage.${i}.versatile`] = "true" } } } @@ -120,10 +118,41 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp if (values.category === "weapon" && !values.weapon_type) { values.weapon_type = "melee" } - const damageRowCount = Math.min( - Number.parseInt(values.damage_row_count || "1", 10), - MAX_DAMAGE_ROWS - ) + const damageRowCount = Number.parseInt(values.damage_row_count || "1", 10) + + // Parse damage entries from values (object array field parsed from dot notation) + const damageEntries: Array<{ + num_dice: string + die_value: string + type: string + versatile: boolean + }> = [] + if (values.damage && typeof values.damage === "object") { + for (const d of Object.values(values.damage) as Record[]) { + damageEntries.push({ + num_dice: String(d.num_dice || "1"), + die_value: String(d.die_value || ""), + type: String(d.type || ""), + versatile: d.versatile === "true", + }) + } + } + + // Adjust array size to match damageRowCount + if (damageEntries.length > damageRowCount) { + // Trim excess entries + damageEntries.length = damageRowCount + } else { + // Add empty rows until we have damageRowCount entries + while (damageEntries.length < damageRowCount) { + damageEntries.push({ + num_dice: "1", + die_value: "", + type: "", + versatile: false, + }) + } + } return ( @@ -458,10 +487,10 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp value={damageRowCount} /> - {Array.from({ length: damageRowCount }, (_, i) => { - const numDiceError = errors?.[`damage_num_dice_${i}`] - const dieValueError = errors?.[`damage_die_value_${i}`] - const damageTypeError = errors?.[`damage_type_${i}`] + {damageEntries.map((entry, i) => { + const numDiceError = errors?.[`damage.${i}.num_dice`] + const dieValueError = errors?.[`damage.${i}.die_value`] + const damageTypeError = errors?.[`damage.${i}.type`] return (
@@ -470,10 +499,10 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp {numDiceError && (
{numDiceError}
@@ -485,10 +514,10 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp
@@ -510,9 +539,9 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp class="form-check-input form-check-input-sm" type="checkbox" id={`damage-versatile-${i}`} - name={`damage_versatile_${i}`} + name={`damage.${i}.versatile`} value="true" - checked={values[`damage_versatile_${i}`] === "true"} + checked={entry.versatile} />
Category cannot be changed
+ {errors?.category &&
{errors.category}
}
{/* Description */} @@ -369,50 +408,65 @@ export const EditItemForm = ({ value={damageRowCount} /> - {Array.from({ length: damageRowCount }, (_, i) => { - const numDiceError = errors?.[`damage_num_dice_${i}`] - const dieValueError = errors?.[`damage_die_value_${i}`] - const damageTypeError = errors?.[`damage_type_${i}`] + {damageEntries.map((entry, i) => { + const numDiceError = errors?.[`damage.${i}.num_dice`] + const dieValueError = errors?.[`damage.${i}.die_value`] + const damageTypeError = errors?.[`damage.${i}.type`] return ( -
-
- - {numDiceError && ( -
{numDiceError}
- )} -
-
- d +
+
+
+ + {numDiceError && ( +
{numDiceError}
+ )} +
+
+ d +
+
+ +
-
- + +
) @@ -424,7 +478,6 @@ export const EditItemForm = ({ type="button" class="btn btn-sm btn-outline-secondary" onclick={`document.getElementById('damage_row_count').value = ${damageRowCount + 1}; document.getElementById('edit-item-form').dispatchEvent(new Event('change'));`} - disabled={damageRowCount >= MAX_DAMAGE_ROWS} > Add Damage diff --git a/src/lib/formErrors.ts b/src/lib/formErrors.ts index b75ac8b..8551c3d 100644 --- a/src/lib/formErrors.ts +++ b/src/lib/formErrors.ts @@ -15,20 +15,50 @@ function humanizeEnumError(error: string): string { return result } +// biome-ignore lint/suspicious/noExplicitAny: Zod error structure is complex and varies by error type +function flattenZodIssues(issue: any, errors: FormErrors) { + // If this is a union error with nested errors, recursively process them + if (issue.code === "invalid_union" && issue.unionErrors) { + for (const unionError of issue.unionErrors) { + for (const nestedIssue of unionError.issues) { + flattenZodIssues(nestedIssue, errors) + } + } + return + } + + // Also handle the "errors" property (array of arrays of issues) + if (issue.code === "invalid_union" && issue.errors && Array.isArray(issue.errors)) { + for (const errorGroup of issue.errors) { + if (Array.isArray(errorGroup)) { + for (const nestedIssue of errorGroup) { + flattenZodIssues(nestedIssue, errors) + } + } + } + return + } + + // Convert path array like ["dice", 0, "roll"] to "dice.0.roll" + const fieldName = issue.path.join(".") + const message = humanizeEnumError(issue.message) + + // Skip empty field names (root-level union errors) + if (!fieldName) return + + // If multiple errors on same field, join with semicolon + if (errors[fieldName]) { + errors[fieldName] += `; ${message}` + } else { + errors[fieldName] = message + } +} + export function zodToFormErrors(zodError: ZodError): FormErrors { const errors: FormErrors = {} for (const issue of zodError.issues) { - // Convert path array like ["dice", 0, "roll"] to "dice.0.roll" - const fieldName = issue.path.join(".") - const message = humanizeEnumError(issue.message) - - // If multiple errors on same field, join with semicolon - if (errors[fieldName]) { - errors[fieldName] += `; ${message}` - } else { - errors[fieldName] = message - } + flattenZodIssues(issue, errors) } return errors diff --git a/src/routes/character.tsx b/src/routes/character.tsx index 5b1722b..b43b5a8 100644 --- a/src/routes/character.tsx +++ b/src/routes/character.tsx @@ -439,7 +439,7 @@ characterRoutes.post("/characters/:id/edit/trait", async (c) => { characterRoutes.post("/characters/:id/edit/newitem", async (c) => { const characterId = c.req.param("id") as string - const body = (await c.req.parseBody()) as Record + const body = (await c.req.parseBody({ all: true, dot: true })) as Record const char = await computeCharacter(getDb(c), characterId) if (!char) { @@ -530,7 +530,7 @@ characterRoutes.get("/characters/:id/items/:itemId/edit", async (c) => { characterRoutes.post("/characters/:id/items/:itemId/edit", async (c) => { const characterId = c.req.param("id") as string const itemId = c.req.param("itemId") as string - const body = (await c.req.parseBody()) as Record + const body = (await c.req.parseBody({ all: true, dot: true })) as Record const char = await computeCharacter(getDb(c), characterId) if (!char) { diff --git a/src/services/createItem.ts b/src/services/createItem.ts index 1480b95..0203175 100644 --- a/src/services/createItem.ts +++ b/src/services/createItem.ts @@ -16,6 +16,7 @@ import { EnumField, NumberField, NumericEnumField, + ObjectArrayField, OptionalString, } from "@src/lib/formSchemas" import type { ServiceResult } from "@src/lib/serviceResult" @@ -64,18 +65,20 @@ const ArmorItemSchema = z.object({ stealth_disadvantage: Checkbox().optional().default(false), }) +// Damage entry schema for ObjectArrayField const DamageDice = [4, 6, 8, 10, 12, 20, 100] as const -const NumDiceField = NumberField( - z.number().int({ message: "Must be a whole number" }).min(1, { message: "Must be at least 1" }) -) -const DieValueField = NumericEnumField( - z.union(DamageDice.map((n) => z.literal(n)) as [z.ZodLiteral, ...z.ZodLiteral[]]) -) -const OptionalDieValueField = NumericEnumField( - z - .union(DamageDice.map((n) => z.literal(n)) as [z.ZodLiteral, ...z.ZodLiteral[]]) - .nullable() -) +const DamageEntrySchema = z.object({ + num_dice: NumberField( + z.number().int({ message: "Must be a whole number" }).min(1, { message: "Must be at least 1" }) + ), + die_value: NumericEnumField( + z.union( + DamageDice.map((n) => z.literal(n)) as [z.ZodLiteral, ...z.ZodLiteral[]] + ) + ), + type: DamageTypeSchema, + versatile: Checkbox().optional().default(false), +}) // Weapon-specific fields const WeaponItemBaseSchema = z.object({ @@ -90,84 +93,8 @@ const WeaponItemBaseSchema = z.object({ reach: Checkbox().optional().default(false), loading: Checkbox().optional().default(false), - damage_row_count: NumberField( - z - .number() - .int({ message: "Must be a whole number" }) - .min(1, { message: "Must be at least 1" }) - .max(10, { message: "Cannot exceed 10" }) - .optional() - .default(1) - ), - - // Row 0 (required) - damage_num_dice_0: NumDiceField, - damage_die_value_0: DieValueField, - damage_type_0: DamageTypeSchema, - damage_versatile_0: Checkbox().optional().default(false), - // Row 1 (optional) - damage_num_dice_1: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_1: OptionalDieValueField.optional(), - damage_type_1: EnumField(DamageTypeSchema.nullable()), - damage_versatile_1: Checkbox().optional().default(false), - // Row 2 (optional) - damage_num_dice_2: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_2: OptionalDieValueField.optional(), - damage_type_2: EnumField(DamageTypeSchema.nullable()), - damage_versatile_2: Checkbox().optional().default(false), - // Row 3 (optional) - damage_num_dice_3: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_3: OptionalDieValueField.optional(), - damage_type_3: EnumField(DamageTypeSchema.nullable()), - damage_versatile_3: Checkbox().optional().default(false), - // Row 4 (optional) - damage_num_dice_4: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_4: OptionalDieValueField.optional(), - damage_type_4: EnumField(DamageTypeSchema.nullable()), - damage_versatile_4: Checkbox().optional().default(false), - // Row 5 (optional) - damage_num_dice_5: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_5: OptionalDieValueField.optional(), - damage_type_5: EnumField(DamageTypeSchema.nullable()), - damage_versatile_5: Checkbox().optional().default(false), - // Row 6 (optional) - damage_num_dice_6: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_6: OptionalDieValueField.optional(), - damage_type_6: EnumField(DamageTypeSchema.nullable()), - damage_versatile_6: Checkbox().optional().default(false), - // Row 7 (optional) - damage_num_dice_7: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_7: OptionalDieValueField.optional(), - damage_type_7: EnumField(DamageTypeSchema.nullable()), - damage_versatile_7: Checkbox().optional().default(false), - // Row 8 (optional) - damage_num_dice_8: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_8: OptionalDieValueField.optional(), - damage_type_8: EnumField(DamageTypeSchema.nullable()), - damage_versatile_8: Checkbox().optional().default(false), - // Row 9 (optional) - damage_num_dice_9: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_9: OptionalDieValueField.optional(), - damage_type_9: EnumField(DamageTypeSchema.nullable()), - damage_versatile_9: Checkbox().optional().default(false), + // Damage entries using ObjectArrayField (at least one required for weapons) + damage: ObjectArrayField(DamageEntrySchema), }) const WeaponItemSchema = z.discriminatedUnion("weapon_type", [ @@ -241,8 +168,6 @@ export type CreateItemResult = ServiceResult<{ category: string }> -const MAX_DAMAGE_ROWS = 10 as const - /** * Creates a new item and adds it to the character's inventory */ @@ -314,45 +239,20 @@ export async function createItem( } } - // Validate damage - for (let i = 0; i < MAX_DAMAGE_ROWS; i++) { - const numDiceField = `damage_num_dice_${i}` as keyof typeof values - const numDice = values[numDiceField] as number | undefined - - const dieValueField = `damage_die_value_${i}` as keyof typeof values - const dieValue = values[dieValueField] as number | undefined - - const damageTypeField = `damage_type_${i}` as keyof typeof values - const damageType = values[damageTypeField] as DamageType | undefined - - const versatileField = `damage_versatile_${i}` as keyof typeof values - const versatile = (values[versatileField] as boolean | undefined) || false - - const damageVals = [numDice, dieValue, damageType] - - // All damage fields provided - if (damageVals.every((v) => v !== undefined)) { + // Validate damage entries + const damageEntries = values.damage || [] + if (damageEntries.length === 0 && !isCheck) { + errors.damage = "At least one damage entry is required for weapons" + } else { + // Convert damage entries to internal format + for (let i = 0; i < damageEntries.length; i++) { + const entry = damageEntries[i] + if (!entry) continue damages.push({ - dice: Array(numDice!).fill(dieValue!), - type: damageType!, - versatile, + dice: Array(entry.num_dice).fill(entry.die_value), + type: entry.type, + versatile: entry.versatile, }) - - // Some but not all damage fields provided - } else if (damageVals.some((v) => v !== undefined)) { - if (numDice === undefined) { - errors[numDiceField] = `Number of dice is required` - } - if (dieValue === undefined) { - errors[dieValueField] = `Die value is required` - } - if (damageType === undefined) { - errors[damageTypeField] = `Damage type is required` - } - - // No damage fields provided on row 0 - } else if (i === 0 && !isCheck) { - errors[numDiceField] = `At least one damage entry is required for weapons` } } } diff --git a/src/services/shortRest.ts b/src/services/shortRest.ts index 60cc25d..cef065c 100644 --- a/src/services/shortRest.ts +++ b/src/services/shortRest.ts @@ -239,7 +239,7 @@ export const shortRestTool = tool({ description: `Take a short rest (1 hour of downtime). You can spend hit dice to recover HP. Each die recovers HP equal to the roll + Constitution modifier. Wizards can use Arcane Recovery to restore spell slots.`, inputSchema: ShortRestApiSchema.omit({ is_check: true, "arcane_slots[]": true }).extend({ arcane_slots: ShortRestApiSchema.shape["arcane_slots[]"], - }) + }), }) /** diff --git a/src/services/updateItem.ts b/src/services/updateItem.ts index da74949..9ae6601 100644 --- a/src/services/updateItem.ts +++ b/src/services/updateItem.ts @@ -14,9 +14,9 @@ import type { DamageType } from "@src/lib/dnd/spells" import { zodToFormErrors } from "@src/lib/formErrors" import { Checkbox, - EnumField, NumberField, NumericEnumField, + ObjectArrayField, OptionalString, } from "@src/lib/formSchemas" import { logger } from "@src/lib/logger" @@ -57,18 +57,20 @@ const ArmorItemUpdateSchema = z.object({ ), }) +// Damage entry schema for ObjectArrayField const DamageDice = [4, 6, 8, 10, 12, 20, 100] as const -const NumDiceField = NumberField( - z.number().int({ message: "Must be a whole number" }).min(1, { message: "Must be at least 1" }) -) -const DieValueField = NumericEnumField( - z.union(DamageDice.map((n) => z.literal(n)) as [z.ZodLiteral, ...z.ZodLiteral[]]) -) -const OptionalDieValueField = NumericEnumField( - z - .union(DamageDice.map((n) => z.literal(n)) as [z.ZodLiteral, ...z.ZodLiteral[]]) - .nullable() -) +const DamageEntrySchema = z.object({ + num_dice: NumberField( + z.number().int({ message: "Must be a whole number" }).min(1, { message: "Must be at least 1" }) + ), + die_value: NumericEnumField( + z.union( + DamageDice.map((n) => z.literal(n)) as [z.ZodLiteral, ...z.ZodLiteral[]] + ) + ), + type: DamageTypeSchema, + versatile: Checkbox().optional().default(false), +}) // Weapon-specific fields const WeaponItemBaseUpdateSchema = z.object({ @@ -78,66 +80,8 @@ const WeaponItemBaseUpdateSchema = z.object({ mastery: WeaponMasterySchema.nullable().default(null), martial: Checkbox().optional().default(false), - damage_row_count: NumberField( - z - .number() - .int({ message: "Must be a whole number" }) - .min(1, { message: "Must be at least 1" }) - .max(10, { message: "Cannot exceed 10" }) - .optional() - .default(1) - ), - - // Row 0 (required) - damage_num_dice_0: NumDiceField, - damage_die_value_0: DieValueField, - damage_type_0: DamageTypeSchema, - // Row 1-9 (optional) - damage_num_dice_1: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_1: OptionalDieValueField.optional(), - damage_type_1: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_2: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_2: OptionalDieValueField.optional(), - damage_type_2: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_3: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_3: OptionalDieValueField.optional(), - damage_type_3: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_4: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_4: OptionalDieValueField.optional(), - damage_type_4: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_5: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_5: OptionalDieValueField.optional(), - damage_type_5: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_6: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_6: OptionalDieValueField.optional(), - damage_type_6: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_7: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_7: OptionalDieValueField.optional(), - damage_type_7: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_8: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_8: OptionalDieValueField.optional(), - damage_type_8: EnumField(DamageTypeSchema.nullable()), - damage_num_dice_9: NumberField( - z.number().int({ message: "Must be a whole number" }).min(1).nullable() - ).optional(), - damage_die_value_9: OptionalDieValueField.optional(), - damage_type_9: EnumField(DamageTypeSchema.nullable()), + // Damage entries using ObjectArrayField (at least one required for weapons) + damage: ObjectArrayField(DamageEntrySchema), }) const WeaponItemUpdateSchema = z.discriminatedUnion("weapon_type", [ @@ -201,8 +145,6 @@ export type UpdateItemResult = | { complete: true } | { complete: false; values: Record; errors?: Record } -const MAX_DAMAGE_ROWS = 10 as const - /** * Updates an existing item */ @@ -280,45 +222,20 @@ export async function updateItem( } } - // Validate damage - for (let i = 0; i < MAX_DAMAGE_ROWS; i++) { - const numDiceField = `damage_num_dice_${i}` as keyof typeof values - const numDice = values[numDiceField] as number | undefined - - const dieValueField = `damage_die_value_${i}` as keyof typeof values - const dieValue = values[dieValueField] as number | undefined - - const damageTypeField = `damage_type_${i}` as keyof typeof values - const damageType = values[damageTypeField] as DamageType | undefined - - const versatileField = `damage_versatile_${i}` as keyof typeof values - const versatile = (values[versatileField] as boolean | undefined) || false - - const damageVals = [numDice, dieValue, damageType] - - // All damage fields provided - if (damageVals.every((v) => v !== undefined)) { + // Validate damage entries + const damageEntries = values.damage || [] + if (damageEntries.length === 0 && !isCheck) { + errors.damage = "At least one damage entry is required for weapons" + } else { + // Convert damage entries to internal format + for (let i = 0; i < damageEntries.length; i++) { + const entry = damageEntries[i] + if (!entry) continue damages.push({ - dice: Array(numDice!).fill(dieValue!), - type: damageType!, - versatile, + dice: Array(entry.num_dice).fill(entry.die_value), + type: entry.type, + versatile: entry.versatile, }) - - // Some but not all damage fields provided - } else if (damageVals.some((v) => v !== undefined)) { - if (numDice === undefined) { - errors[numDiceField] = "Number of dice is required" - } - if (dieValue === undefined) { - errors[dieValueField] = "Die value is required" - } - if (damageType === undefined) { - errors[damageTypeField] = "Damage type is required" - } - - // No damage fields provided on row 0 - } else if (i === 0 && !isCheck) { - errors[numDiceField] = "At least one damage entry is required for weapons" } } } From f2c72ee46807106ac357296cdbaaf8f022f3ca53 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 16:32:30 -0800 Subject: [PATCH 11/13] refactor: item creation tool works with flat schema our discriminated union doesn't work for anthropic api. so we flattened it out! --- src/services/createItem.ts | 163 +++++++++++++++++++++++++++++++++ src/services/createItemTool.ts | 118 ------------------------ src/tools.ts | 2 +- 3 files changed, 164 insertions(+), 119 deletions(-) delete mode 100644 src/services/createItemTool.ts diff --git a/src/services/createItem.ts b/src/services/createItem.ts index 0203175..2bbb4fd 100644 --- a/src/services/createItem.ts +++ b/src/services/createItem.ts @@ -20,8 +20,10 @@ import { OptionalString, } from "@src/lib/formSchemas" import type { ServiceResult } from "@src/lib/serviceResult" +import { tool } from "ai" import type { SQL } from "bun" import { z } from "zod" +import type { ComputedCharacter } from "./computeCharacter" // Base schema for all items export const BaseItemSchema = z.object({ @@ -360,3 +362,164 @@ export async function createItem( }, } } + +// ============================================================================ +// Tool Definition +// ============================================================================ + +export const createItemToolName = "create_item" as const + +// Flat schema with all fields optional (service validates category-specific requirements) +// Reuses schemas from above to avoid duplication +const CreateItemToolSchema = z.object({ + // Base fields from BaseItemSchema (excluding internal fields) + character_id: BaseItemSchema.shape.character_id, + name: BaseItemSchema.shape.name, + description: BaseItemSchema.shape.description, + category: BaseItemSchema.shape.category, + note: BaseItemSchema.shape.note, + + // Weapon-specific fields from WeaponItemBaseSchema + weapon_type: z.enum(["melee", "ranged", "thrown"]).optional(), + damage: WeaponItemBaseSchema.shape.damage.optional(), + finesse: WeaponItemBaseSchema.shape.finesse.optional(), + mastery: WeaponItemBaseSchema.shape.mastery.optional(), + martial: WeaponItemBaseSchema.shape.martial.optional(), + light: WeaponItemBaseSchema.shape.light.optional(), + heavy: WeaponItemBaseSchema.shape.heavy.optional(), + two_handed: WeaponItemBaseSchema.shape.two_handed.optional(), + reach: WeaponItemBaseSchema.shape.reach.optional(), + loading: WeaponItemBaseSchema.shape.loading.optional(), + // Range fields from the discriminated union variants (made optional) + normal_range: NumberField( + z.number().int({ message: "Must be a whole number" }).positive().optional() + ), + long_range: NumberField( + z.number().int({ message: "Must be a whole number" }).positive().optional() + ), + starting_ammo: NumberField( + z.number().int({ message: "Must be a whole number" }).min(0).optional() + ), + + // Armor-specific fields from ArmorItemSchema + armor_type: ArmorItemSchema.shape.armor_type.optional(), + armor_class: ArmorItemSchema.shape.armor_class.optional(), + armor_class_dex: ArmorItemSchema.shape.armor_class_dex.optional(), + armor_class_dex_max: ArmorItemSchema.shape.armor_class_dex_max.optional(), + min_strength: ArmorItemSchema.shape.min_strength.optional(), + stealth_disadvantage: ArmorItemSchema.shape.stealth_disadvantage.optional(), + + // Shield-specific fields from ShieldItemSchema + armor_modifier: ShieldItemSchema.shape.armor_modifier.optional(), +}) + +/** + * Vercel AI SDK tool definition for item creation + * This tool requires approval before execution + */ +export const createItemTool = tool({ + name: createItemToolName, + description: [ + "Create a new item and add it to the character's inventory.", + "The item will be added to inventory but not equipped.", + "You can create weapons, armor, shields, or misc items. For weapons, you must specify damage dice.", + "For armor, you must specify armor_class and armor_type. For shields, you must specify armor_modifier.", + "For common items, you can use lookup_item_template tool first to get item details from the SRD, then pass those details here.", + ].join(" "), + inputSchema: CreateItemToolSchema, +}) + +/** + * Execute the create_item tool from AI assistant + * Converts AI parameters to service format and calls createItem + */ +export async function executeCreateItem( + db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record, + isCheck?: boolean +) { + // Convert all parameters to string format for the service + const data: Record = {} + + // Convert each parameter to string, handling all the possible fields + for (const [key, value] of Object.entries(parameters)) { + if (value !== null && value !== undefined) { + data[key] = value.toString() + } + } + + // Add is_check flag + data.is_check = isCheck ? "true" : "false" + + // Call the existing createItem service and return its result directly + return createItem(db, char.user_id, data) +} + +/** + * Format approval message for create_item tool calls + */ +export function formatCreateItemApproval( + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record +): string { + const { name, category, description } = parameters + let message = `Create ${category}: ${name}` + + // Add category-specific details + if (category === "weapon") { + const weaponDetails = [] + if (parameters.weapon_type) { + weaponDetails.push(parameters.weapon_type) + } + if (parameters.martial) { + weaponDetails.push("martial") + } + if (parameters.finesse) { + weaponDetails.push("finesse") + } + if (parameters.two_handed) { + weaponDetails.push("two-handed") + } + + // Add damage info from damage array + if (parameters.damage && Array.isArray(parameters.damage) && parameters.damage.length > 0) { + const damageStrings = parameters.damage.map((dmg) => { + const dice = `${dmg.num_dice}d${dmg.die_value}` + const type = dmg.type || "" + const versatile = dmg.versatile ? " (versatile)" : "" + return `${dice} ${type}${versatile}` + }) + weaponDetails.push(...damageStrings) + } + + if (weaponDetails.length > 0) { + message += ` (${weaponDetails.join(", ")})` + } + } else if (category === "armor") { + const armorDetails = [] + if (parameters.armor_type) { + armorDetails.push(parameters.armor_type) + } + if (parameters.armor_class) { + armorDetails.push(`AC ${parameters.armor_class}`) + } + if (parameters.stealth_disadvantage) { + armorDetails.push("stealth disadvantage") + } + if (armorDetails.length > 0) { + message += ` (${armorDetails.join(", ")})` + } + } else if (category === "shield") { + if (parameters.armor_modifier) { + message += ` (+${parameters.armor_modifier} AC)` + } + } + + if (description) { + message += `\n${description}` + } + + return message +} diff --git a/src/services/createItemTool.ts b/src/services/createItemTool.ts deleted file mode 100644 index fcf14e0..0000000 --- a/src/services/createItemTool.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { tool } from "ai" -import type { SQL } from "bun" -import type { ComputedCharacter } from "./computeCharacter" -import { BaseItemSchema, createItem, ItemTypeSchemas } from "./createItem" - -export const createItemToolName = "create_item" as const -const InputSchema = BaseItemSchema.omit({ - is_check: true, - template: true, - prev_template: true, -}).and(ItemTypeSchemas) - -/** - * Vercel AI SDK tool definition for item creation - * This tool requires approval before execution - */ -export const createItemTool = tool({ - name: createItemToolName, - description: [ - "Create a new item and add it to the character's inventory.", - "The item will be added to inventory but not equipped.", - "You can create weapons, armor, shields, or misc items. For weapons, you must specify damage dice.", - "For armor, you must specify armor_class and armor_type. For shields, you must specify armor_modifier.", - "For common items, you can use lookup_item_template tool first to get item details from the SRD, then pass those details here.", - ].join(" "), - inputSchema: InputSchema, -}) - -/** - * Execute the create_item tool from AI assistant - * Converts AI parameters to service format and calls createItem - */ -export async function executeCreateItem( - db: SQL, - char: ComputedCharacter, - // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON - parameters: Record, - isCheck?: boolean -) { - // Convert all parameters to string format for the service - const data: Record = {} - - // Convert each parameter to string, handling all the possible fields - for (const [key, value] of Object.entries(parameters)) { - if (value !== null && value !== undefined) { - data[key] = value.toString() - } - } - - // Add is_check flag - data.is_check = isCheck ? "true" : "false" - - // Call the existing createItem service and return its result directly - return createItem(db, char.user_id, data) -} - -/** - * Format approval message for create_item tool calls - */ -export function formatCreateItemApproval( - // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON - parameters: Record -): string { - const { name, category, description } = parameters - let message = `Create ${category}: ${name}` - - // Add category-specific details - if (category === "weapon") { - const weaponDetails = [] - if (parameters.weapon_type) { - weaponDetails.push(parameters.weapon_type) - } - if (parameters.martial) { - weaponDetails.push("martial") - } - if (parameters.finesse) { - weaponDetails.push("finesse") - } - if (parameters.two_handed) { - weaponDetails.push("two-handed") - } - - // Add damage info - if (parameters.damage_num_dice_0 && parameters.damage_die_value_0) { - const damage = `${parameters.damage_num_dice_0}d${parameters.damage_die_value_0}` - const damageType = parameters.damage_type_0 || "" - weaponDetails.push(`${damage} ${damageType}`) - } - - if (weaponDetails.length > 0) { - message += ` (${weaponDetails.join(", ")})` - } - } else if (category === "armor") { - const armorDetails = [] - if (parameters.armor_type) { - armorDetails.push(parameters.armor_type) - } - if (parameters.armor_class) { - armorDetails.push(`AC ${parameters.armor_class}`) - } - if (parameters.stealth_disadvantage) { - armorDetails.push("stealth disadvantage") - } - if (armorDetails.length > 0) { - message += ` (${armorDetails.join(", ")})` - } - } else if (category === "shield") { - if (parameters.armor_modifier) { - message += ` (+${parameters.armor_modifier} AC)` - } - } - - if (description) { - message += `\n${description}` - } - - return message -} diff --git a/src/tools.ts b/src/tools.ts index e6d2eee..bac026a 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -35,7 +35,7 @@ import { createItemToolName, executeCreateItem, formatCreateItemApproval, -} from "./services/createItemTool" +} from "./services/createItem" import { equipItemTool, equipItemToolName, From a583e89ce07ee8a8b1323c3e40e6263fabfca5ff Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Thu, 6 Nov 2025 16:43:37 -0800 Subject: [PATCH 12/13] feat: don't pass character id in schemas we get it from context (the character object we pass to tools and services) --- src/components/TraitEditForm.tsx | 2 -- src/routes/character.tsx | 8 ++------ src/services/addLevel.ts | 3 +-- src/services/addTrait.ts | 22 +++++++++++----------- src/services/createItem.ts | 27 ++++++++++++++++----------- src/services/updateItem.ts | 9 ++------- src/services/updateSpellSlots.ts | 8 +++----- 7 files changed, 35 insertions(+), 44 deletions(-) diff --git a/src/components/TraitEditForm.tsx b/src/components/TraitEditForm.tsx index 09c6e47..d8f1e01 100644 --- a/src/components/TraitEditForm.tsx +++ b/src/components/TraitEditForm.tsx @@ -19,8 +19,6 @@ export const TraitEditForm = ({ character, values, errors }: TraitEditFormProps) hx-swap="innerHTML" class="modal-body needs-validation" > - -