From 839365a5a160c813b9b7ed989944cf6881022d8b Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 31 Dec 2025 22:21:22 -0800 Subject: [PATCH] Add weapon stat modifier UI for AX skills and befoulments (#448) ## Summary - Add weapon stat modifier types and API layer - Rewrite AX skill components to fetch modifiers from API instead of local data - Add befoulment select component for weapon editing - Update weapon edit views to use new modifier system - Remove old hardcoded ax skill definitions This is the frontend counterpart to the API weapon stat modifiers feature. AX skills and befoulments are now fetched from the API instead of being hardcoded in the frontend. ## Test plan - [ ] Edit a weapon with AX skills, verify dropdown shows correct options - [ ] Add/remove AX skills on a weapon, verify saves correctly - [ ] Add befoulment to a weapon, verify UI and save work - [ ] Verify existing weapons with AX skills display correctly --- .../adapters/__tests__/entity.adapter.test.ts | 2 +- .../adapters/__tests__/grid.adapter.test.ts | 2 +- src/lib/api/adapters/entity.adapter.ts | 41 ++- src/lib/api/queries/entity.queries.ts | 47 ++- src/lib/api/schemas/party.ts | 52 ++- .../collection/CollectionWeaponPane.svelte | 26 +- .../collection/WeaponEditPane.svelte | 72 ++-- .../sidebar/EditWeaponSidebar.svelte | 80 +++-- .../sidebar/details/TeamView.svelte | 25 +- .../sidebar/edit/AxSkillSelect.svelte | 310 +++++++--------- .../sidebar/edit/BefoulmentSelect.svelte | 235 ++++++++++++ .../useWeaponStatModifiers.svelte.ts | 113 ++++++ src/lib/data/ax.ts | 338 ------------------ src/lib/types/GridWeapon.d.ts | 19 - src/lib/types/SimpleAxSkill.d.ts | 4 - src/lib/types/api/collection.ts | 15 +- src/lib/types/api/entities.ts | 6 - src/lib/types/api/party.ts | 9 +- src/lib/types/api/weaponSeries.ts | 14 +- src/lib/types/api/weaponStatModifier.ts | 61 ++++ src/lib/utils/modificationDetector.ts | 11 +- src/lib/utils/modificationFormatters.ts | 22 +- src/lib/utils/modifiers.ts | 16 +- src/stories/mocks/weapons.ts | 6 +- 24 files changed, 864 insertions(+), 662 deletions(-) create mode 100644 src/lib/components/sidebar/edit/BefoulmentSelect.svelte create mode 100644 src/lib/composables/useWeaponStatModifiers.svelte.ts delete mode 100644 src/lib/data/ax.ts delete mode 100644 src/lib/types/GridWeapon.d.ts delete mode 100644 src/lib/types/SimpleAxSkill.d.ts create mode 100644 src/lib/types/api/weaponStatModifier.ts diff --git a/src/lib/api/adapters/__tests__/entity.adapter.test.ts b/src/lib/api/adapters/__tests__/entity.adapter.test.ts index 89b4437c..95029209 100644 --- a/src/lib/api/adapters/__tests__/entity.adapter.test.ts +++ b/src/lib/api/adapters/__tests__/entity.adapter.test.ts @@ -29,7 +29,7 @@ describe('EntityAdapter', () => { name: { en: 'Dark Opus', ja: 'ダークオーパス' }, hasWeaponKeys: true, hasAwakening: true, - hasAxSkills: false, + augmentType: 'none', extra: false, elementChangeable: false }, diff --git a/src/lib/api/adapters/__tests__/grid.adapter.test.ts b/src/lib/api/adapters/__tests__/grid.adapter.test.ts index b86040cb..1c39aed7 100644 --- a/src/lib/api/adapters/__tests__/grid.adapter.test.ts +++ b/src/lib/api/adapters/__tests__/grid.adapter.test.ts @@ -29,7 +29,7 @@ describe('GridAdapter', () => { name: { en: 'Dark Opus', ja: 'ダークオーパス' }, hasWeaponKeys: true, hasAwakening: true, - hasAxSkills: false, + augmentType: 'none', extra: false, elementChangeable: false }, diff --git a/src/lib/api/adapters/entity.adapter.ts b/src/lib/api/adapters/entity.adapter.ts index c60c68c0..80da378c 100644 --- a/src/lib/api/adapters/entity.adapter.ts +++ b/src/lib/api/adapters/entity.adapter.ts @@ -17,6 +17,7 @@ import type { CreateWeaponSeriesPayload, UpdateWeaponSeriesPayload } from '$lib/types/api/weaponSeries' +import type { WeaponStatModifier } from '$lib/types/api/weaponStatModifier' import type { CharacterSeriesRef, CharacterSeries, @@ -731,10 +732,47 @@ export class EntityAdapter extends BaseAdapter { }) } + // ============================================ + // Weapon Stat Modifier Methods (AX Skills & Befoulments) + // ============================================ + + /** + * Gets all weapon stat modifiers (AX skills and befoulments) + * @param category - Optional filter: 'ax' or 'befoulment' + */ + async getWeaponStatModifiers(category?: 'ax' | 'befoulment'): Promise { + const searchParams = new URLSearchParams() + if (category) { + searchParams.set('category', category) + } + + const queryString = searchParams.toString() + const url = queryString ? `/weapon_stat_modifiers?${queryString}` : '/weapon_stat_modifiers' + + return this.request(url, { + method: 'GET', + cacheTTL: 3600000 // Cache for 1 hour - reference data rarely changes + }) + } + + /** + * Gets AX skills only + */ + async getAxSkills(): Promise { + return this.getWeaponStatModifiers('ax') + } + + /** + * Gets befoulments only + */ + async getBefoulments(): Promise { + return this.getWeaponStatModifiers('befoulment') + } + /** * Clears entity cache */ - clearEntityCache(type?: 'weapons' | 'characters' | 'summons' | 'weapon_keys' | 'weapon_series') { + clearEntityCache(type?: 'weapons' | 'characters' | 'summons' | 'weapon_keys' | 'weapon_series' | 'weapon_stat_modifiers') { if (type) { this.clearCache(`/${type}`) } else { @@ -744,6 +782,7 @@ export class EntityAdapter extends BaseAdapter { this.clearCache('/summons') this.clearCache('/weapon_keys') this.clearCache('/weapon_series') + this.clearCache('/weapon_stat_modifiers') } } diff --git a/src/lib/api/queries/entity.queries.ts b/src/lib/api/queries/entity.queries.ts index ce764096..b7033720 100644 --- a/src/lib/api/queries/entity.queries.ts +++ b/src/lib/api/queries/entity.queries.ts @@ -173,6 +173,46 @@ export const entityQueries = { enabled: !!idOrSlug, staleTime: 1000 * 60 * 60, // 1 hour gcTime: 1000 * 60 * 60 * 24 // 24 hours + }), + + /** + * Weapon stat modifiers query options (AX skills and befoulments) + * + * @param category - Optional filter: 'ax' or 'befoulment' + * @returns Query options for fetching weapon stat modifiers + */ + weaponStatModifiers: (category?: 'ax' | 'befoulment') => + queryOptions({ + queryKey: ['weaponStatModifiers', category ?? 'all'] as const, + queryFn: () => entityAdapter.getWeaponStatModifiers(category), + staleTime: 1000 * 60 * 60, // 1 hour - reference data + gcTime: 1000 * 60 * 60 * 24 // 24 hours + }), + + /** + * AX skills only query options + * + * @returns Query options for fetching AX skills + */ + axSkills: () => + queryOptions({ + queryKey: ['weaponStatModifiers', 'ax'] as const, + queryFn: () => entityAdapter.getAxSkills(), + staleTime: 1000 * 60 * 60, // 1 hour + gcTime: 1000 * 60 * 60 * 24 // 24 hours + }), + + /** + * Befoulments only query options + * + * @returns Query options for fetching befoulments + */ + befoulments: () => + queryOptions({ + queryKey: ['weaponStatModifiers', 'befoulment'] as const, + queryFn: () => entityAdapter.getBefoulments(), + staleTime: 1000 * 60 * 60, // 1 hour + gcTime: 1000 * 60 * 60 * 24 // 24 hours }) } @@ -210,5 +250,10 @@ export const entityKeys = { allCharacterSeries: () => ['characterSeries'] as const, summonSeriesList: () => ['summonSeries', 'list'] as const, summonSeries: (idOrSlug: string) => ['summonSeries', idOrSlug] as const, - allSummonSeries: () => ['summonSeries'] as const + allSummonSeries: () => ['summonSeries'] as const, + weaponStatModifiers: (category?: 'ax' | 'befoulment') => + ['weaponStatModifiers', category ?? 'all'] as const, + allWeaponStatModifiers: () => ['weaponStatModifiers'] as const, + axSkills: () => ['weaponStatModifiers', 'ax'] as const, + befoulments: () => ['weaponStatModifiers', 'befoulment'] as const } diff --git a/src/lib/api/schemas/party.ts b/src/lib/api/schemas/party.ts index 0f23d274..71424070 100644 --- a/src/lib/api/schemas/party.ts +++ b/src/lib/api/schemas/party.ts @@ -198,11 +198,36 @@ const WeaponSeriesRefSchema = z.object({ }), has_weapon_keys: z.boolean().optional(), has_awakening: z.boolean().optional(), - has_ax_skills: z.boolean().optional(), + augment_type: z.enum(['none', 'ax', 'befoulment']).optional(), extra: z.boolean().optional(), element_changeable: z.boolean().optional() }) +// Weapon stat modifier schema (for AX skills and befoulments) +const WeaponStatModifierSchema = z.object({ + id: z.string(), + slug: z.string(), + name_en: z.string(), + name_jp: z.string(), + category: z.enum(['ax', 'befoulment']), + stat: z.string(), + polarity: z.number(), + suffix: z.string().nullable() +}) + +// AX skill with modifier object and strength +const AugmentSkillSchema = z.object({ + modifier: WeaponStatModifierSchema, + strength: z.number() +}) + +// Befoulment with modifier, strength, and exorcism level +const BefoulmentSchema = z.object({ + modifier: WeaponStatModifierSchema, + strength: z.number(), + exorcism_level: z.number() +}) + // Item schemas const WeaponSchema = z.object({ id: z.string(), @@ -282,28 +307,33 @@ const GridWeaponSchema = z.object({ transcendence_step: z.number().nullish().default(0), transcendence_level: z.number().nullish().default(0), // Alias for compatibility element: z.number().nullish(), - + // Weapon keys weapon_key1_id: z.string().nullish(), weapon_key2_id: z.string().nullish(), weapon_key3_id: z.string().nullish(), weapon_key4_id: z.string().nullish(), weapon_keys: z.array(z.any()).nullish(), // Populated by API with key details - + // Awakening awakening_id: z.string().nullish(), awakening_level: z.number().nullish().default(1), awakening: z.any().nullish(), // Populated by API with awakening details - - // AX modifiers - ax_modifier1: z.number().nullish(), - ax_strength1: z.number().nullish(), - ax_modifier2: z.number().nullish(), - ax_strength2: z.number().nullish(), - + + // AX skills (new format with full modifier objects) + ax: z.array(AugmentSkillSchema).nullish(), + + // Befoulment (for Odiant weapons) + befoulment: BefoulmentSchema.nullish(), + // Nested weapon data (populated by API) weapon: WeaponSchema.nullish(), - + + // Collection link fields + collection_weapon_id: z.string().nullish(), + out_of_sync: z.boolean().nullish(), + orphaned: z.boolean().nullish(), + created_at: z.string().nullish(), updated_at: z.string().nullish() }) diff --git a/src/lib/components/collection/CollectionWeaponPane.svelte b/src/lib/components/collection/CollectionWeaponPane.svelte index 3ce6f37c..59ca3b2f 100644 --- a/src/lib/components/collection/CollectionWeaponPane.svelte +++ b/src/lib/components/collection/CollectionWeaponPane.svelte @@ -10,7 +10,7 @@ */ import { onMount } from 'svelte' import type { CollectionWeapon } from '$lib/types/api/collection' - import type { SimpleAxSkill } from '$lib/types/api/entities' + import type { AugmentSkill, Befoulment } from '$lib/types/api/weaponStatModifier' import { useUpdateCollectionWeapon, useRemoveWeaponFromCollection @@ -89,7 +89,8 @@ level: weapon.awakening.level } : null, - axSkills: (weapon.ax as SimpleAxSkill[]) ?? [] + axSkills: (weapon.ax as AugmentSkill[]) ?? [], + befoulment: (weapon.befoulment as Befoulment) ?? null }) // Element name for theming @@ -143,15 +144,22 @@ } // AX skills - if (updates.axModifier1 !== undefined) { - input.axModifier1 = updates.axModifier1 + if (updates.axModifier1Id !== undefined) { + input.axModifier1Id = updates.axModifier1Id input.axStrength1 = updates.axStrength1 } - if (updates.axModifier2 !== undefined) { - input.axModifier2 = updates.axModifier2 + if (updates.axModifier2Id !== undefined) { + input.axModifier2Id = updates.axModifier2Id input.axStrength2 = updates.axStrength2 } + // Befoulment + if (updates.befoulmentModifierId !== undefined) { + input.befoulmentModifierId = updates.befoulmentModifierId + input.befoulmentStrength = updates.befoulmentStrength + input.exorcismLevel = updates.exorcismLevel + } + const updatedWeapon = await updateMutation.mutateAsync({ id: weapon.id, input @@ -247,7 +255,7 @@ // Check conditions const hasAwakening = $derived(weapon.awakening !== null) const hasWeaponKeys = $derived((weapon.weaponKeys?.length ?? 0) > 0) - const hasAxSkills = $derived((weapon.ax?.length ?? 0) > 0 && weapon.ax?.some(ax => ax.modifier >= 0)) + const hasAxSkills = $derived((weapon.ax?.length ?? 0) > 0 && weapon.ax?.some(ax => ax.modifier?.id)) const canChangeElement = $derived(weaponData?.element === 0) // Set up sidebar action on mount and clean up on destroy @@ -334,8 +342,8 @@ {#each weapon.ax ?? [] as ax, i} - {#if ax.modifier >= 0} - + {#if ax.modifier?.id} + {/if} {/each} diff --git a/src/lib/components/collection/WeaponEditPane.svelte b/src/lib/components/collection/WeaponEditPane.svelte index b647e295..0ffa714c 100644 --- a/src/lib/components/collection/WeaponEditPane.svelte +++ b/src/lib/components/collection/WeaponEditPane.svelte @@ -10,12 +10,14 @@ * - AX skills (for weapons with AX support) * - Awakening (for weapons with awakening support) */ - import type { Weapon, Awakening, SimpleAxSkill } from '$lib/types/api/entities' + import type { Weapon, Awakening } from '$lib/types/api/entities' + import type { AugmentSkill, Befoulment } from '$lib/types/api/weaponStatModifier' import DetailsSection from '$lib/components/sidebar/details/DetailsSection.svelte' import Select from '$lib/components/ui/Select.svelte' import WeaponKeySelect from '$lib/components/sidebar/edit/WeaponKeySelect.svelte' import AwakeningSelect from '$lib/components/sidebar/edit/AwakeningSelect.svelte' import AxSkillSelect from '$lib/components/sidebar/edit/AxSkillSelect.svelte' + import BefoulmentSelect from '$lib/components/sidebar/edit/BefoulmentSelect.svelte' import UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte' import { getElementIcon } from '$lib/utils/images' import { seriesHasWeaponKeys, getSeriesSlug } from '$lib/utils/weaponSeries' @@ -31,7 +33,8 @@ type?: Awakening level: number } | null - axSkills: SimpleAxSkill[] + axSkills: AugmentSkill[] + befoulment?: Befoulment | null } export interface WeaponEditUpdates { @@ -46,10 +49,13 @@ id: string level: number } | null - axModifier1?: number + axModifier1Id?: string axStrength1?: number - axModifier2?: number + axModifier2Id?: string axStrength2?: number + befoulmentModifierId?: string + befoulmentStrength?: number + exorcismLevel?: number } interface Props { @@ -72,14 +78,8 @@ let weaponKey3 = $state(currentValues.weaponKey3Id) let selectedAwakening = $state(currentValues.awakening?.type) let awakeningLevel = $state(currentValues.awakening?.level ?? 1) - let axSkills = $state( - currentValues.axSkills.length > 0 - ? currentValues.axSkills - : [ - { modifier: -1, strength: 0 }, - { modifier: -1, strength: 0 } - ] - ) + let axSkills = $state(currentValues.axSkills ?? []) + let befoulment = $state(currentValues.befoulment ?? null) // Re-initialize when currentValues changes $effect(() => { @@ -91,13 +91,8 @@ weaponKey3 = currentValues.weaponKey3Id selectedAwakening = currentValues.awakening?.type awakeningLevel = currentValues.awakening?.level ?? 1 - axSkills = - currentValues.axSkills.length > 0 - ? currentValues.axSkills - : [ - { modifier: -1, strength: 0 }, - { modifier: -1, strength: 0 } - ] + axSkills = currentValues.axSkills ?? [] + befoulment = currentValues.befoulment ?? null }) // Derived conditions @@ -118,8 +113,10 @@ const hasWeaponKeys = $derived(seriesHasWeaponKeys(series)) const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0) - const hasAxSkills = $derived(weaponData?.ax === true) - const axType = $derived(weaponData?.axType ?? 1) + // Augment type from series determines AX skills vs befoulment + const augmentType = $derived(series?.augmentType ?? 'none') + const hasAxSkills = $derived(augmentType === 'ax') + const hasBefoulment = $derived(augmentType === 'befoulment') const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0) const availableAwakenings = $derived(weaponData?.awakenings ?? []) @@ -196,17 +193,26 @@ } // AX Skills - if (hasAxSkills && axSkills.length >= 2) { - if (axSkills[0] && axSkills[0].modifier >= 0) { - updates.axModifier1 = axSkills[0].modifier + if (hasAxSkills) { + if (axSkills[0]?.modifier?.id) { + updates.axModifier1Id = axSkills[0].modifier.id updates.axStrength1 = axSkills[0].strength } - if (axSkills[1] && axSkills[1].modifier >= 0) { - updates.axModifier2 = axSkills[1].modifier + if (axSkills[1]?.modifier?.id) { + updates.axModifier2Id = axSkills[1].modifier.id updates.axStrength2 = axSkills[1].strength } } + // Befoulment + if (hasBefoulment) { + if (befoulment?.modifier?.id) { + updates.befoulmentModifierId = befoulment.modifier.id + updates.befoulmentStrength = befoulment.strength + updates.exorcismLevel = befoulment.exorcismLevel + } + } + onSave?.(updates) } @@ -279,7 +285,6 @@
{ axSkills = skills @@ -289,6 +294,19 @@ {/if} + {#if hasBefoulment} + +
+ { + befoulment = bef + }} + /> +
+
+ {/if} + {#if hasAwakening && availableAwakenings.length > 0}
diff --git a/src/lib/components/sidebar/EditWeaponSidebar.svelte b/src/lib/components/sidebar/EditWeaponSidebar.svelte index 437afaff..08b9b351 100644 --- a/src/lib/components/sidebar/EditWeaponSidebar.svelte +++ b/src/lib/components/sidebar/EditWeaponSidebar.svelte @@ -1,13 +1,15 @@ @@ -296,7 +311,6 @@
{ axSkills = skills @@ -306,6 +320,19 @@ {/if} + {#if hasBefoulment} + +
+ { + befoulment = bef + }} + /> +
+
+ {/if} + {#if hasAwakening && availableAwakenings.length > 0}
@@ -438,7 +465,8 @@ padding: spacing.$unit; } - .ax-skills-wrapper { + .ax-skills-wrapper, + .befoulment-wrapper { padding: spacing.$unit; } diff --git a/src/lib/components/sidebar/details/TeamView.svelte b/src/lib/components/sidebar/details/TeamView.svelte index d10dfb1c..f23aecd2 100644 --- a/src/lib/components/sidebar/details/TeamView.svelte +++ b/src/lib/components/sidebar/details/TeamView.svelte @@ -105,17 +105,32 @@ {/if} - {#if modificationStatus.hasAxSkills && weapon.ax} + {#if modificationStatus.hasAxSkills && weapon.ax?.length} {#each weapon.ax as axSkill} - + {#if axSkill.modifier?.id} + + {/if} {/each} {/if} + {#if modificationStatus.hasBefoulment && weapon.befoulment?.modifier} + + + + + {/if} + {#if modificationStatus.hasElement && weapon.element} diff --git a/src/lib/components/sidebar/edit/AxSkillSelect.svelte b/src/lib/components/sidebar/edit/AxSkillSelect.svelte index ee2242ff..81c6fe28 100644 --- a/src/lib/components/sidebar/edit/AxSkillSelect.svelte +++ b/src/lib/components/sidebar/edit/AxSkillSelect.svelte @@ -1,215 +1,138 @@ -
- -
-
-
- - {/if} -
- - {#if primaryError} -

{primaryError}

- {/if} +{#if isLoading} +
+
- - - {#if showSecondary} +{:else} +
+
+ {#if selectedPrimary} + + {#if getSuffix(selectedPrimary)} + {getSuffix(selectedPrimary)} {/if} {/if}
- - {#if secondaryError} -

{secondaryError}

- {/if}
- {/if} -
+ + + {#if showSecondary} +
+
+
+ + {#if getSuffix(selectedSecondary)} + {getSuffix(selectedSecondary)} + {/if} + {/if} +
+
+ {/if} +
+{/if} diff --git a/src/lib/components/sidebar/edit/BefoulmentSelect.svelte b/src/lib/components/sidebar/edit/BefoulmentSelect.svelte new file mode 100644 index 00000000..0fc3a02f --- /dev/null +++ b/src/lib/components/sidebar/edit/BefoulmentSelect.svelte @@ -0,0 +1,235 @@ + + + + +{#if isLoading} +
+
+
+{:else} +
+ +
+ + +
+ + +
+ +