From 6cff6f00d87a55bb0cc43adb2c1e712a4c00b5fa Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Sat, 15 Nov 2025 15:21:11 -0800 Subject: [PATCH 01/14] fix: prompt is clear that DnD rules advice is okay --- src/ai/prompts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/prompts.ts b/src/ai/prompts.ts index 731879d..ff63e2c 100644 --- a/src/ai/prompts.ts +++ b/src/ai/prompts.ts @@ -20,7 +20,7 @@ Background: ${character.background || "none"} # Your approach: -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. +Your main job is to update the character sheet based on what the player tells you. The players focus on the game while you handle the bookkeeping. You are also an expert in the rules of DnD, and you can answer questions, provide advice, and help with rules clarifications. Players can ask you for advice on character optimization, spell selection, and strategy -- keep your advice concise and curt. You're here to help them, not to play the game for them. 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! From 2c937c736d80170a91d10b3599a8173f525f0fa9 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Sat, 15 Nov 2025 15:24:52 -0800 Subject: [PATCH 02/14] feat: tools for creating/deleting item effects --- src/routes/character.tsx | 11 +-- src/services/createItemEffect.ts | 108 +++++++++++++++++++++++++++-- src/services/deleteItemEffect.ts | 115 +++++++++++++++++++++++++++++-- src/tools.ts | 24 +++++++ 4 files changed, 243 insertions(+), 15 deletions(-) diff --git a/src/routes/character.tsx b/src/routes/character.tsx index c4bebf4..4fed560 100644 --- a/src/routes/character.tsx +++ b/src/routes/character.tsx @@ -539,7 +539,7 @@ characterRoutes.post("/characters/:id/items/:itemId/effects", async (c) => { const body = (await c.req.parseBody()) as Record body.item_id = itemId - const result = await createItemEffect(getDb(c), body) + const result = await createItemEffect(getDb(c), char, body) if (!result.complete) { return c.html( @@ -593,15 +593,18 @@ characterRoutes.delete("/characters/:id/items/:itemId/effects/:effectId", async ) } - const deleteResult = await deleteItemEffect(getDb(c), itemId, effectId) + const deleteResult = await deleteItemEffect(getDb(c), char, { + item_id: itemId, + effect_id: effectId, + }) - if (!deleteResult.success) { + if (!deleteResult.complete) { return c.html( ) } diff --git a/src/services/createItemEffect.ts b/src/services/createItemEffect.ts index f60e756..28bfae9 100644 --- a/src/services/createItemEffect.ts +++ b/src/services/createItemEffect.ts @@ -3,8 +3,11 @@ import { ItemEffectAppliesSchema, ItemEffectOpSchema, ItemEffectTargetSchema } f import { zodToFormErrors } from "@src/lib/formErrors" import { Checkbox, EnumField, NumberField } from "@src/lib/formSchemas" import { logger } from "@src/lib/logger" +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 creating an item effect const BaseItemEffectSchema = z.object({ @@ -33,15 +36,14 @@ const CheckSchema = BaseItemEffectSchema.extend( export type CreateItemEffectData = z.infer -export type CreateItemEffectResult = - | { complete: true } - | { complete: false; values: Record; errors?: Record } +export type CreateItemEffectResult = ServiceResult /** * Creates a new item effect */ export async function createItemEffect( db: SQL, + _char: ComputedCharacter, data: Record ): Promise { const errors: Record = {} @@ -102,7 +104,7 @@ export async function createItemEffect( applies: result.data.applies, }) - return { complete: true } + return { complete: true, result: {} } } catch (error) { logger.error("Error creating item effect:", error as Error) return { @@ -112,3 +114,101 @@ export async function createItemEffect( } } } + +// ============================================================================ +// Tool Definition +// ============================================================================ + +export const createItemEffectToolName = "create_item_effect" as const + +const CreateItemEffectToolSchema = z.object({ + item_id: z.string().describe("The ID of the item to add the effect to"), + target: ItemEffectTargetSchema.describe( + "What the effect targets (skill, ability, ac, speed, attack, damage, initiative, or passive perception)" + ), + op: ItemEffectOpSchema.describe( + "The operation: 'add' or 'set' for numeric changes, 'advantage', 'disadvantage', 'proficiency', or 'expertise' for special effects" + ), + value: z + .number() + .int({ message: "Must be a whole number" }) + .refine((val) => val !== 0, { message: "Value cannot be 0" }) + .nullable() + .optional() + .describe( + "Numeric value for 'add' or 'set' operations (required for these ops, null/omit for others)" + ), + applies: z + .enum(["worn", "wielded"]) + .nullable() + .optional() + .describe( + "When the effect applies: 'worn' for armor/clothing, 'wielded' for weapons, null/omit for always active" + ), +}) + +/** + * Vercel AI SDK tool definition for creating item effects + * This tool requires approval before execution + */ +export const createItemEffectTool = tool({ + name: createItemEffectToolName, + description: [ + "Add a magical or special effect to an item.", + "Effects can modify skills, abilities, AC, speed, attack rolls, damage, initiative, or passive perception.", + "Use 'add' to add a bonus, 'set' to set a specific value, or use advantage/disadvantage/proficiency/expertise for special effects.", + "Specify 'applies' as 'worn' or 'wielded' to make the effect conditional, or omit for always-active effects.", + ].join(" "), + inputSchema: CreateItemEffectToolSchema, +}) + +/** + * Execute the create_item_effect tool from AI assistant + * Converts AI parameters to service format and calls createItemEffect + */ +export async function executeCreateItemEffect( + db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record, + isCheck?: boolean +) { + // Convert parameters to the format expected by createItemEffect service + const data: Record = {} + + 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 createItemEffect service and return its result directly + return createItemEffect(db, char, data) +} + +/** + * Format approval message for create_item_effect tool calls + */ +export function formatCreateItemEffectApproval( + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record +): string { + const { target, op, value, applies } = parameters + + let message = `Add effect to item: ${op} ` + + if (value !== null && value !== undefined) { + message += `${value > 0 ? "+" : ""}${value} ` + } + + message += `to ${target}` + + if (applies) { + message += ` when ${applies}` + } + + return message +} diff --git a/src/services/deleteItemEffect.ts b/src/services/deleteItemEffect.ts index 3cd0789..7e880df 100644 --- a/src/services/deleteItemEffect.ts +++ b/src/services/deleteItemEffect.ts @@ -1,33 +1,134 @@ import { deleteById as deleteItemEffectDb, findById } from "@src/db/item_effects" import { logger } from "@src/lib/logger" +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 type DeleteItemEffectResult = ServiceResult /** * Deletes an item effect after verifying it belongs to the specified item */ export async function deleteItemEffect( db: SQL, - itemId: string, - effectId: string -): Promise<{ success: boolean; error?: string }> { + _char: ComputedCharacter, + data: Record, + isCheck?: boolean +): Promise { + const errors: Record = {} + const itemId = data.item_id ?? "" + const effectId = data.effect_id ?? "" + + // Validate required fields + if (!itemId) { + if (!isCheck) { + errors.item_id = "Item ID is required" + } + } + + if (!effectId) { + if (!isCheck) { + errors.effect_id = "Effect ID is required" + } + } + + // Early return if validation errors or check mode + if (isCheck || Object.keys(errors).length > 0) { + return { complete: false, values: data, errors } + } + try { // Verify the effect exists and belongs to this item const effect = await findById(db, effectId) if (!effect) { - return { success: false, error: "Effect not found" } + return { + complete: false, + values: data, + errors: { effect_id: "Effect not found" }, + } } if (effect.item_id !== itemId) { - return { success: false, error: "Effect does not belong to this item" } + return { + complete: false, + values: data, + errors: { effect_id: "Effect does not belong to this item" }, + } } // Delete the effect await deleteItemEffectDb(db, effectId) - return { success: true } + return { complete: true, result: {} } } catch (error) { logger.error("Error deleting item effect:", error as Error) - return { success: false, error: "Failed to delete effect. Please try again." } + return { + complete: false, + values: data, + errors: { general: "Failed to delete effect. Please try again." }, + } + } +} + +// ============================================================================ +// Tool Definition +// ============================================================================ + +export const deleteItemEffectToolName = "delete_item_effect" as const + +const DeleteItemEffectToolSchema = z.object({ + item_id: z.string().describe("The ID of the item that owns the effect"), + effect_id: z.string().describe("The ID of the effect to delete"), +}) + +/** + * Vercel AI SDK tool definition for deleting item effects + * This tool requires approval before execution + */ +export const deleteItemEffectTool = tool({ + name: deleteItemEffectToolName, + description: [ + "Delete an effect from an item.", + "This removes a magical or special effect that was previously added to an item.", + "Both the item_id and effect_id must be provided.", + ].join(" "), + inputSchema: DeleteItemEffectToolSchema, +}) + +/** + * Execute the delete_item_effect tool from AI assistant + * Converts AI parameters to service format and calls deleteItemEffect + */ +export async function executeDeleteItemEffect( + db: SQL, + char: ComputedCharacter, + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record, + isCheck?: boolean +) { + // Convert parameters to the format expected by deleteItemEffect service + const data: Record = {} + + for (const [key, value] of Object.entries(parameters)) { + if (value !== null && value !== undefined) { + data[key] = value.toString() + } } + + // Call the existing deleteItemEffect service and return its result directly + return deleteItemEffect(db, char, data, isCheck) +} + +/** + * Format approval message for delete_item_effect tool calls + */ +export function formatDeleteItemEffectApproval( + // biome-ignore lint/suspicious/noExplicitAny: Tool parameters can be any valid JSON + parameters: Record +): string { + const { effect_id } = parameters + return `Delete item effect ${effect_id}` } diff --git a/src/tools.ts b/src/tools.ts index bac026a..0724fc3 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -36,6 +36,18 @@ import { executeCreateItem, formatCreateItemApproval, } from "./services/createItem" +import { + createItemEffectTool, + createItemEffectToolName, + executeCreateItemEffect, + formatCreateItemEffectApproval, +} from "./services/createItemEffect" +import { + deleteItemEffectTool, + deleteItemEffectToolName, + executeDeleteItemEffect, + formatDeleteItemEffectApproval, +} from "./services/deleteItemEffect" import { equipItemTool, equipItemToolName, @@ -265,6 +277,18 @@ export const TOOLS: ToolRegistration[] = [ executor: executeManageCharge, formatApprovalMessage: formatManageChargeApproval, }, + { + name: createItemEffectToolName, + tool: createItemEffectTool, + executor: executeCreateItemEffect, + formatApprovalMessage: formatCreateItemEffectApproval, + }, + { + name: deleteItemEffectToolName, + tool: deleteItemEffectTool, + executor: executeDeleteItemEffect, + formatApprovalMessage: formatDeleteItemEffectApproval, + }, // Character Advancement { From 49c6bd6ea8a20fe542a754f0ffdb49fe2de91746 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Wed, 19 Nov 2025 13:24:58 -0800 Subject: [PATCH 03/14] fix: wearing/removing/wielding items should update spells panel in case there's an effects change which affects how spell stuff is calculated --- src/routes/character.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/routes/character.tsx b/src/routes/character.tsx index 4fed560..9baac0f 100644 --- a/src/routes/character.tsx +++ b/src/routes/character.tsx @@ -639,6 +639,7 @@ characterRoutes.post("/characters/:id/items/:itemId/wear", async (c) => { + ) @@ -660,6 +661,7 @@ characterRoutes.post("/characters/:id/items/:itemId/remove", async (c) => { + ) @@ -681,6 +683,7 @@ characterRoutes.post("/characters/:id/items/:itemId/wield", async (c) => { + ) @@ -702,6 +705,7 @@ characterRoutes.post("/characters/:id/items/:itemId/sheathe", async (c) => { + ) @@ -723,6 +727,7 @@ characterRoutes.post("/characters/:id/items/:itemId/drop", async (c) => { + ) From 851a1c785bb4ea31908616cb644119e438eb3dc5 Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Wed, 19 Nov 2025 13:27:33 -0800 Subject: [PATCH 04/14] fix: lightbox shows entire image (uncropped) cropping is just for avatar display --- src/components/AvatarLightbox.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/AvatarLightbox.tsx b/src/components/AvatarLightbox.tsx index 86a3fc6..679e8a9 100644 --- a/src/components/AvatarLightbox.tsx +++ b/src/components/AvatarLightbox.tsx @@ -1,5 +1,4 @@ import type { ComputedCharacter } from "@src/services/computeCharacter" -import { AvatarDisplay } from "./AvatarDisplay" import { ModalContent } from "./ui/ModalContent" export interface AvatarLightboxProps { @@ -12,15 +11,29 @@ export const AvatarLightbox = ({ character, currentIndex }: AvatarLightboxProps) const prevIndex = currentIndex > 0 ? currentIndex - 1 : totalAvatars - 1 const nextIndex = currentIndex < totalAvatars - 1 ? currentIndex + 1 : 0 const showNavigation = totalAvatars > 1 + const currentAvatar = character.avatars[currentIndex] + + if (!currentAvatar) { + return ( + + + + ) + } return ( ) diff --git a/src/routes/character.test.ts b/src/routes/character.test.ts index 95fb1a6..bf07417 100644 --- a/src/routes/character.test.ts +++ b/src/routes/character.test.ts @@ -355,8 +355,8 @@ describe("GET /characters?show_archived=true", () => { const document = await parseHtml(response) const restoreButton = expectElement(document, `[data-testid="unarchive-${character.id}"]`) - expect(restoreButton.textContent?.trim()).toBe("Restore") expect(restoreButton.getAttribute("hx-post")).toBe(`/characters/${character.id}/unarchive`) + expect(restoreButton.getAttribute("title")).toBe("Restore character") }) test("checkbox is checked", async () => { diff --git a/src/services/listCharacters.ts b/src/services/listCharacters.ts index 23528a3..56a6694 100644 --- a/src/services/listCharacters.ts +++ b/src/services/listCharacters.ts @@ -1,5 +1,6 @@ import type { Character } from "@src/db/characters" import type { ClassNameType } from "@src/lib/dnd" +import type { CharacterAvatarWithUrl } from "@src/services/computeCharacter" import type { SQL } from "bun" export interface CharacterClass { @@ -11,6 +12,10 @@ export interface CharacterClass { export interface ListCharacter extends Character { classes: CharacterClass[] totalLevel: number + avatars: Omit< + CharacterAvatarWithUrl, + "id" | "character_id" | "upload_id" | "created_at" | "updated_at" + >[] } /** @@ -36,6 +41,19 @@ export async function listCharacters( FROM char_levels cl INNER JOIN characters c ON c.id = cl.character_id WHERE c.user_id = ${userId} + ), + primary_avatars AS ( + SELECT DISTINCT ON (ca.character_id) + ca.character_id, + ca.crop_x_percent, + ca.crop_y_percent, + ca.crop_width_percent, + ca.crop_height_percent, + u.id as upload_id + FROM character_avatars ca + INNER JOIN uploads u ON u.id = ca.upload_id + WHERE ca.is_primary = true + ORDER BY ca.character_id, ca.created_at DESC ) SELECT c.*, @@ -45,11 +63,23 @@ export async function listCharacters( ORDER BY cl.id ASC ) FILTER (WHERE cl.class IS NOT NULL), '[]' - ) as classes + ) as classes, + CASE + WHEN pa.upload_id IS NOT NULL THEN + json_build_object( + 'uploadUrl', '/uploads/' || pa.upload_id, + 'crop_x_percent', pa.crop_x_percent, + 'crop_y_percent', pa.crop_y_percent, + 'crop_width_percent', pa.crop_width_percent, + 'crop_height_percent', pa.crop_height_percent + ) + ELSE NULL + END as primary_avatar FROM characters c LEFT JOIN current_levels cl ON cl.character_id = c.id AND cl.rn = 1 + LEFT JOIN primary_avatars pa ON pa.character_id = c.id WHERE c.user_id = ${userId} - GROUP BY c.id + GROUP BY c.id, pa.upload_id, pa.crop_x_percent, pa.crop_y_percent, pa.crop_width_percent, pa.crop_height_percent ORDER BY c.archived_at IS NULL DESC, c.created_at DESC ` : db` @@ -64,6 +94,19 @@ export async function listCharacters( FROM char_levels cl INNER JOIN characters c ON c.id = cl.character_id WHERE c.user_id = ${userId} AND c.archived_at IS NULL + ), + primary_avatars AS ( + SELECT DISTINCT ON (ca.character_id) + ca.character_id, + ca.crop_x_percent, + ca.crop_y_percent, + ca.crop_width_percent, + ca.crop_height_percent, + u.id as upload_id + FROM character_avatars ca + INNER JOIN uploads u ON u.id = ca.upload_id + WHERE ca.is_primary = true + ORDER BY ca.character_id, ca.created_at DESC ) SELECT c.*, @@ -73,11 +116,23 @@ export async function listCharacters( ORDER BY cl.id ASC ) FILTER (WHERE cl.class IS NOT NULL), '[]' - ) as classes + ) as classes, + CASE + WHEN pa.upload_id IS NOT NULL THEN + json_build_object( + 'uploadUrl', '/uploads/' || pa.upload_id, + 'crop_x_percent', pa.crop_x_percent, + 'crop_y_percent', pa.crop_y_percent, + 'crop_width_percent', pa.crop_width_percent, + 'crop_height_percent', pa.crop_height_percent + ) + ELSE NULL + END as primary_avatar FROM characters c LEFT JOIN current_levels cl ON cl.character_id = c.id AND cl.rn = 1 + LEFT JOIN primary_avatars pa ON pa.character_id = c.id WHERE c.user_id = ${userId} AND c.archived_at IS NULL - GROUP BY c.id + GROUP BY c.id, pa.upload_id, pa.crop_x_percent, pa.crop_y_percent, pa.crop_width_percent, pa.crop_height_percent ORDER BY c.created_at DESC ` @@ -88,6 +143,20 @@ export async function listCharacters( const classes: CharacterClass[] = Array.isArray(row.classes) ? row.classes : [] const totalLevel = classes.reduce((sum, c) => sum + c.level, 0) + // Convert primary_avatar JSON to avatars array format + const avatars = row.primary_avatar + ? [ + { + uploadUrl: row.primary_avatar.uploadUrl, + is_primary: true, + crop_x_percent: row.primary_avatar.crop_x_percent, + crop_y_percent: row.primary_avatar.crop_y_percent, + crop_width_percent: row.primary_avatar.crop_width_percent, + crop_height_percent: row.primary_avatar.crop_height_percent, + }, + ] + : [] + return { id: row.id, user_id: row.user_id, @@ -102,6 +171,7 @@ export async function listCharacters( updated_at: new Date(row.updated_at), classes, totalLevel, + avatars, } }) } From b899ef5360439dfdd43aac136a6b29d1ea485a8d Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Wed, 19 Nov 2025 15:27:37 -0800 Subject: [PATCH 09/14] fix: add damage rows to edit item form button was not working --- src/components/EditItemForm.tsx | 4 +- src/routes/character.edititem.test.ts | 283 ++++++++++++++++++++++++++ src/test/factories/item.ts | 92 +++++++++ 3 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 src/routes/character.edititem.test.ts create mode 100644 src/test/factories/item.ts diff --git a/src/components/EditItemForm.tsx b/src/components/EditItemForm.tsx index 4a21e3a..3e8a237 100644 --- a/src/components/EditItemForm.tsx +++ b/src/components/EditItemForm.tsx @@ -480,7 +480,7 @@ export const EditItemForm = ({ @@ -488,7 +488,7 @@ export const EditItemForm = ({ diff --git a/src/routes/character.edititem.test.ts b/src/routes/character.edititem.test.ts new file mode 100644 index 0000000..61ffd87 --- /dev/null +++ b/src/routes/character.edititem.test.ts @@ -0,0 +1,283 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import type { Character } from "@src/db/characters" +import type { User } from "@src/db/users" +import { useTestApp } from "@src/test/app" +import { characterFactory } from "@src/test/factories/character" +import { itemFactory } from "@src/test/factories/item" +import { userFactory } from "@src/test/factories/user" +import { expectElement, getElements, makeRequest, parseHtml } from "@src/test/http" + +describe("POST /characters/:id/items/:itemId/edit", () => { + const testCtx = useTestApp() + + describe("dynamic damage row behavior", () => { + let user: User + let character: Character + let itemId: string + + beforeEach(async () => { + user = await userFactory.create({}, testCtx.db) + character = await characterFactory.create({ user_id: user.id }, testCtx.db) + + // Create a simple weapon item with one damage entry + const item = await itemFactory.create( + { + character_id: character.id, + user_id: user.id, + name: "Test Sword", + category: "weapon", + weapon_type: "melee", + }, + testCtx.db + ) + itemId = item.id + }) + + test("renders form with initial damage row", async () => { + // Make initial request with is_check to render the form + const formData = new FormData() + formData.append("is_check", "true") + formData.append("damage_row_count", "1") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify one damage row is rendered + const numDiceInputs = getElements(document, 'input[name^="damage."][name$=".num_dice"]') + expect(numDiceInputs.length).toBe(1) + + // Verify the damage row has the correct index + const firstDamageInput = expectElement( + document, + 'input[name="damage.0.num_dice"]' + ) as HTMLInputElement + expect(firstDamageInput).toBeDefined() + }) + + test("renders additional damage row when damage_row_count increases", async () => { + // Simulate clicking "Add Damage" by posting with incremented damage_row_count + const formData = new FormData() + formData.append("is_check", "true") + formData.append("damage_row_count", "2") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify two damage rows are rendered + const numDiceInputs = getElements(document, 'input[name^="damage."][name$=".num_dice"]') + expect(numDiceInputs.length).toBe(2) + + // Verify both damage rows have correct indices + const firstDamageInput = expectElement(document, 'input[name="damage.0.num_dice"]') + const secondDamageInput = expectElement(document, 'input[name="damage.1.num_dice"]') + expect(firstDamageInput).toBeDefined() + expect(secondDamageInput).toBeDefined() + }) + + test("renders three damage rows when damage_row_count is 3", async () => { + const formData = new FormData() + formData.append("is_check", "true") + formData.append("damage_row_count", "3") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify three damage rows are rendered + const numDiceInputs = getElements(document, 'input[name^="damage."][name$=".num_dice"]') + expect(numDiceInputs.length).toBe(3) + + // Verify all damage rows have correct indices + expectElement(document, 'input[name="damage.0.num_dice"]') + expectElement(document, 'input[name="damage.1.num_dice"]') + expectElement(document, 'input[name="damage.2.num_dice"]') + }) + + test("reduces damage rows when damage_row_count decreases", async () => { + // First add multiple rows + const formData1 = new FormData() + formData1.append("is_check", "true") + formData1.append("damage_row_count", "3") + + await makeRequest(testCtx.app, `/characters/${character.id}/items/${itemId}/edit`, { + user, + method: "POST", + body: formData1, + }) + + // Now simulate clicking "Remove" by posting with decremented damage_row_count + const formData2 = new FormData() + formData2.append("is_check", "true") + formData2.append("damage_row_count", "2") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData2, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify only two damage rows are rendered + const numDiceInputs = getElements(document, 'input[name^="damage."][name$=".num_dice"]') + expect(numDiceInputs.length).toBe(2) + + // Verify the third row is gone + const thirdDamageInput = document.querySelector('input[name="damage.2.num_dice"]') + expect(thirdDamageInput).toBeNull() + }) + + test("preserves damage values when adding rows", async () => { + // Start with one row and populate it + const formData1 = new FormData() + formData1.append("is_check", "true") + formData1.append("damage_row_count", "1") + formData1.append("damage.0.num_dice", "2") + formData1.append("damage.0.die_value", "6") + formData1.append("damage.0.type", "slashing") + + await makeRequest(testCtx.app, `/characters/${character.id}/items/${itemId}/edit`, { + user, + method: "POST", + body: formData1, + }) + + // Now add a second row + const formData2 = new FormData() + formData2.append("is_check", "true") + formData2.append("damage_row_count", "2") + formData2.append("damage.0.num_dice", "2") + formData2.append("damage.0.die_value", "6") + formData2.append("damage.0.type", "slashing") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData2, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify the first row's values are preserved + const numDiceInput = expectElement( + document, + 'input[name="damage.0.num_dice"]' + ) as HTMLInputElement + const dieValueSelect = expectElement( + document, + 'select[name="damage.0.die_value"]' + ) as HTMLSelectElement + const typeSelect = expectElement( + document, + 'select[name="damage.0.type"]' + ) as HTMLSelectElement + + expect(numDiceInput.value).toBe("2") + expect(dieValueSelect.value).toBe("6") + expect(typeSelect.value).toBe("slashing") + + // Verify second row exists but is empty (new row) + const secondNumDiceInput = expectElement( + document, + 'input[name="damage.1.num_dice"]' + ) as HTMLInputElement + expect(secondNumDiceInput.value).toBe("1") // Default value + }) + + test("hidden input has correct ID for JavaScript access", async () => { + const formData = new FormData() + formData.append("is_check", "true") + formData.append("damage_row_count", "1") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify the hidden input has the correct ID that matches the button onclick handlers + const hiddenInput = expectElement( + document, + "input#edititem-damage-row-count" + ) as HTMLInputElement + expect(hiddenInput.name).toBe("damage_row_count") + expect(hiddenInput.value).toBe("1") + }) + + test("form has correct ID for JavaScript access", async () => { + const formData = new FormData() + formData.append("is_check", "true") + formData.append("damage_row_count", "1") + + const response = await makeRequest( + testCtx.app, + `/characters/${character.id}/items/${itemId}/edit`, + { + user, + method: "POST", + body: formData, + } + ) + + expect(response.status).toBe(200) + + const document = await parseHtml(response) + + // Verify the form has the correct ID that matches the button onclick handlers + const form = expectElement(document, "form#edititem-form") + expect(form.id).toBe("edititem-form") + }) + }) +}) diff --git a/src/test/factories/item.ts b/src/test/factories/item.ts new file mode 100644 index 0000000..c1c41fd --- /dev/null +++ b/src/test/factories/item.ts @@ -0,0 +1,92 @@ +import { faker } from "@faker-js/faker" +import { create as createCharItem } from "@src/db/char_items" +import type { Item } from "@src/db/items" +import { create as createItem } from "@src/db/items" +import type { ItemCategoryType } from "@src/lib/dnd" +import type { SQL } from "bun" +import { Factory } from "fishery" + +interface ItemFactoryParams { + character_id: string + user_id: string + name: string + description: string | null + category: ItemCategoryType + weapon_type: "melee" | "ranged" | "thrown" | null + worn: boolean + wielded: boolean +} + +const factory = Factory.define(({ params }) => ({ + character_id: params.character_id ?? "", + user_id: params.user_id ?? "", + name: params.name ?? faker.commerce.productName(), + description: params.description ?? null, + category: params.category ?? "gear", + weapon_type: params.weapon_type ?? null, + worn: params.worn ?? false, + wielded: params.wielded ?? false, +})) + +interface ItemWithCharId extends Item { + character_id?: string +} + +/** + * Create a test item in the database, linked to a character + * Usage: + * const item = await itemFactory.create({ character_id: character.id, user_id: user.id }, testCtx.db) + * const weapon = await itemFactory.create({ character_id: character.id, user_id: user.id, category: 'weapon' }, testCtx.db) + */ +export const itemFactory = { + build: factory.build.bind(factory), + create: async (params: Partial, db: SQL): Promise => { + const built = factory.build(params) + + if (!built.character_id) { + throw new Error("character_id is required to create an item") + } + + if (!built.user_id) { + throw new Error("user_id is required to create an item") + } + + // Create the item itself + const item = await createItem(db, { + name: built.name, + description: built.description, + category: built.category, + created_by: built.user_id, + // Set default values for optional fields + armor_type: null, + armor_class: null, + armor_class_dex: false, + armor_class_dex_max: null, + armor_modifier: null, + normal_range: null, + long_range: null, + thrown: false, + finesse: false, + mastery: null, + martial: false, + light: false, + heavy: false, + two_handed: false, + reach: false, + loading: false, + min_strength: null, + }) + + // Link the item to the character's inventory + await createCharItem(db, { + character_id: built.character_id, + item_id: item.id, + worn: built.worn, + wielded: built.wielded, + dropped_at: null, + note: null, + }) + + return { ...item, character_id: built.character_id } + }, +} From 88390b48146e281956b0d12c1c9bc15e4c39c15d Mon Sep 17 00:00:00 2001 From: Igor Serebryany Date: Wed, 19 Nov 2025 15:30:55 -0800 Subject: [PATCH 10/14] fix: correct textarea values mostly affects notes fields --- src/components/AbilitiesEditForm.tsx | 5 +++-- src/components/CastSpellForm.tsx | 5 +++-- src/components/ChargeManagementForm.tsx | 5 +++-- src/components/ClassEditForm.tsx | 5 +++-- src/components/CoinsEditForm.tsx | 5 +++-- src/components/LearnSpellForm.tsx | 5 +++-- src/components/PrepareSpellForm.tsx | 5 +++-- src/components/SkillsEditForm.tsx | 5 +++-- src/components/SpellSlotsEditForm.tsx | 5 +++-- 9 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/components/AbilitiesEditForm.tsx b/src/components/AbilitiesEditForm.tsx index d8a597a..b12e999 100644 --- a/src/components/AbilitiesEditForm.tsx +++ b/src/components/AbilitiesEditForm.tsx @@ -162,8 +162,9 @@ export const AbilitiesEditForm = ({ character, values, errors = {} }: AbilitiesE name="note" rows={2} placeholder="Add a note about these ability changes..." - value={values.note ?? ""} - /> + > + {values.note ?? ""} + {/* General Errors */} diff --git a/src/components/CastSpellForm.tsx b/src/components/CastSpellForm.tsx index f2e8da8..68b0764 100644 --- a/src/components/CastSpellForm.tsx +++ b/src/components/CastSpellForm.tsx @@ -161,8 +161,9 @@ export const CastSpellForm = ({ character, values = {}, errors = {} }: CastSpell name="note" rows={2} placeholder="Add a note about casting this spell..." - value={values?.note || ""} - /> + > + {values?.note || ""} + {/* General Errors */} diff --git a/src/components/LearnSpellForm.tsx b/src/components/LearnSpellForm.tsx index 4339dc4..557e705 100644 --- a/src/components/LearnSpellForm.tsx +++ b/src/components/LearnSpellForm.tsx @@ -87,8 +87,9 @@ function LearnSpellFormBody({ character, values = {}, errors = {} }: LearnSpellF name="note" rows={2} placeholder="Add a note about adding this spell to your spellbook..." - value={values?.note || ""} - /> + > + {values?.note || ""} + {/* General Errors */} diff --git a/src/components/SpellSlotsEditForm.tsx b/src/components/SpellSlotsEditForm.tsx index 6c59bbb..dc1bd9b 100644 --- a/src/components/SpellSlotsEditForm.tsx +++ b/src/components/SpellSlotsEditForm.tsx @@ -203,8 +203,9 @@ export const SpellSlotsEditForm = ({ character, values, errors }: SpellSlotsEdit name="note" rows={2} placeholder="Add a note about this spell slot change..." - value={values?.note || ""} - /> + > + {values?.note || ""} + @@ -128,13 +128,13 @@ export const CastSpellForm = ({ character, values = {}, errors = {} }: CastSpell {/* Spell Slot Selection */} {!isCantrip && !asRitual && (
-