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
This commit is contained in:
Justin Edmund 2025-12-31 22:21:22 -08:00 committed by GitHub
parent 5d7e8334f9
commit 839365a5a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 864 additions and 662 deletions

View file

@ -29,7 +29,7 @@ describe('EntityAdapter', () => {
name: { en: 'Dark Opus', ja: 'ダークオーパス' }, name: { en: 'Dark Opus', ja: 'ダークオーパス' },
hasWeaponKeys: true, hasWeaponKeys: true,
hasAwakening: true, hasAwakening: true,
hasAxSkills: false, augmentType: 'none',
extra: false, extra: false,
elementChangeable: false elementChangeable: false
}, },

View file

@ -29,7 +29,7 @@ describe('GridAdapter', () => {
name: { en: 'Dark Opus', ja: 'ダークオーパス' }, name: { en: 'Dark Opus', ja: 'ダークオーパス' },
hasWeaponKeys: true, hasWeaponKeys: true,
hasAwakening: true, hasAwakening: true,
hasAxSkills: false, augmentType: 'none',
extra: false, extra: false,
elementChangeable: false elementChangeable: false
}, },

View file

@ -17,6 +17,7 @@ import type {
CreateWeaponSeriesPayload, CreateWeaponSeriesPayload,
UpdateWeaponSeriesPayload UpdateWeaponSeriesPayload
} from '$lib/types/api/weaponSeries' } from '$lib/types/api/weaponSeries'
import type { WeaponStatModifier } from '$lib/types/api/weaponStatModifier'
import type { import type {
CharacterSeriesRef, CharacterSeriesRef,
CharacterSeries, 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<WeaponStatModifier[]> {
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<WeaponStatModifier[]>(url, {
method: 'GET',
cacheTTL: 3600000 // Cache for 1 hour - reference data rarely changes
})
}
/**
* Gets AX skills only
*/
async getAxSkills(): Promise<WeaponStatModifier[]> {
return this.getWeaponStatModifiers('ax')
}
/**
* Gets befoulments only
*/
async getBefoulments(): Promise<WeaponStatModifier[]> {
return this.getWeaponStatModifiers('befoulment')
}
/** /**
* Clears entity cache * 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) { if (type) {
this.clearCache(`/${type}`) this.clearCache(`/${type}`)
} else { } else {
@ -744,6 +782,7 @@ export class EntityAdapter extends BaseAdapter {
this.clearCache('/summons') this.clearCache('/summons')
this.clearCache('/weapon_keys') this.clearCache('/weapon_keys')
this.clearCache('/weapon_series') this.clearCache('/weapon_series')
this.clearCache('/weapon_stat_modifiers')
} }
} }

View file

@ -173,6 +173,46 @@ export const entityQueries = {
enabled: !!idOrSlug, enabled: !!idOrSlug,
staleTime: 1000 * 60 * 60, // 1 hour staleTime: 1000 * 60 * 60, // 1 hour
gcTime: 1000 * 60 * 60 * 24 // 24 hours 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, allCharacterSeries: () => ['characterSeries'] as const,
summonSeriesList: () => ['summonSeries', 'list'] as const, summonSeriesList: () => ['summonSeries', 'list'] as const,
summonSeries: (idOrSlug: string) => ['summonSeries', idOrSlug] 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
} }

View file

@ -198,11 +198,36 @@ const WeaponSeriesRefSchema = z.object({
}), }),
has_weapon_keys: z.boolean().optional(), has_weapon_keys: z.boolean().optional(),
has_awakening: 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(), extra: z.boolean().optional(),
element_changeable: 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 // Item schemas
const WeaponSchema = z.object({ const WeaponSchema = z.object({
id: z.string(), id: z.string(),
@ -295,15 +320,20 @@ const GridWeaponSchema = z.object({
awakening_level: z.number().nullish().default(1), awakening_level: z.number().nullish().default(1),
awakening: z.any().nullish(), // Populated by API with awakening details awakening: z.any().nullish(), // Populated by API with awakening details
// AX modifiers // AX skills (new format with full modifier objects)
ax_modifier1: z.number().nullish(), ax: z.array(AugmentSkillSchema).nullish(),
ax_strength1: z.number().nullish(),
ax_modifier2: z.number().nullish(), // Befoulment (for Odiant weapons)
ax_strength2: z.number().nullish(), befoulment: BefoulmentSchema.nullish(),
// Nested weapon data (populated by API) // Nested weapon data (populated by API)
weapon: WeaponSchema.nullish(), 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(), created_at: z.string().nullish(),
updated_at: z.string().nullish() updated_at: z.string().nullish()
}) })

View file

@ -10,7 +10,7 @@
*/ */
import { onMount } from 'svelte' import { onMount } from 'svelte'
import type { CollectionWeapon } from '$lib/types/api/collection' 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 { import {
useUpdateCollectionWeapon, useUpdateCollectionWeapon,
useRemoveWeaponFromCollection useRemoveWeaponFromCollection
@ -89,7 +89,8 @@
level: weapon.awakening.level level: weapon.awakening.level
} }
: null, : null,
axSkills: (weapon.ax as SimpleAxSkill[]) ?? [] axSkills: (weapon.ax as AugmentSkill[]) ?? [],
befoulment: (weapon.befoulment as Befoulment) ?? null
}) })
// Element name for theming // Element name for theming
@ -143,15 +144,22 @@
} }
// AX skills // AX skills
if (updates.axModifier1 !== undefined) { if (updates.axModifier1Id !== undefined) {
input.axModifier1 = updates.axModifier1 input.axModifier1Id = updates.axModifier1Id
input.axStrength1 = updates.axStrength1 input.axStrength1 = updates.axStrength1
} }
if (updates.axModifier2 !== undefined) { if (updates.axModifier2Id !== undefined) {
input.axModifier2 = updates.axModifier2 input.axModifier2Id = updates.axModifier2Id
input.axStrength2 = updates.axStrength2 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({ const updatedWeapon = await updateMutation.mutateAsync({
id: weapon.id, id: weapon.id,
input input
@ -247,7 +255,7 @@
// Check conditions // Check conditions
const hasAwakening = $derived(weapon.awakening !== null) const hasAwakening = $derived(weapon.awakening !== null)
const hasWeaponKeys = $derived((weapon.weaponKeys?.length ?? 0) > 0) 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) const canChangeElement = $derived(weaponData?.element === 0)
// Set up sidebar action on mount and clean up on destroy // Set up sidebar action on mount and clean up on destroy
@ -334,8 +342,8 @@
<DetailsSection title="AX Skills" empty={!hasAxSkills} emptyMessage="Not set"> <DetailsSection title="AX Skills" empty={!hasAxSkills} emptyMessage="Not set">
{#each weapon.ax ?? [] as ax, i} {#each weapon.ax ?? [] as ax, i}
{#if ax.modifier >= 0} {#if ax.modifier?.id}
<DetailRow label="Skill {i + 1}" value={`${ax.modifier}: ${ax.strength}`} /> <DetailRow label="Skill {i + 1}" value={`${ax.modifier.nameEn} +${ax.strength}${ax.modifier.suffix ?? ''}`} />
{/if} {/if}
{/each} {/each}
</DetailsSection> </DetailsSection>

View file

@ -10,12 +10,14 @@
* - AX skills (for weapons with AX support) * - AX skills (for weapons with AX support)
* - Awakening (for weapons with awakening 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 DetailsSection from '$lib/components/sidebar/details/DetailsSection.svelte'
import Select from '$lib/components/ui/Select.svelte' import Select from '$lib/components/ui/Select.svelte'
import WeaponKeySelect from '$lib/components/sidebar/edit/WeaponKeySelect.svelte' import WeaponKeySelect from '$lib/components/sidebar/edit/WeaponKeySelect.svelte'
import AwakeningSelect from '$lib/components/sidebar/edit/AwakeningSelect.svelte' import AwakeningSelect from '$lib/components/sidebar/edit/AwakeningSelect.svelte'
import AxSkillSelect from '$lib/components/sidebar/edit/AxSkillSelect.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 UncapIndicator from '$lib/components/uncap/UncapIndicator.svelte'
import { getElementIcon } from '$lib/utils/images' import { getElementIcon } from '$lib/utils/images'
import { seriesHasWeaponKeys, getSeriesSlug } from '$lib/utils/weaponSeries' import { seriesHasWeaponKeys, getSeriesSlug } from '$lib/utils/weaponSeries'
@ -31,7 +33,8 @@
type?: Awakening type?: Awakening
level: number level: number
} | null } | null
axSkills: SimpleAxSkill[] axSkills: AugmentSkill[]
befoulment?: Befoulment | null
} }
export interface WeaponEditUpdates { export interface WeaponEditUpdates {
@ -46,10 +49,13 @@
id: string id: string
level: number level: number
} | null } | null
axModifier1?: number axModifier1Id?: string
axStrength1?: number axStrength1?: number
axModifier2?: number axModifier2Id?: string
axStrength2?: number axStrength2?: number
befoulmentModifierId?: string
befoulmentStrength?: number
exorcismLevel?: number
} }
interface Props { interface Props {
@ -72,14 +78,8 @@
let weaponKey3 = $state<string | undefined>(currentValues.weaponKey3Id) let weaponKey3 = $state<string | undefined>(currentValues.weaponKey3Id)
let selectedAwakening = $state<Awakening | undefined>(currentValues.awakening?.type) let selectedAwakening = $state<Awakening | undefined>(currentValues.awakening?.type)
let awakeningLevel = $state(currentValues.awakening?.level ?? 1) let awakeningLevel = $state(currentValues.awakening?.level ?? 1)
let axSkills = $state<SimpleAxSkill[]>( let axSkills = $state<AugmentSkill[]>(currentValues.axSkills ?? [])
currentValues.axSkills.length > 0 let befoulment = $state<Befoulment | null>(currentValues.befoulment ?? null)
? currentValues.axSkills
: [
{ modifier: -1, strength: 0 },
{ modifier: -1, strength: 0 }
]
)
// Re-initialize when currentValues changes // Re-initialize when currentValues changes
$effect(() => { $effect(() => {
@ -91,13 +91,8 @@
weaponKey3 = currentValues.weaponKey3Id weaponKey3 = currentValues.weaponKey3Id
selectedAwakening = currentValues.awakening?.type selectedAwakening = currentValues.awakening?.type
awakeningLevel = currentValues.awakening?.level ?? 1 awakeningLevel = currentValues.awakening?.level ?? 1
axSkills = axSkills = currentValues.axSkills ?? []
currentValues.axSkills.length > 0 befoulment = currentValues.befoulment ?? null
? currentValues.axSkills
: [
{ modifier: -1, strength: 0 },
{ modifier: -1, strength: 0 }
]
}) })
// Derived conditions // Derived conditions
@ -118,8 +113,10 @@
const hasWeaponKeys = $derived(seriesHasWeaponKeys(series)) const hasWeaponKeys = $derived(seriesHasWeaponKeys(series))
const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0) const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0)
const hasAxSkills = $derived(weaponData?.ax === true) // Augment type from series determines AX skills vs befoulment
const axType = $derived(weaponData?.axType ?? 1) const augmentType = $derived(series?.augmentType ?? 'none')
const hasAxSkills = $derived(augmentType === 'ax')
const hasBefoulment = $derived(augmentType === 'befoulment')
const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0) const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0)
const availableAwakenings = $derived(weaponData?.awakenings ?? []) const availableAwakenings = $derived(weaponData?.awakenings ?? [])
@ -196,17 +193,26 @@
} }
// AX Skills // AX Skills
if (hasAxSkills && axSkills.length >= 2) { if (hasAxSkills) {
if (axSkills[0] && axSkills[0].modifier >= 0) { if (axSkills[0]?.modifier?.id) {
updates.axModifier1 = axSkills[0].modifier updates.axModifier1Id = axSkills[0].modifier.id
updates.axStrength1 = axSkills[0].strength updates.axStrength1 = axSkills[0].strength
} }
if (axSkills[1] && axSkills[1].modifier >= 0) { if (axSkills[1]?.modifier?.id) {
updates.axModifier2 = axSkills[1].modifier updates.axModifier2Id = axSkills[1].modifier.id
updates.axStrength2 = axSkills[1].strength 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) onSave?.(updates)
} }
</script> </script>
@ -279,7 +285,6 @@
<DetailsSection title="AX Skills"> <DetailsSection title="AX Skills">
<div class="section-content"> <div class="section-content">
<AxSkillSelect <AxSkillSelect
{axType}
currentSkills={axSkills} currentSkills={axSkills}
onChange={(skills) => { onChange={(skills) => {
axSkills = skills axSkills = skills
@ -289,6 +294,19 @@
</DetailsSection> </DetailsSection>
{/if} {/if}
{#if hasBefoulment}
<DetailsSection title="Befoulment">
<div class="section-content">
<BefoulmentSelect
currentBefoulment={befoulment}
onChange={(bef) => {
befoulment = bef
}}
/>
</div>
</DetailsSection>
{/if}
{#if hasAwakening && availableAwakenings.length > 0} {#if hasAwakening && availableAwakenings.length > 0}
<DetailsSection title="Awakening"> <DetailsSection title="Awakening">
<div class="section-content"> <div class="section-content">

View file

@ -1,13 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { GridWeapon } from '$lib/types/api/party' import type { GridWeapon } from '$lib/types/api/party'
import type { WeaponKey } from '$lib/api/adapters/entity.adapter' import type { WeaponKey } from '$lib/api/adapters/entity.adapter'
import type { Awakening, SimpleAxSkill } from '$lib/types/api/entities' import type { Awakening } from '$lib/types/api/entities'
import type { AugmentSkill, Befoulment } from '$lib/types/api/weaponStatModifier'
import DetailsSection from './details/DetailsSection.svelte' import DetailsSection from './details/DetailsSection.svelte'
import ItemHeader from './details/ItemHeader.svelte' import ItemHeader from './details/ItemHeader.svelte'
import Select from '$lib/components/ui/Select.svelte' import Select from '$lib/components/ui/Select.svelte'
import WeaponKeySelect from './edit/WeaponKeySelect.svelte' import WeaponKeySelect from './edit/WeaponKeySelect.svelte'
import AwakeningSelect from './edit/AwakeningSelect.svelte' import AwakeningSelect from './edit/AwakeningSelect.svelte'
import AxSkillSelect from './edit/AxSkillSelect.svelte' import AxSkillSelect from './edit/AxSkillSelect.svelte'
import BefoulmentSelect from './edit/BefoulmentSelect.svelte'
import Button from '$lib/components/ui/Button.svelte' import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte' import Icon from '$lib/components/Icon.svelte'
import { getElementIcon } from '$lib/utils/images' import { getElementIcon } from '$lib/utils/images'
@ -52,12 +54,10 @@
let awakeningLevel = $state(weapon.awakening?.level ?? 1) let awakeningLevel = $state(weapon.awakening?.level ?? 1)
// AX skill state - initialize from existing AX skills // AX skill state - initialize from existing AX skills
let axSkills = $state<SimpleAxSkill[]>( let axSkills = $state<AugmentSkill[]>(weapon.ax ?? [])
weapon.ax ?? [
{ modifier: -1, strength: 0 }, // Befoulment state - initialize from existing befoulment
{ modifier: -1, strength: 0 } let befoulment = $state<Befoulment | null>(weapon.befoulment ?? null)
]
)
// Weapon data shortcuts // Weapon data shortcuts
const weaponData = $derived(weapon.weapon) const weaponData = $derived(weapon.weapon)
@ -81,8 +81,10 @@
const hasWeaponKeys = $derived(seriesHasWeaponKeys(series)) const hasWeaponKeys = $derived(seriesHasWeaponKeys(series))
const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0) const keySlotCount = $derived(seriesSlug ? (WEAPON_KEY_SLOTS[seriesSlug] ?? 2) : 0)
const hasAxSkills = $derived(weaponData?.ax === true) // Augment type from series determines AX skills vs befoulment
const axType = $derived(weaponData?.axType ?? 1) const augmentType = $derived(series?.augmentType ?? 'none')
const hasAxSkills = $derived(augmentType === 'ax')
const hasBefoulment = $derived(augmentType === 'befoulment')
const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0) const hasAwakening = $derived((weaponData?.maxAwakeningLevel ?? 0) > 0)
const availableAwakenings = $derived(weaponData?.awakenings ?? []) const availableAwakenings = $derived(weaponData?.awakenings ?? [])
@ -125,10 +127,13 @@
weaponKey3Id?: string | null weaponKey3Id?: string | null
awakeningId?: string | null awakeningId?: string | null
awakeningLevel?: number awakeningLevel?: number
axModifier1?: number | null axModifier1Id?: string | null
axStrength1?: number | null axStrength1?: number | null
axModifier2?: number | null axModifier2Id?: string | null
axStrength2?: number | null axStrength2?: number | null
befoulmentModifierId?: string | null
befoulmentStrength?: number | null
exorcismLevel?: number | null
} }
function handleSave() { function handleSave() {
@ -169,32 +174,44 @@
} }
} }
// AX skills - send modifier/strength pairs // AX skills - send modifier IDs and strength values
if (hasAxSkills) { if (hasAxSkills) {
const originalAx = weapon.ax ?? [ const originalAx = weapon.ax ?? []
{ modifier: -1, strength: 0 },
{ modifier: -1, strength: 0 }
]
const ax1 = axSkills[0] const ax1 = axSkills[0]
const ax2 = axSkills[1] const ax2 = axSkills[1]
const origAx1 = originalAx[0] const origAx1 = originalAx[0]
const origAx2 = originalAx[1] const origAx2 = originalAx[1]
if (ax1?.modifier !== origAx1?.modifier) { if (ax1?.modifier?.id !== origAx1?.modifier?.id) {
updates.axModifier1 = ax1?.modifier ?? null updates.axModifier1Id = ax1?.modifier?.id ?? null
} }
if (ax1?.strength !== origAx1?.strength) { if (ax1?.strength !== origAx1?.strength) {
updates.axStrength1 = ax1?.strength ?? null updates.axStrength1 = ax1?.strength ?? null
} }
if (ax2?.modifier !== origAx2?.modifier) { if (ax2?.modifier?.id !== origAx2?.modifier?.id) {
updates.axModifier2 = ax2?.modifier ?? null updates.axModifier2Id = ax2?.modifier?.id ?? null
} }
if (ax2?.strength !== origAx2?.strength) { if (ax2?.strength !== origAx2?.strength) {
updates.axStrength2 = ax2?.strength ?? null updates.axStrength2 = ax2?.strength ?? null
} }
} }
// Befoulment - send modifier ID, strength, and exorcism level
if (hasBefoulment) {
const originalBef = weapon.befoulment
if (befoulment?.modifier?.id !== originalBef?.modifier?.id) {
updates.befoulmentModifierId = befoulment?.modifier?.id ?? null
}
if (befoulment?.strength !== originalBef?.strength) {
updates.befoulmentStrength = befoulment?.strength ?? null
}
if (befoulment?.exorcismLevel !== originalBef?.exorcismLevel) {
updates.exorcismLevel = befoulment?.exorcismLevel ?? null
}
}
// Only call onSave if there are actual updates // Only call onSave if there are actual updates
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
onSave?.(updates as Partial<GridWeapon>) onSave?.(updates as Partial<GridWeapon>)
@ -212,10 +229,8 @@
weaponKey3 = weapon.weaponKeys?.[2]?.id weaponKey3 = weapon.weaponKeys?.[2]?.id
selectedAwakening = weapon.awakening?.type selectedAwakening = weapon.awakening?.type
awakeningLevel = weapon.awakening?.level ?? 1 awakeningLevel = weapon.awakening?.level ?? 1
axSkills = weapon.ax ?? [ axSkills = weapon.ax ?? []
{ modifier: -1, strength: 0 }, befoulment = weapon.befoulment ?? null
{ modifier: -1, strength: 0 }
]
onCancel?.() onCancel?.()
} }
</script> </script>
@ -296,7 +311,6 @@
<DetailsSection title="AX Skills"> <DetailsSection title="AX Skills">
<div class="ax-skills-wrapper"> <div class="ax-skills-wrapper">
<AxSkillSelect <AxSkillSelect
{axType}
currentSkills={axSkills} currentSkills={axSkills}
onChange={(skills) => { onChange={(skills) => {
axSkills = skills axSkills = skills
@ -306,6 +320,19 @@
</DetailsSection> </DetailsSection>
{/if} {/if}
{#if hasBefoulment}
<DetailsSection title="Befoulment">
<div class="befoulment-wrapper">
<BefoulmentSelect
currentBefoulment={befoulment}
onChange={(bef) => {
befoulment = bef
}}
/>
</div>
</DetailsSection>
{/if}
{#if hasAwakening && availableAwakenings.length > 0} {#if hasAwakening && availableAwakenings.length > 0}
<DetailsSection title="Awakening"> <DetailsSection title="Awakening">
<div class="awakening-select-wrapper"> <div class="awakening-select-wrapper">
@ -438,7 +465,8 @@
padding: spacing.$unit; padding: spacing.$unit;
} }
.ax-skills-wrapper { .ax-skills-wrapper,
.befoulment-wrapper {
padding: spacing.$unit; padding: spacing.$unit;
} }

View file

@ -105,17 +105,32 @@
</DetailsSection> </DetailsSection>
{/if} {/if}
{#if modificationStatus.hasAxSkills && weapon.ax} {#if modificationStatus.hasAxSkills && weapon.ax?.length}
<DetailsSection title="AX Skills"> <DetailsSection title="AX Skills">
{#each weapon.ax as axSkill} {#each weapon.ax as axSkill}
{#if axSkill.modifier?.id}
<DetailRow <DetailRow
label={formatAxSkill(axSkill).split('+')[0]?.trim() ?? ''} label={axSkill.modifier.nameEn}
value={`+${axSkill.strength}${axSkill.modifier <= 2 ? '' : '%'}`} value={`+${axSkill.strength}${axSkill.modifier.suffix ?? ''}`}
/> />
{/if}
{/each} {/each}
</DetailsSection> </DetailsSection>
{/if} {/if}
{#if modificationStatus.hasBefoulment && weapon.befoulment?.modifier}
<DetailsSection title="Befoulment">
<DetailRow
label={weapon.befoulment.modifier.nameEn}
value={`${weapon.befoulment.strength}${weapon.befoulment.modifier.suffix ?? ''}`}
/>
<DetailRow
label="Exorcism Level"
value={`${weapon.befoulment.exorcismLevel ?? 0}`}
/>
</DetailsSection>
{/if}
{#if modificationStatus.hasElement && weapon.element} {#if modificationStatus.hasElement && weapon.element}
<DetailsSection title="Element Override"> <DetailsSection title="Element Override">
<DetailRow label="Weapon Element"> <DetailRow label="Weapon Element">

View file

@ -1,170 +1,129 @@
<svelte:options runes={true} /> <svelte:options runes={true} />
<script lang="ts"> <script lang="ts">
import type { SimpleAxSkill } from '$lib/types/api/entities' import type { AugmentSkill, WeaponStatModifier } from '$lib/types/api/weaponStatModifier'
import { import { useWeaponStatModifiers } from '$lib/composables/useWeaponStatModifiers.svelte'
type AxSkill,
NO_AX_SKILL,
getAxSkillsForType,
findPrimarySkill,
findSecondarySkill
} from '$lib/data/ax'
import Select from '$lib/components/ui/Select.svelte' import Select from '$lib/components/ui/Select.svelte'
interface Props { interface Props {
/** The weapon's axType (1-3) */
axType: number
/** Current AX skills on the weapon */ /** Current AX skills on the weapon */
currentSkills?: SimpleAxSkill[] currentSkills?: AugmentSkill[]
/** Called when skills change */ /** Called when skills change */
onChange?: (skills: SimpleAxSkill[]) => void onChange?: (skills: AugmentSkill[]) => void
/** Language for display */
locale?: 'en' | 'ja'
} }
let { axType, currentSkills, onChange }: Props = $props() let { currentSkills = [], onChange, locale = 'en' }: Props = $props()
// Get available primary skills for this axType const { primaryAxSkills, secondaryAxSkills, findAxSkill, isLoading } = useWeaponStatModifiers()
const primarySkills = $derived(getAxSkillsForType(axType))
// State for primary skill // State for primary skill
let primaryModifier = $state(currentSkills?.[0]?.modifier ?? -1) let selectedPrimaryId = $state<string>(currentSkills[0]?.modifier?.id ?? '')
let primaryValue = $state(currentSkills?.[0]?.strength ?? 0) let primaryStrength = $state<number>(currentSkills[0]?.strength ?? 0)
let primaryError = $state('')
// State for secondary skill // State for secondary skill
let secondaryModifier = $state(currentSkills?.[1]?.modifier ?? -1) let selectedSecondaryId = $state<string>(currentSkills[1]?.modifier?.id ?? '')
let secondaryValue = $state(currentSkills?.[1]?.strength ?? 0) let secondaryStrength = $state<number>(currentSkills[1]?.strength ?? 0)
let secondaryError = $state('')
// Get the selected primary skill // Get selected modifiers
const selectedPrimarySkill = $derived(findPrimarySkill(axType, primaryModifier)) const selectedPrimary = $derived(selectedPrimaryId ? findAxSkill(selectedPrimaryId) : undefined)
const selectedSecondary = $derived(
selectedSecondaryId ? findAxSkill(selectedSecondaryId) : undefined
)
// Whether secondary skill selection should be shown // Whether secondary skill selection should be shown
// Hide if no primary skill selected, or if primary skill has no secondaries (like EXP/Rupie) const showSecondary = $derived(!!selectedPrimary)
const showSecondary = $derived(
primaryModifier >= 0 &&
selectedPrimarySkill?.secondary &&
selectedPrimarySkill.secondary.length > 0
)
// Build primary skill options // Build primary skill options
const primaryOptions = $derived.by(() => { const primaryOptions = $derived.by(() => {
const items: Array<{ value: number; label: string }> = [ const items: Array<{ value: string; label: string }> = [{ value: '', label: 'No skill' }]
{ value: -1, label: NO_AX_SKILL.name.en }
]
for (const skill of primarySkills) { for (const skill of primaryAxSkills) {
items.push({ items.push({
value: skill.id, value: skill.id,
label: skill.name.en label: locale === 'ja' ? skill.nameJp : skill.nameEn
}) })
} }
return items return items
}) })
// Build secondary skill options based on selected primary // Build secondary skill options
const secondaryOptions = $derived.by(() => { const secondaryOptions = $derived.by(() => {
const items: Array<{ value: number; label: string }> = [ const items: Array<{ value: string; label: string }> = [{ value: '', label: 'No skill' }]
{ value: -1, label: NO_AX_SKILL.name.en }
]
if (selectedPrimarySkill?.secondary) { for (const skill of secondaryAxSkills) {
for (const skill of selectedPrimarySkill.secondary) {
items.push({ items.push({
value: skill.id, value: skill.id,
label: skill.name.en label: locale === 'ja' ? skill.nameJp : skill.nameEn
}) })
} }
}
return items return items
}) })
// Get range string for input placeholder // Get suffix for display
function getRangeString(skill: AxSkill | undefined): string { function getSuffix(modifier: WeaponStatModifier | undefined): string {
if (!skill) return '' return modifier?.suffix ?? ''
return `${skill.minValue}~${skill.maxValue}${skill.suffix || ''}`
}
// Validate a value against a skill's constraints
function validateValue(value: number, skill: AxSkill | undefined): string {
if (!skill) return ''
if (isNaN(value) || value <= 0) {
return `Please enter a value for ${skill.name.en}`
}
if (value < skill.minValue) {
return `${skill.name.en} must be at least ${skill.minValue}${skill.suffix || ''}`
}
if (value > skill.maxValue) {
return `${skill.name.en} cannot exceed ${skill.maxValue}${skill.suffix || ''}`
}
if (!skill.fractional && value % 1 !== 0) {
return `${skill.name.en} must be a whole number`
}
return ''
} }
// Handle primary skill change // Handle primary skill change
function handlePrimaryChange(value: number | undefined) { function handlePrimaryChange(value: string | undefined) {
const newValue = value ?? -1 selectedPrimaryId = value ?? ''
primaryModifier = newValue if (!value) {
primaryValue = 0 primaryStrength = 0
primaryError = '' // Reset secondary when primary is cleared
selectedSecondaryId = ''
// Reset secondary when primary changes secondaryStrength = 0
secondaryModifier = -1 }
secondaryValue = 0
secondaryError = ''
emitChange() emitChange()
} }
// Handle primary value change // Handle primary value change
function handlePrimaryValueChange(event: Event) { function handlePrimaryStrengthChange(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const value = parseFloat(input.value) primaryStrength = parseFloat(input.value) || 0
primaryValue = value
primaryError = validateValue(value, selectedPrimarySkill)
emitChange() emitChange()
} }
// Handle secondary skill change // Handle secondary skill change
function handleSecondaryChange(value: number | undefined) { function handleSecondaryChange(value: string | undefined) {
const newValue = value ?? -1 selectedSecondaryId = value ?? ''
secondaryModifier = newValue if (!value) {
secondaryValue = 0 secondaryStrength = 0
secondaryError = '' }
emitChange() emitChange()
} }
// Handle secondary value change // Handle secondary value change
function handleSecondaryValueChange(event: Event) { function handleSecondaryStrengthChange(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const value = parseFloat(input.value) secondaryStrength = parseFloat(input.value) || 0
secondaryValue = value
const secondarySkill = findSecondarySkill(selectedPrimarySkill!, secondaryModifier)
secondaryError = validateValue(value, secondarySkill)
emitChange() emitChange()
} }
// Emit change to parent // Emit change to parent
function emitChange() { function emitChange() {
const skills: SimpleAxSkill[] = [ const skills: AugmentSkill[] = []
{ modifier: primaryModifier, strength: primaryValue },
{ modifier: secondaryModifier, strength: secondaryValue } if (selectedPrimary && primaryStrength > 0) {
] skills.push({ modifier: selectedPrimary, strength: primaryStrength })
}
if (selectedSecondary && secondaryStrength > 0) {
skills.push({ modifier: selectedSecondary, strength: secondaryStrength })
}
onChange?.(skills) onChange?.(skills)
} }
</script> </script>
{#if isLoading}
<div class="ax-skill-select loading">
<div class="skeleton"></div>
</div>
{:else}
<div class="ax-skill-select"> <div class="ax-skill-select">
<!-- Primary Skill --> <!-- Primary Skill -->
<div class="skill-row"> <div class="skill-row">
@ -172,7 +131,7 @@
<div class="skill-select"> <div class="skill-select">
<Select <Select
options={primaryOptions} options={primaryOptions}
value={primaryModifier} value={selectedPrimaryId}
onValueChange={handlePrimaryChange} onValueChange={handlePrimaryChange}
placeholder="Select skill" placeholder="Select skill"
size="medium" size="medium"
@ -181,34 +140,30 @@
/> />
</div> </div>
{#if primaryModifier >= 0 && selectedPrimarySkill} {#if selectedPrimary}
<input <input
type="number" type="number"
class="skill-value" class="skill-value"
class:error={primaryError !== ''} step="0.5"
min={selectedPrimarySkill.minValue} placeholder="Value"
max={selectedPrimarySkill.maxValue} value={primaryStrength || ''}
step={selectedPrimarySkill.fractional ? '0.5' : '1'} oninput={handlePrimaryStrengthChange}
placeholder={getRangeString(selectedPrimarySkill)}
value={primaryValue || ''}
oninput={handlePrimaryValueChange}
/> />
{#if getSuffix(selectedPrimary)}
<span class="suffix">{getSuffix(selectedPrimary)}</span>
{/if}
{/if} {/if}
</div> </div>
{#if primaryError}
<p class="error-text">{primaryError}</p>
{/if}
</div> </div>
<!-- Secondary Skill (only shown when primary has secondaries) --> <!-- Secondary Skill -->
{#if showSecondary} {#if showSecondary}
<div class="skill-row"> <div class="skill-row">
<div class="skill-fields"> <div class="skill-fields">
<div class="skill-select"> <div class="skill-select">
<Select <Select
options={secondaryOptions} options={secondaryOptions}
value={secondaryModifier} value={selectedSecondaryId}
onValueChange={handleSecondaryChange} onValueChange={handleSecondaryChange}
placeholder="Select skill" placeholder="Select skill"
size="medium" size="medium"
@ -217,30 +172,24 @@
/> />
</div> </div>
{#if secondaryModifier >= 0} {#if selectedSecondary}
{@const secondarySkill = findSecondarySkill(selectedPrimarySkill!, secondaryModifier)}
{#if secondarySkill}
<input <input
type="number" type="number"
class="skill-value" class="skill-value"
class:error={secondaryError !== ''} step="0.5"
min={secondarySkill.minValue} placeholder="Value"
max={secondarySkill.maxValue} value={secondaryStrength || ''}
step={secondarySkill.fractional ? '0.5' : '1'} oninput={handleSecondaryStrengthChange}
placeholder={getRangeString(secondarySkill)}
value={secondaryValue || ''}
oninput={handleSecondaryValueChange}
/> />
{#if getSuffix(selectedSecondary)}
<span class="suffix">{getSuffix(selectedSecondary)}</span>
{/if} {/if}
{/if} {/if}
</div> </div>
{#if secondaryError}
<p class="error-text">{secondaryError}</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<style lang="scss"> <style lang="scss">
@use '$src/themes/colors' as colors; @use '$src/themes/colors' as colors;
@ -252,6 +201,27 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: spacing.$unit-2x; gap: spacing.$unit-2x;
&.loading {
min-height: 80px;
}
}
.skeleton {
height: 40px;
background: var(--skeleton-bg, colors.$grey-80);
border-radius: layout.$item-corner-small;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
} }
.skill-row { .skill-row {
@ -272,7 +242,7 @@
} }
.skill-value { .skill-value {
width: 90px; width: 80px;
flex-shrink: 0; flex-shrink: 0;
padding: spacing.$unit spacing.$unit-2x; padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bg, colors.$grey-85); background: var(--input-bg, colors.$grey-85);
@ -287,10 +257,6 @@
border-color: var(--accent-primary); border-color: var(--accent-primary);
} }
&.error {
border-color: colors.$error;
}
// Remove spin buttons // Remove spin buttons
-moz-appearance: textfield; -moz-appearance: textfield;
&::-webkit-outer-spin-button, &::-webkit-outer-spin-button,
@ -300,9 +266,9 @@
} }
} }
.error-text { .suffix {
margin: 0; color: var(--text-secondary);
font-size: typography.$font-small; font-size: typography.$font-small;
color: colors.$error; flex-shrink: 0;
} }
</style> </style>

View file

@ -0,0 +1,235 @@
<svelte:options runes={true} />
<script lang="ts">
import type { Befoulment, WeaponStatModifier } from '$lib/types/api/weaponStatModifier'
import { useWeaponStatModifiers } from '$lib/composables/useWeaponStatModifiers.svelte'
import Select from '$lib/components/ui/Select.svelte'
interface Props {
/** Current befoulment on the weapon */
currentBefoulment?: Befoulment | null
/** Called when befoulment changes */
onChange?: (befoulment: Befoulment | null) => void
/** Language for display */
locale?: 'en' | 'ja'
}
let { currentBefoulment = null, onChange, locale = 'en' }: Props = $props()
const { befoulments, findBefoulment, isLoading } = useWeaponStatModifiers()
// State
let selectedModifierId = $state<string>(currentBefoulment?.modifier?.id ?? '')
let strength = $state<number>(currentBefoulment?.strength ?? 0)
let exorcismLevel = $state<number>(currentBefoulment?.exorcismLevel ?? 0)
// Get selected modifier
const selectedModifier = $derived(
selectedModifierId ? findBefoulment(selectedModifierId) : undefined
)
// Build befoulment options
const befoulmentOptions = $derived.by(() => {
const items: Array<{ value: string; label: string }> = [
{ value: '', label: 'No befoulment' }
]
for (const bef of befoulments) {
items.push({
value: bef.id,
label: locale === 'ja' ? bef.nameJp : bef.nameEn
})
}
return items
})
// Exorcism level options (0-5)
const exorcismOptions: Array<{ value: number; label: string }> = [
{ value: 0, label: 'Level 0 (Full Effect)' },
{ value: 1, label: 'Level 1' },
{ value: 2, label: 'Level 2' },
{ value: 3, label: 'Level 3' },
{ value: 4, label: 'Level 4' },
{ value: 5, label: 'Level 5 (Fully Exorcised)' }
]
// Get suffix for display
function getSuffix(modifier: WeaponStatModifier | undefined): string {
return modifier?.suffix ?? ''
}
// Handle befoulment type change
function handleModifierChange(value: string | undefined) {
selectedModifierId = value ?? ''
if (!value) {
strength = 0
exorcismLevel = 0
}
emitChange()
}
// Handle strength change
function handleStrengthChange(event: Event) {
const input = event.target as HTMLInputElement
strength = parseFloat(input.value) || 0
emitChange()
}
// Handle exorcism level change
function handleExorcismChange(value: number | undefined) {
exorcismLevel = value ?? 0
emitChange()
}
// Emit change to parent
function emitChange() {
if (!selectedModifier) {
onChange?.(null)
return
}
onChange?.({
modifier: selectedModifier,
strength,
exorcismLevel
})
}
</script>
{#if isLoading}
<div class="befoulment-select loading">
<div class="skeleton"></div>
</div>
{:else}
<div class="befoulment-select">
<!-- Befoulment Type -->
<div class="field-row">
<label class="field-label">Befoulment Type</label>
<Select
options={befoulmentOptions}
value={selectedModifierId}
onValueChange={handleModifierChange}
placeholder="Select befoulment"
size="medium"
fullWidth
contained
/>
</div>
{#if selectedModifier}
<!-- Strength -->
<div class="field-row">
<label class="field-label">
Strength
{#if getSuffix(selectedModifier)}
<span class="suffix">({getSuffix(selectedModifier)})</span>
{/if}
</label>
<input
type="number"
class="strength-input"
step="0.5"
placeholder="Value"
value={strength || ''}
oninput={handleStrengthChange}
/>
</div>
<!-- Exorcism Level -->
<div class="field-row">
<label class="field-label">Exorcism Level</label>
<Select
options={exorcismOptions}
value={exorcismLevel}
onValueChange={handleExorcismChange}
size="medium"
fullWidth
contained
/>
<p class="help-text">Higher exorcism reduces the negative effect</p>
</div>
{/if}
</div>
{/if}
<style lang="scss">
@use '$src/themes/colors' as colors;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
@use '$src/themes/layout' as layout;
.befoulment-select {
display: flex;
flex-direction: column;
gap: spacing.$unit-2x;
&.loading {
min-height: 80px;
}
}
.skeleton {
height: 40px;
background: var(--skeleton-bg, colors.$grey-80);
border-radius: layout.$item-corner-small;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.field-row {
display: flex;
flex-direction: column;
gap: spacing.$unit;
}
.field-label {
font-size: typography.$font-small;
font-weight: 500;
color: var(--text-primary);
.suffix {
color: var(--text-secondary);
font-weight: 400;
}
}
.strength-input {
width: 100%;
max-width: 120px;
padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bg, colors.$grey-85);
border: 1px solid var(--border-secondary);
border-radius: layout.$item-corner-small;
color: var(--text-primary);
font-size: typography.$font-regular;
&:focus {
outline: none;
border-color: var(--accent-primary);
}
// Remove spin buttons
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.help-text {
margin: 0;
font-size: typography.$font-small;
color: var(--text-tertiary);
}
</style>

View file

@ -0,0 +1,113 @@
/**
* Composable for accessing weapon stat modifiers (AX skills and befoulments)
*
* Provides a reactive interface to fetch and filter weapon stat modifiers
* from the API, with derived values for common use cases.
*
* @module composables/useWeaponStatModifiers
*/
import { createQuery } from '@tanstack/svelte-query'
import { entityQueries } from '$lib/api/queries/entity.queries'
import type { WeaponStatModifier } from '$lib/types/api/weaponStatModifier'
/**
* Primary AX skill slugs - these are the main skills that can have secondaries
*/
const PRIMARY_AX_SLUGS = ['ax_atk', 'ax_def', 'ax_hp', 'ax_ca_dmg', 'ax_multiattack']
/**
* Composable for accessing weapon stat modifiers
*
* @example
* ```svelte
* <script lang="ts">
* import { useWeaponStatModifiers } from '$lib/composables/useWeaponStatModifiers.svelte'
*
* const { axSkills, befoulments, isLoading } = useWeaponStatModifiers()
* </script>
*
* {#if isLoading}
* <p>Loading...</p>
* {:else}
* <p>Found {axSkills.length} AX skills and {befoulments.length} befoulments</p>
* {/if}
* ```
*/
export function useWeaponStatModifiers() {
// Fetch all weapon stat modifiers
const query = createQuery(() => entityQueries.weaponStatModifiers())
// Filter to AX skills only
const axSkills = $derived(
(query.data ?? []).filter((m): m is WeaponStatModifier => m.category === 'ax')
)
// Filter to befoulments only
const befoulments = $derived(
(query.data ?? []).filter((m): m is WeaponStatModifier => m.category === 'befoulment')
)
// Primary AX skills (main skills that can have secondaries)
const primaryAxSkills = $derived(axSkills.filter((m) => PRIMARY_AX_SLUGS.includes(m.slug)))
// Secondary AX skills (all others that aren't primary)
const secondaryAxSkills = $derived(axSkills.filter((m) => !PRIMARY_AX_SLUGS.includes(m.slug)))
/**
* Find a modifier by its ID
*/
function findModifierById(id: string): WeaponStatModifier | undefined {
return query.data?.find((m) => m.id === id)
}
/**
* Find a modifier by its slug
*/
function findModifierBySlug(slug: string): WeaponStatModifier | undefined {
return query.data?.find((m) => m.slug === slug)
}
/**
* Find an AX skill by ID
*/
function findAxSkill(id: string): WeaponStatModifier | undefined {
return axSkills.find((m) => m.id === id)
}
/**
* Find a befoulment by ID
*/
function findBefoulment(id: string): WeaponStatModifier | undefined {
return befoulments.find((m) => m.id === id)
}
return {
/** The underlying TanStack Query object */
query,
/** All modifiers (AX skills + befoulments) */
allModifiers: query.data ?? [],
/** AX skills only */
axSkills,
/** Befoulments only */
befoulments,
/** Primary AX skills (ATK, DEF, HP, C.A. DMG, Multiattack) */
primaryAxSkills,
/** Secondary AX skills (all others) */
secondaryAxSkills,
/** Whether the query is loading */
isLoading: query.isLoading,
/** Whether the query encountered an error */
isError: query.isError,
/** Error object if query failed */
error: query.error,
/** Find a modifier by ID */
findModifierById,
/** Find a modifier by slug */
findModifierBySlug,
/** Find an AX skill by ID */
findAxSkill,
/** Find a befoulment by ID */
findBefoulment
}
}

View file

@ -1,338 +0,0 @@
/**
* AX Skill data for weapon modifications
*
* Structure:
* - Array of 3 AX types (indexed by axType - 1)
* - Each type has a list of primary skills
* - Primary skills may have secondary skills that can be paired with them
*/
export interface AxSkill {
name: {
en: string
ja: string
}
id: number
granblueId: string
slug: string
minValue: number
maxValue: number
fractional: boolean
suffix?: string
secondary?: AxSkill[]
}
// "No skill" constant for empty selection
export const NO_AX_SKILL: AxSkill = {
name: { en: 'No skill', ja: 'スキルなし' },
id: -1,
granblueId: '',
slug: 'no-skill',
minValue: 0,
maxValue: 0,
fractional: false
}
// AX skill data organized by axType (1-indexed in API, 0-indexed here)
const ax: AxSkill[][] = [
// axType 1 - Standard AX skills
[
{
name: { en: 'ATK', ja: '攻撃' },
id: 0,
granblueId: '1589',
slug: 'atk',
minValue: 1,
maxValue: 3.5,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'C.A. DMG', ja: '奥義ダメ' }, id: 3, granblueId: '1591', slug: 'ca-dmg', minValue: 2, maxValue: 4, fractional: true, suffix: '%' },
{ name: { en: 'Double Attack Rate', ja: 'DA確率' }, id: 5, granblueId: '1596', slug: 'da', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Triple Attack Rate', ja: 'TA確率' }, id: 6, granblueId: '1597', slug: 'ta', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Skill DMG Cap', ja: 'アビ上限' }, id: 7, granblueId: '1588', slug: 'skill-cap', minValue: 1, maxValue: 2, fractional: true, suffix: '%' }
]
},
{
name: { en: 'DEF', ja: '防御' },
id: 1,
granblueId: '1590',
slug: 'def',
minValue: 1,
maxValue: 8,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'HP', ja: 'HP' }, id: 2, granblueId: '1588', slug: 'hp', minValue: 1, maxValue: 3, fractional: true, suffix: '%' },
{ name: { en: 'Debuff Resistance', ja: '弱体耐性' }, id: 9, granblueId: '1593', slug: 'debuff', minValue: 1, maxValue: 3, fractional: false, suffix: '%' },
{ name: { en: 'Healing', ja: '回復性能' }, id: 10, granblueId: '1595', slug: 'healing', minValue: 2, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Enmity', ja: '背水' }, id: 11, granblueId: '1601', slug: 'enmity', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'HP', ja: 'HP' },
id: 2,
granblueId: '1588',
slug: 'hp',
minValue: 1,
maxValue: 11,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'DEF', ja: '防御' }, id: 1, granblueId: '1590', slug: 'def', minValue: 1, maxValue: 3, fractional: true, suffix: '%' },
{ name: { en: 'Debuff Resistance', ja: '弱体耐性' }, id: 9, granblueId: '1593', slug: 'debuff', minValue: 1, maxValue: 3, fractional: false, suffix: '%' },
{ name: { en: 'Healing', ja: '回復性能' }, id: 10, granblueId: '1595', slug: 'healing', minValue: 2, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'C.A. DMG', ja: '奥義ダメ' },
id: 3,
granblueId: '1591',
slug: 'ca-dmg',
minValue: 2,
maxValue: 8.5,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'ATK', ja: '攻撃' }, id: 0, granblueId: '1589', slug: 'atk', minValue: 1, maxValue: 1.5, fractional: true, suffix: '%' },
{ name: { en: 'Elemental ATK', ja: '全属性攻撃力' }, id: 13, granblueId: '1594', slug: 'ele-atk', minValue: 1, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'C.A. DMG Cap', ja: '奥義上限' }, id: 8, granblueId: '1599', slug: 'ca-cap', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: true }
]
},
{
name: { en: 'Multiattack Rate', ja: '連撃率' },
id: 4,
granblueId: '1592',
slug: 'ta',
minValue: 1,
maxValue: 4,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'C.A. DMG', ja: '奥義ダメ' }, id: 3, granblueId: '1591', slug: 'ca-dmg', minValue: 2, maxValue: 4, fractional: true, suffix: '%' },
{ name: { en: 'Elemental ATK', ja: '全属性攻撃力' }, id: 13, granblueId: '1594', slug: 'ele-atk', minValue: 1, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Double Attack Rate', ja: 'DA確率' }, id: 5, granblueId: '1596', slug: 'da', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Triple Attack Rate', ja: 'TA確率' }, id: 6, granblueId: '1597', slug: 'ta', minValue: 1, maxValue: 2, fractional: true, suffix: '%' }
]
}
],
// axType 2 - Extended AX skills
[
{
name: { en: 'ATK', ja: '攻撃' },
id: 0,
granblueId: '1589',
slug: 'atk',
minValue: 1,
maxValue: 3.5,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'C.A. DMG', ja: '奥義ダメ' }, id: 3, granblueId: '1591', slug: 'ca-dmg', minValue: 2, maxValue: 8.5, fractional: true, suffix: '%' },
{ name: { en: 'Multiattack Rate', ja: '連撃確率' }, id: 4, granblueId: '1592', slug: 'ta', minValue: 1.5, maxValue: 4, fractional: true, suffix: '%' },
{ name: { en: 'Normal ATK DMG Cap', ja: '通常ダメ上限' }, id: 14, granblueId: '1722', slug: 'na-dmg', minValue: 0.5, maxValue: 1.5, fractional: true, suffix: '%' },
{ name: { en: 'Supplemental Skill DMG', ja: 'アビ与ダメ上昇' }, id: 15, granblueId: '1719', slug: 'skill-supp', minValue: 1, maxValue: 5, fractional: false }
]
},
{
name: { en: 'DEF', ja: '防御' },
id: 1,
granblueId: '1590',
slug: 'def',
minValue: 1,
maxValue: 8,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'Elemental DMG Reduction', ja: '属性ダメ軽減' }, id: 17, granblueId: '1721', slug: 'ele-def', minValue: 1, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Debuff Resistance', ja: '弱体耐性' }, id: 9, granblueId: '1593', slug: 'debuff', minValue: 1, maxValue: 3, fractional: false, suffix: '%' },
{ name: { en: 'Healing', ja: '回復性能' }, id: 10, granblueId: '1595', slug: 'healing', minValue: 2, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Enmity', ja: '背水' }, id: 11, granblueId: '1601', slug: 'enmity', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'HP', ja: 'HP' },
id: 2,
granblueId: '1588',
slug: 'hp',
minValue: 1,
maxValue: 11,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'Elemental DMG Reduction', ja: '属性ダメ軽減' }, id: 17, granblueId: '1721', slug: 'ele-def', minValue: 1, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Debuff Resistance', ja: '弱体耐性' }, id: 9, granblueId: '1593', slug: 'debuff', minValue: 1, maxValue: 3, fractional: false, suffix: '%' },
{ name: { en: 'Healing', ja: '回復性能' }, id: 10, granblueId: '1595', slug: 'healing', minValue: 2, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'C.A. DMG', ja: '奥義ダメ' },
id: 3,
granblueId: '1591',
slug: 'ca-dmg',
minValue: 2,
maxValue: 8.5,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'Multiattack Rate', ja: '連撃率' }, id: 4, granblueId: '1592', slug: 'ta', minValue: 1.5, maxValue: 4, fractional: true, suffix: '%' },
{ name: { en: 'Supplemental Skill DMG', ja: 'アビ与ダメ上昇' }, id: 15, granblueId: '1719', slug: 'skill-supp', minValue: 1, maxValue: 5, fractional: false },
{ name: { en: 'Supplemental C.A. DMG', ja: '奥義与ダメ上昇' }, id: 16, granblueId: '1720', slug: 'ca-supp', minValue: 1, maxValue: 5, fractional: false },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'Multiattack Rate', ja: '連撃率' },
id: 4,
granblueId: '1592',
slug: 'ta',
minValue: 1,
maxValue: 4,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'Supplemental C.A. DMG', ja: '奥義与ダメ上昇' }, id: 16, granblueId: '1720', slug: 'ca-supp', minValue: 1, maxValue: 5, fractional: false },
{ name: { en: 'Normal ATK DMG Cap', ja: '通常ダメ上限' }, id: 14, granblueId: '1722', slug: 'na-cap', minValue: 0.5, maxValue: 1.5, fractional: true, suffix: '%' },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: false },
{ name: { en: 'Enmity', ja: '背水' }, id: 11, granblueId: '1601', slug: 'enmity', minValue: 1, maxValue: 3, fractional: false }
]
}
],
// axType 3 - Standard + EXP/Rupie skills
[
{
name: { en: 'ATK', ja: '攻撃' },
id: 0,
granblueId: '1589',
slug: 'atk',
minValue: 1,
maxValue: 3.5,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'C.A. DMG', ja: '奥義ダメ' }, id: 3, granblueId: '1591', slug: 'ca-dmg', minValue: 2, maxValue: 4, fractional: true, suffix: '%' },
{ name: { en: 'Double Attack Rate', ja: 'DA確率' }, id: 5, granblueId: '1596', slug: 'da', minValue: 1, maxValue: 2, fractional: false, suffix: '%' },
{ name: { en: 'Triple Attack Rate', ja: 'TA確率' }, id: 6, granblueId: '1597', slug: 'ta', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Skill DMG Cap', ja: 'アビ上限' }, id: 7, granblueId: '1588', slug: 'skill-cap', minValue: 1, maxValue: 2, fractional: true, suffix: '%' }
]
},
{
name: { en: 'DEF', ja: '防御' },
id: 1,
granblueId: '1590',
slug: 'def',
minValue: 1,
maxValue: 8,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'HP', ja: 'HP' }, id: 2, granblueId: '1588', slug: 'hp', minValue: 1, maxValue: 3, fractional: true, suffix: '%' },
{ name: { en: 'Debuff Resistance', ja: '弱体耐性' }, id: 9, granblueId: '1593', slug: 'debuff', minValue: 1, maxValue: 3, fractional: false, suffix: '%' },
{ name: { en: 'Healing', ja: '回復性能' }, id: 10, granblueId: '1595', slug: 'healing', minValue: 2, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Enmity', ja: '背水' }, id: 11, granblueId: '1601', slug: 'enmity', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'HP', ja: 'HP' },
id: 2,
granblueId: '1588',
slug: 'hp',
minValue: 1,
maxValue: 11,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'DEF', ja: '防御' }, id: 1, granblueId: '1590', slug: 'def', minValue: 1, maxValue: 3, fractional: true, suffix: '%' },
{ name: { en: 'Debuff Resistance', ja: '弱体耐性' }, id: 9, granblueId: '1593', slug: 'debuff', minValue: 1, maxValue: 3, fractional: false, suffix: '%' },
{ name: { en: 'Healing', ja: '回復性能' }, id: 10, granblueId: '1595', slug: 'healing', minValue: 2, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'C.A. DMG', ja: '奥義ダメ' },
id: 3,
granblueId: '1591',
slug: 'ca-dmg',
minValue: 2,
maxValue: 8.5,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'ATK', ja: '攻撃' }, id: 0, granblueId: '1589', slug: 'atk', minValue: 1, maxValue: 1.5, fractional: true, suffix: '%' },
{ name: { en: 'Elemental ATK', ja: '全属性攻撃力' }, id: 13, granblueId: '1594', slug: 'ele-atk', minValue: 1, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'C.A. DMG Cap', ja: '奥義上限' }, id: 8, granblueId: '1599', slug: 'ca-dmg', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Stamina', ja: '渾身' }, id: 12, granblueId: '1600', slug: 'stamina', minValue: 1, maxValue: 3, fractional: false }
]
},
{
name: { en: 'Multiattack Rate', ja: '連撃率' },
id: 4,
granblueId: '1592',
slug: 'ta',
minValue: 1,
maxValue: 4,
suffix: '%',
fractional: true,
secondary: [
{ name: { en: 'C.A. DMG', ja: '奥義ダメ' }, id: 3, granblueId: '1591', slug: 'ca-dmg', minValue: 2, maxValue: 4, fractional: true, suffix: '%' },
{ name: { en: 'Elemental ATK', ja: '全属性攻撃力' }, id: 13, granblueId: '1594', slug: 'ele-atk', minValue: 1, maxValue: 5, fractional: true, suffix: '%' },
{ name: { en: 'Double Attack Rate', ja: 'DA確率' }, id: 5, granblueId: '1596', slug: 'da', minValue: 1, maxValue: 2, fractional: true, suffix: '%' },
{ name: { en: 'Triple Attack Rate', ja: 'TA確率' }, id: 6, granblueId: '1597', slug: 'ta', minValue: 1, maxValue: 2, fractional: true, suffix: '%' }
]
},
// Skills without secondary options
{
name: { en: 'EXP Gain', ja: 'EXP UP' },
id: 18,
granblueId: '1837',
slug: 'exp',
minValue: 5,
maxValue: 10,
suffix: '%',
fractional: false
},
{
name: { en: 'Rupie Gain', ja: '獲得ルピ' },
id: 19,
granblueId: '1838',
slug: 'rupie',
minValue: 10,
maxValue: 20,
suffix: '%',
fractional: false
}
]
]
/**
* Get AX skills for a given axType
* @param axType - The weapon's axType (1-indexed from API)
* @returns Array of primary AX skills for this type
*/
export function getAxSkillsForType(axType: number): AxSkill[] {
const index = axType - 1
if (index < 0 || index >= ax.length) {
return []
}
return ax[index] ?? []
}
/**
* Find a primary skill by its modifier ID
*/
export function findPrimarySkill(axType: number, modifierId: number): AxSkill | undefined {
const skills = getAxSkillsForType(axType)
return skills.find((s) => s.id === modifierId)
}
/**
* Find a secondary skill by its modifier ID within a primary skill
*/
export function findSecondarySkill(primarySkill: AxSkill, modifierId: number): AxSkill | undefined {
return primarySkill.secondary?.find((s) => s.id === modifierId)
}
export default ax

View file

@ -1,19 +0,0 @@
export interface GridWeapon {
id: string
mainhand: boolean
position: number
object: Weapon
uncap_level: number
transcendence_step: number
element: number
weapon_keys?: Array<WeaponKey>
ax?: Array<SimpleAxSkill>
awakening?: {
type: Awakening
level: number
}
/** Reference to the source collection weapon if linked */
collectionWeaponId?: string
/** Whether the grid item is out of sync with its collection source */
outOfSync?: boolean
}

View file

@ -1,4 +0,0 @@
export interface SimpleAxSkill {
modifier: number
strength: number
}

View file

@ -2,6 +2,7 @@
// These define user-owned items with customizations // These define user-owned items with customizations
import type { Character, Weapon, Summon, JobAccessory, Awakening } from './entities' import type { Character, Weapon, Summon, JobAccessory, Awakening } from './entities'
import type { AugmentSkill, Befoulment } from './weaponStatModifier'
/** /**
* Extended mastery modifier (used for rings and earrings) * Extended mastery modifier (used for rings and earrings)
@ -43,7 +44,10 @@ export interface CollectionWeapon {
uncapLevel: number uncapLevel: number
transcendenceStep: number transcendenceStep: number
element?: number // For element-changeable weapons element?: number // For element-changeable weapons
ax?: Array<{ modifier: number; strength: number }> /** AX skills with full modifier objects */
ax?: AugmentSkill[]
/** Befoulment for Odiant weapons */
befoulment?: Befoulment
awakening: { awakening: {
type: Awakening type: Awakening
level: number level: number
@ -122,10 +126,15 @@ export interface CollectionWeaponInput {
weaponKey4Id?: string weaponKey4Id?: string
awakeningId?: string awakeningId?: string
awakeningLevel?: number awakeningLevel?: number
axModifier1?: number // AX skills (uses FK IDs for API payload)
axModifier1Id?: string
axStrength1?: number axStrength1?: number
axModifier2?: number axModifier2Id?: string
axStrength2?: number axStrength2?: number
// Befoulment (for Odiant weapons)
befoulmentModifierId?: string
befoulmentStrength?: number
exorcismLevel?: number
} }
/** /**

View file

@ -259,12 +259,6 @@ export interface WeaponKey {
order: number order: number
} }
// SimpleAxSkill for weapon AX skills
export interface SimpleAxSkill {
modifier: number
strength: number
}
// Guidebook entity // Guidebook entity
export interface Guidebook { export interface Guidebook {
id: string id: string

View file

@ -12,10 +12,10 @@ import type {
Guidebook, Guidebook,
User, User,
Awakening, Awakening,
WeaponKey, WeaponKey
SimpleAxSkill
} from './entities' } from './entities'
import type { GridArtifact, CollectionArtifact } from './artifact' import type { GridArtifact, CollectionArtifact } from './artifact'
import type { AugmentSkill, Befoulment } from './weaponStatModifier'
// Grid item types - these are the junction tables between Party and entities // Grid item types - these are the junction tables between Party and entities
@ -29,7 +29,10 @@ export interface GridWeapon {
element?: number element?: number
weapon: Weapon // Named properly, not "object" weapon: Weapon // Named properly, not "object"
weaponKeys?: WeaponKey[] weaponKeys?: WeaponKey[]
ax?: SimpleAxSkill[] /** AX skills with full modifier objects */
ax?: AugmentSkill[]
/** Befoulment for Odiant weapons */
befoulment?: Befoulment
awakening?: { awakening?: {
type?: Awakening type?: Awakening
level?: number level?: number

View file

@ -7,6 +7,8 @@
* @module types/api/weaponSeries * @module types/api/weaponSeries
*/ */
import type { AugmentType } from './weaponStatModifier'
/** /**
* Embedded series reference on weapons. * Embedded series reference on weapons.
* This is the structure returned in weapon.series field. * This is the structure returned in weapon.series field.
@ -18,7 +20,8 @@ export interface WeaponSeriesRef {
name: { en: string; ja: string } name: { en: string; ja: string }
hasWeaponKeys: boolean hasWeaponKeys: boolean
hasAwakening: boolean hasAwakening: boolean
hasAxSkills: boolean /** Type of augment this series supports: "ax", "befoulment", or "none" */
augmentType: AugmentType
extra: boolean extra: boolean
elementChangeable: boolean elementChangeable: boolean
} }
@ -37,7 +40,8 @@ export interface WeaponSeries {
elementChangeable: boolean elementChangeable: boolean
hasWeaponKeys: boolean hasWeaponKeys: boolean
hasAwakening: boolean hasAwakening: boolean
hasAxSkills: boolean /** Type of augment this series supports: "ax", "befoulment", or "none" */
augmentType: AugmentType
// Only included in :full view (show endpoint) // Only included in :full view (show endpoint)
weaponCount?: number weaponCount?: number
} }
@ -54,7 +58,7 @@ export interface WeaponSeriesInput {
element_changeable: boolean element_changeable: boolean
has_weapon_keys: boolean has_weapon_keys: boolean
has_awakening: boolean has_awakening: boolean
has_ax_skills: boolean augment_type: AugmentType
} }
/** /**
@ -69,7 +73,7 @@ export interface CreateWeaponSeriesPayload {
element_changeable?: boolean element_changeable?: boolean
has_weapon_keys?: boolean has_weapon_keys?: boolean
has_awakening?: boolean has_awakening?: boolean
has_ax_skills?: boolean augment_type?: AugmentType
} }
/** /**
@ -84,7 +88,7 @@ export interface UpdateWeaponSeriesPayload {
element_changeable?: boolean element_changeable?: boolean
has_weapon_keys?: boolean has_weapon_keys?: boolean
has_awakening?: boolean has_awakening?: boolean
has_ax_skills?: boolean augment_type?: AugmentType
} }
/** /**

View file

@ -0,0 +1,61 @@
/**
* Weapon Stat Modifier Types
*
* Type definitions for the unified AX skill and Befoulment system.
* These types represent modifiers from the weapon_stat_modifiers API endpoint.
*
* @module types/api/weaponStatModifier
*/
/**
* Augment type enum for weapon series.
* Determines whether a weapon series supports AX skills, befoulments, or neither.
*/
export type AugmentType = 'none' | 'ax' | 'befoulment'
/**
* WeaponStatModifier from the API.
* Represents an AX skill or befoulment modifier definition.
*/
export interface WeaponStatModifier {
/** Unique identifier */
id: string
/** URL-safe identifier (e.g., "ax_atk", "bef_atk_down") */
slug: string
/** English display name */
nameEn: string
/** Japanese display name */
nameJp: string
/** Category: "ax" for AX skills, "befoulment" for negative modifiers */
category: 'ax' | 'befoulment'
/** The stat this modifier affects (e.g., "atk", "def", "hp") */
stat: string
/** Polarity: 1 for buffs (positive), -1 for debuffs (negative) */
polarity: 1 | -1
/** Display suffix for values (e.g., "%") */
suffix: string | null
}
/**
* AX Skill with its modifier and strength value.
* Used in GridWeapon and CollectionWeapon for weapons with AX skills.
*/
export interface AugmentSkill {
/** The modifier definition */
modifier: WeaponStatModifier
/** The strength/value of this skill (e.g., 3.0 for 3% ATK) */
strength: number
}
/**
* Befoulment with modifier, strength, and exorcism level.
* Used for Odiant weapons with negative stat modifiers.
*/
export interface Befoulment {
/** The befoulment modifier definition */
modifier: WeaponStatModifier
/** The strength/value of this befoulment */
strength: number
/** Exorcism level (0-5) - higher levels reduce the negative effect */
exorcismLevel: number
}

View file

@ -6,6 +6,7 @@ export interface ModificationStatus {
hasAwakening: boolean hasAwakening: boolean
hasWeaponKeys: boolean hasWeaponKeys: boolean
hasAxSkills: boolean hasAxSkills: boolean
hasBefoulment: boolean
hasRings: boolean hasRings: boolean
hasEarring: boolean hasEarring: boolean
hasPerpetuity: boolean hasPerpetuity: boolean
@ -25,6 +26,7 @@ export function detectModifications(
hasAwakening: false, hasAwakening: false,
hasWeaponKeys: false, hasWeaponKeys: false,
hasAxSkills: false, hasAxSkills: false,
hasBefoulment: false,
hasRings: false, hasRings: false,
hasEarring: false, hasEarring: false,
hasPerpetuity: false, hasPerpetuity: false,
@ -60,6 +62,7 @@ export function detectModifications(
status.hasAwakening = !!weapon.awakening status.hasAwakening = !!weapon.awakening
status.hasWeaponKeys = !!(weapon.weaponKeys && weapon.weaponKeys.length > 0) status.hasWeaponKeys = !!(weapon.weaponKeys && weapon.weaponKeys.length > 0)
status.hasAxSkills = !!(weapon.ax && weapon.ax.length > 0) status.hasAxSkills = !!(weapon.ax && weapon.ax.length > 0)
status.hasBefoulment = !!weapon.befoulment?.modifier
status.hasTranscendence = !!(weapon.transcendenceStep && weapon.transcendenceStep > 0) status.hasTranscendence = !!(weapon.transcendenceStep && weapon.transcendenceStep > 0)
status.hasUncapLevel = weapon.uncapLevel !== undefined && weapon.uncapLevel !== null status.hasUncapLevel = weapon.uncapLevel !== undefined && weapon.uncapLevel !== null
status.hasElement = !!(weapon.element && weapon.weapon?.element === 0) status.hasElement = !!(weapon.element && weapon.weapon?.element === 0)
@ -68,6 +71,7 @@ export function detectModifications(
status.hasAwakening || status.hasAwakening ||
status.hasWeaponKeys || status.hasWeaponKeys ||
status.hasAxSkills || status.hasAxSkills ||
status.hasBefoulment ||
status.hasTranscendence || status.hasTranscendence ||
status.hasUncapLevel || status.hasUncapLevel ||
status.hasElement status.hasElement
@ -111,13 +115,14 @@ export function canWeaponBeModified(gridWeapon: GridWeapon | undefined): boolean
// Weapon keys (series-specific) - use utility function that handles both formats // Weapon keys (series-specific) - use utility function that handles both formats
const hasWeaponKeys = seriesHasWeaponKeys(weapon.series) const hasWeaponKeys = seriesHasWeaponKeys(weapon.series)
// AX skills // AX skills or Befoulment - check augmentType from series
const hasAxSkills = weapon.ax === true const augmentType = weapon.series?.augmentType ?? 'none'
const hasAugments = augmentType !== 'none'
// Awakening (maxAwakeningLevel > 0 means it can have awakening) // Awakening (maxAwakeningLevel > 0 means it can have awakening)
const hasAwakening = (weapon.maxAwakeningLevel ?? 0) > 0 const hasAwakening = (weapon.maxAwakeningLevel ?? 0) > 0
return canChangeElement || hasWeaponKeys || hasAxSkills || hasAwakening return canChangeElement || hasWeaponKeys || hasAugments || hasAwakening
} }
/** /**

View file

@ -1,21 +1,7 @@
import type { SimpleAxSkill } from '$lib/types/api/entities' import type { AugmentSkill } from '$lib/types/api/weaponStatModifier'
import { getRingStat, getEarringStat, getElementalizedEarringStat } from './masteryUtils' import { getRingStat, getEarringStat, getElementalizedEarringStat } from './masteryUtils'
import { isWeaponSeriesRef, type WeaponSeriesRef } from '$lib/types/api/weaponSeries' import { isWeaponSeriesRef, type WeaponSeriesRef } from '$lib/types/api/weaponSeries'
const AX_SKILL_NAMES: Record<number, string> = {
1: 'Attack',
2: 'HP',
3: 'Double Attack',
4: 'Triple Attack',
5: 'C.A. DMG',
6: 'C.A. DMG Cap',
7: 'Skill DMG',
8: 'Skill DMG Cap',
9: 'Stamina',
10: 'Enmity',
11: 'Critical Hit'
}
export function formatRingStat( export function formatRingStat(
modifier: number, modifier: number,
strength: number, strength: number,
@ -46,9 +32,9 @@ export function formatEarringStat(
return `${statName} +${strength}${stat.suffix}` return `${statName} +${strength}${stat.suffix}`
} }
export function formatAxSkill(ax: SimpleAxSkill): string { export function formatAxSkill(ax: AugmentSkill, locale: 'en' | 'ja' = 'en'): string {
const skillName = AX_SKILL_NAMES[ax.modifier] || `Unknown (${ax.modifier})` const skillName = locale === 'ja' ? ax.modifier.nameJp : ax.modifier.nameEn
const suffix = ax.modifier <= 2 ? '' : '%' const suffix = ax.modifier.suffix ?? ''
return `${skillName} +${ax.strength}${suffix}` return `${skillName} +${ax.strength}${suffix}`
} }

View file

@ -3,7 +3,7 @@
*/ */
import type { Awakening, WeaponKey } from '$lib/types/api/entities' import type { Awakening, WeaponKey } from '$lib/types/api/entities'
import type { SimpleAxSkill } from '$lib/types/SimpleAxSkill' import type { AugmentSkill } from '$lib/types/api/weaponStatModifier'
import { isWeaponSeriesRef, type WeaponSeriesRef } from '$lib/types/api/weaponSeries' import { isWeaponSeriesRef, type WeaponSeriesRef } from '$lib/types/api/weaponSeries'
import { getBasePath } from '$lib/utils/images' import { getBasePath } from '$lib/utils/images'
@ -120,10 +120,14 @@ export function getAxSkillImage(axSkill?: { slug?: string }): string | null {
/** /**
* Get all AX skill images for a weapon * Get all AX skill images for a weapon
* Note: This is a placeholder until ax data structure is fully implemented
*/ */
export function getAxSkillImages(ax?: SimpleAxSkill[]): Array<{ url: string; alt: string }> { export function getAxSkillImages(ax?: AugmentSkill[]): Array<{ url: string; alt: string }> {
// TODO: Implement when ax data reference is available if (!ax || ax.length === 0) return []
// This would need to map ax modifiers to actual ax skill data
return [] return ax
.filter((skill) => skill.modifier?.slug)
.map((skill) => ({
url: `${getBasePath()}/ax/${skill.modifier.slug}.png`,
alt: skill.modifier.nameEn || skill.modifier.slug || 'AX Skill'
}))
} }

View file

@ -8,7 +8,7 @@ const mockOmegaSeries = {
name: { en: 'Omega', ja: 'マグナ' }, name: { en: 'Omega', ja: 'マグナ' },
hasWeaponKeys: false, hasWeaponKeys: false,
hasAwakening: true, hasAwakening: true,
hasAxSkills: true, augmentType: 'ax' as const,
extra: false, extra: false,
elementChangeable: false elementChangeable: false
}; };
@ -19,7 +19,7 @@ const mockOpusSeries = {
name: { en: 'Opus', ja: 'オプス' }, name: { en: 'Opus', ja: 'オプス' },
hasWeaponKeys: true, hasWeaponKeys: true,
hasAwakening: true, hasAwakening: true,
hasAxSkills: false, augmentType: 'none' as const,
extra: false, extra: false,
elementChangeable: false elementChangeable: false
}; };
@ -30,7 +30,7 @@ const mockDraconicSeries = {
name: { en: 'Draconic', ja: 'ドラゴニック' }, name: { en: 'Draconic', ja: 'ドラゴニック' },
hasWeaponKeys: true, hasWeaponKeys: true,
hasAwakening: false, hasAwakening: false,
hasAxSkills: false, augmentType: 'none' as const,
extra: false, extra: false,
elementChangeable: false elementChangeable: false
}; };