+
+
+ Trait Name
+
+
+ {errors?.name &&
{errors.name}
}
+
-
-
- Description
-
-
- {errors?.description &&
{errors.description}
}
-
+
+
+ Description
+
+
+ {errors?.description && (
+
{errors.description}
+ )}
+
-
-
- Note (Optional)
-
-
- {errors?.note &&
{errors.note}
}
-
Add any additional context or reminders about this trait
+
+
+ Note (Optional)
+
+
+ {errors?.note &&
{errors.note}
}
+
Add any additional context or reminders about this trait
+
-
+
)
}
diff --git a/src/components/ui/ModalForm.tsx b/src/components/ui/ModalForm.tsx
new file mode 100644
index 0000000..5a1db31
--- /dev/null
+++ b/src/components/ui/ModalForm.tsx
@@ -0,0 +1,66 @@
+import type { Child } from "hono/jsx"
+
+export interface ModalFormProps {
+ id: string
+ endpoint: string
+ trigger?: string
+ swap?: string
+ children: Child
+}
+
+export const ModalForm = ({
+ id,
+ endpoint,
+ trigger = "change",
+ swap = "morph:innerHTML",
+ children,
+}: ModalFormProps) => {
+ return (
+
+ )
+}
+
+export interface ModalFormSubmitProps {
+ endpoint: string
+ children: Child
+ disabled?: boolean
+ id?: string
+ swap?: string
+}
+
+export const ModalFormSubmit = ({
+ endpoint,
+ children,
+ disabled,
+ id,
+ swap = "morph:innerHTML",
+}: ModalFormSubmitProps) => {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/db/auth_tokens.ts b/src/db/auth_tokens.ts
index 91bca98..1897595 100644
--- a/src/db/auth_tokens.ts
+++ b/src/db/auth_tokens.ts
@@ -1,6 +1,6 @@
import { config } from "@src/config"
+import { ulid } from "@src/lib/ids"
import type { SQL } from "bun"
-import { ulid } from "ulid"
export interface AuthToken {
id: string
@@ -121,7 +121,7 @@ export async function validateOtp(db: SQL, email: string, otpCode: string): Prom
WHERE email = ${email}
AND used_at IS NULL
AND expires_at > ${now.toISOString()}
- ORDER BY created_at DESC
+ ORDER BY id DESC
`
// Use timing-safe comparison to prevent info leakage
@@ -165,7 +165,7 @@ export async function validateSessionToken(db: SQL, sessionToken: string): Promi
FROM auth_tokens
WHERE used_at IS NULL
AND expires_at > ${now.toISOString()}
- ORDER BY created_at DESC
+ ORDER BY id DESC
`
// Use timing-safe comparison to prevent info leakage
diff --git a/src/db/char_abilities.ts b/src/db/char_abilities.ts
index 612d873..4c7ae2c 100644
--- a/src/db/char_abilities.ts
+++ b/src/db/char_abilities.ts
@@ -1,6 +1,6 @@
import { AbilitySchema, type AbilityType } from "@src/lib/dnd"
+import { ulid } from "@src/lib/ids"
import type { SQL } from "bun"
-import { ulid } from "ulid"
import { z } from "zod"
export const CharAbilitySchema = z.object({
@@ -52,7 +52,7 @@ export async function findByCharacterId(db: SQL, characterId: string): Promise
@@ -71,7 +71,7 @@ export async function getCurrentLevels(db: SQL, characterId: string): Promise {
+ 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/routes/character.skills.test.ts b/src/routes/character.skills.test.ts
index 95a0f20..87b36dd 100644
--- a/src/routes/character.skills.test.ts
+++ b/src/routes/character.skills.test.ts
@@ -390,14 +390,15 @@ describe("GET /characters/:id/history/skills", () => {
describe("with skill changes", () => {
beforeEach(async () => {
- // Create some skill history (use future timestamps to be after factory-created skills)
+ // Create some skill history
+ // Use IDs that sort in timestamp order: aaaa < bbbb < cccc < dddd
await testCtx.db`
INSERT INTO char_skills (id, character_id, skill, proficiency, note, created_at, updated_at)
VALUES
- ('skill-1', ${character.id}, 'acrobatics', 'none', 'Initial', NOW() + INTERVAL '1 second', NOW()),
- ('skill-2', ${character.id}, 'acrobatics', 'proficient', 'Trained', NOW() + INTERVAL '2 seconds', NOW()),
- ('skill-3', ${character.id}, 'stealth', 'none', 'Initial', NOW() + INTERVAL '1 second', NOW()),
- ('skill-4', ${character.id}, 'stealth', 'expert', 'Mastered', NOW() + INTERVAL '2 seconds', NOW())
+ ('skill-aaaa', ${character.id}, 'acrobatics', 'none', 'Initial Acrobatics', NOW() + INTERVAL '1 second', NOW()),
+ ('skill-bbbb', ${character.id}, 'acrobatics', 'proficient', 'Trained', NOW() + INTERVAL '2 seconds', NOW()),
+ ('skill-cccc', ${character.id}, 'stealth', 'none', 'Initial Stealth', NOW() + INTERVAL '1 second', NOW()),
+ ('skill-dddd', ${character.id}, 'stealth', 'expert', 'Mastered', NOW() + INTERVAL '2 seconds', NOW())
`
})
@@ -455,7 +456,7 @@ describe("GET /characters/:id/history/skills", () => {
)
const html = await response.text()
- expect(html).toContain("Initial")
+ expect(html).toContain("Initial Acrobatics")
expect(html).toContain("Trained")
expect(html).toContain("Mastered")
})
@@ -469,10 +470,10 @@ describe("GET /characters/:id/history/skills", () => {
const html = await response.text()
const trainedIndex = html.indexOf("Trained")
- const initialIndex = html.indexOf("Initial")
+ const initialAcrobaticsIndex = html.indexOf("Initial Acrobatics")
- // "Trained" should appear before "Initial" in the HTML
- expect(trainedIndex).toBeLessThan(initialIndex)
+ // "Trained" should appear before "Initial Acrobatics" in the HTML (both are acrobatics skill)
+ expect(trainedIndex).toBeLessThan(initialAcrobaticsIndex)
})
test("groups simultaneous changes with rowspan", async () => {
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/routes/character.tsx b/src/routes/character.tsx
index c4bebf4..9baac0f 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(
)
}
@@ -636,6 +639,7 @@ characterRoutes.post("/characters/:id/items/:itemId/wear", async (c) => {
+
>
)
@@ -657,6 +661,7 @@ characterRoutes.post("/characters/:id/items/:itemId/remove", async (c) => {
+
>
)
@@ -678,6 +683,7 @@ characterRoutes.post("/characters/:id/items/:itemId/wield", async (c) => {
+
>
)
@@ -699,6 +705,7 @@ characterRoutes.post("/characters/:id/items/:itemId/sheathe", async (c) => {
+
>
)
@@ -720,6 +727,7 @@ characterRoutes.post("/characters/:id/items/:itemId/drop", async (c) => {
+
>
)
diff --git a/src/services/computeCharacterItems.ts b/src/services/computeCharacterItems.ts
index 006a7c2..e0f70c4 100644
--- a/src/services/computeCharacterItems.ts
+++ b/src/services/computeCharacterItems.ts
@@ -62,7 +62,7 @@ export async function computeCharacterItems(
worn,
wielded,
dropped_at,
- ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at DESC) as rn
+ ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY id DESC) as rn
FROM char_items
WHERE character_id = ${characterId}
),
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/services/listCharacters.ts b/src/services/listCharacters.ts
index f487cd4..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"
+ >[]
}
/**
@@ -31,25 +36,50 @@ export async function listCharacters(
cl.class,
cl.level,
cl.subclass,
- cl.created_at,
- ROW_NUMBER() OVER (PARTITION BY cl.character_id, cl.class ORDER BY cl.created_at DESC) as rn
+ cl.id,
+ ROW_NUMBER() OVER (PARTITION BY cl.character_id, cl.class ORDER BY cl.id DESC) as rn
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.*,
COALESCE(
json_agg(
json_build_object('class', cl.class, 'level', cl.level, 'subclass', cl.subclass)
- ORDER BY cl.created_at ASC
+ 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`
@@ -59,25 +89,50 @@ export async function listCharacters(
cl.class,
cl.level,
cl.subclass,
- cl.created_at,
- ROW_NUMBER() OVER (PARTITION BY cl.character_id, cl.class ORDER BY cl.created_at DESC) as rn
+ cl.id,
+ ROW_NUMBER() OVER (PARTITION BY cl.character_id, cl.class ORDER BY cl.id DESC) as rn
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.*,
COALESCE(
json_agg(
json_build_object('class', cl.class, 'level', cl.level, 'subclass', cl.subclass)
- ORDER BY cl.created_at ASC
+ 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,
}
})
}
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 }
+ },
+}
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
{
diff --git a/static/avatar-cropper.js b/static/avatar-cropper.js
index 5b04fe5..71d3e11 100644
--- a/static/avatar-cropper.js
+++ b/static/avatar-cropper.js
@@ -35,20 +35,26 @@ function prepareCropperImage() {
const renderedW = rect.width;
// canvas dimensions
- const canvasW = cropperCanvas.getBoundingClientRect().width;
+ const canvasBox = cropperCanvas.getBoundingClientRect();
+ const canvasW = canvasBox.width;
+ const canvasH = canvasBox.height;
const cropX = cropForm.querySelector('input[name="crop_x_percent"]');
const cropY = cropForm.querySelector('input[name="crop_y_percent"]');
const cropW = cropForm.querySelector('input[name="crop_width_percent"]');
const cropH = cropForm.querySelector('input[name="crop_height_percent"]');
- const yPercent = y / renderedH;
+ // Calculate padding (image might be smaller than canvas)
+ const canvasExtraW = (canvasW - renderedW) / 2;
+ const canvasExtraH = (canvasH - renderedH) / 2;
+
+ // Convert canvas coordinates to image-relative percentages
+ const yPercent = (y - canvasExtraH) / renderedH;
cropY.value = yPercent;
const hPercent = height / renderedH;
cropH.value = hPercent;
- const canvasExtraW = (canvasW - renderedW) / 2;
const xPercent = (x - canvasExtraW) / renderedW;
cropX.value = xPercent;
@@ -59,33 +65,53 @@ function prepareCropperImage() {
function onCropperSelectionChange(event) {
const detail = event.detail;
- // prevent negative coordinates
- if (detail.x < 0 || detail.y < 0) {
- event.preventDefault();
- }
-
// image dimensions
const rect = img.getBoundingClientRect();
const renderedW = rect.width;
+ const renderedH = rect.height;
// canvas dimensions
const canvasBox = cropperCanvas.getBoundingClientRect();
- const [canvasW, canvasH] = [canvasBox.width, canvasBox.height];
+ const canvasW = canvasBox.width;
+ const canvasH = canvasBox.height;
- // don't allow selection to exceed image bounds
- // we know the height of the canvas is the same as the image height
- if (detail.y + detail.height > canvasH) {
- event.preventDefault();
+ // Calculate padding (image might be smaller than canvas)
+ const canvasExtraW = (canvasW - renderedW) / 2;
+ const canvasExtraH = (canvasH - renderedH) / 2;
+
+ // Constrain selection to stay within image bounds
+ let { x, y, width, height } = detail;
+ let needsCorrection = false;
+
+ // Prevent selection from going into top/left padding
+ if (x < canvasExtraW) {
+ x = canvasExtraW;
+ needsCorrection = true;
+ }
+ if (y < canvasExtraH) {
+ y = canvasExtraH;
+ needsCorrection = true;
}
- // we might have extra space on the sides if the image is narrow
- const canvasExtraV = (canvasW - renderedW) / 2
- if (detail.x < canvasExtraV || detail.x + detail.width > canvasW - canvasExtraV) {
- event.preventDefault();
+ // Prevent selection from exceeding image bounds on right/bottom
+ if (x + width > canvasW - canvasExtraW) {
+ x = canvasW - canvasExtraW - width;
+ needsCorrection = true;
+ }
+ if (y + height > canvasH - canvasExtraH) {
+ y = canvasH - canvasExtraH - height;
+ needsCorrection = true;
}
- // update the hidden form fields
- setCropFormFields(event.detail);
+ // If we needed to correct the position, update the selection
+ if (needsCorrection) {
+ event.preventDefault();
+ cropperSelection.$change(x, y, width, height);
+
+ // update the hidden form fields with corrected coordinates
+ } else {
+ setCropFormFields({ x, y, width, height });
+ }
}
// add the selection change listener
@@ -98,15 +124,19 @@ function prepareCropperImage() {
const { existingx, existingy, existingw, existingh } = cropperSelection.dataset;
- // Calculate canvas padding for narrow images
- const canvasW = cropperCanvas.getBoundingClientRect().width;
+ // Calculate canvas padding (image might be smaller than canvas)
+ const canvasBox = cropperCanvas.getBoundingClientRect();
+ const canvasW = canvasBox.width;
+ const canvasH = canvasBox.height;
const canvasExtraW = (canvasW - renderedW) / 2;
+ const canvasExtraH = (canvasH - renderedH) / 2;
// if existing crop data is present, set initial selection
- if (existingx && existingy && existingw && existingh) {
- // Convert percentages to canvas pixels (add padding to x)
+ // Use != null to allow 0 values (which are valid percentages)
+ if (existingx != null && existingy != null && existingw != null && existingh != null) {
+ // Convert percentages to canvas pixels (add padding to both x and y)
const x = parseFloat(existingx) * renderedW + canvasExtraW;
- const y = parseFloat(existingy) * renderedH;
+ const y = parseFloat(existingy) * renderedH + canvasExtraH;
const width = parseFloat(existingw) * renderedW;
const height = parseFloat(existingh) * renderedH;
cropperSelection.$change(x, y, width, height);
@@ -116,7 +146,7 @@ function prepareCropperImage() {
} else {
const sideLength = Math.min(renderedW, renderedH) * 0.8;
const x = (renderedW - sideLength) / 2 + canvasExtraW;
- const y = (renderedH - sideLength) / 2;
+ const y = (renderedH - sideLength) / 2 + canvasExtraH;
cropperSelection.$change(x, y, sideLength, sideLength);
setCropFormFields({ x, y, width: sideLength, height: sideLength });
}