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: 'ダークオーパス' },
hasWeaponKeys: true,
hasAwakening: true,
hasAxSkills: false,
augmentType: 'none',
extra: false,
elementChangeable: false
},

View file

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

View file

@ -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<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
*/
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')
}
}

View file

@ -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
}

View file

@ -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()
})

View file

@ -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 @@
<DetailsSection title="AX Skills" empty={!hasAxSkills} emptyMessage="Not set">
{#each weapon.ax ?? [] as ax, i}
{#if ax.modifier >= 0}
<DetailRow label="Skill {i + 1}" value={`${ax.modifier}: ${ax.strength}`} />
{#if ax.modifier?.id}
<DetailRow label="Skill {i + 1}" value={`${ax.modifier.nameEn} +${ax.strength}${ax.modifier.suffix ?? ''}`} />
{/if}
{/each}
</DetailsSection>

View file

@ -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<string | undefined>(currentValues.weaponKey3Id)
let selectedAwakening = $state<Awakening | undefined>(currentValues.awakening?.type)
let awakeningLevel = $state(currentValues.awakening?.level ?? 1)
let axSkills = $state<SimpleAxSkill[]>(
currentValues.axSkills.length > 0
? currentValues.axSkills
: [
{ modifier: -1, strength: 0 },
{ modifier: -1, strength: 0 }
]
)
let axSkills = $state<AugmentSkill[]>(currentValues.axSkills ?? [])
let befoulment = $state<Befoulment | null>(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)
}
</script>
@ -279,7 +285,6 @@
<DetailsSection title="AX Skills">
<div class="section-content">
<AxSkillSelect
{axType}
currentSkills={axSkills}
onChange={(skills) => {
axSkills = skills
@ -289,6 +294,19 @@
</DetailsSection>
{/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}
<DetailsSection title="Awakening">
<div class="section-content">

View file

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

View file

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

View file

@ -1,215 +1,138 @@
<svelte:options runes={true} />
<script lang="ts">
import type { SimpleAxSkill } from '$lib/types/api/entities'
import {
type AxSkill,
NO_AX_SKILL,
getAxSkillsForType,
findPrimarySkill,
findSecondarySkill
} from '$lib/data/ax'
import type { AugmentSkill, WeaponStatModifier } from '$lib/types/api/weaponStatModifier'
import { useWeaponStatModifiers } from '$lib/composables/useWeaponStatModifiers.svelte'
import Select from '$lib/components/ui/Select.svelte'
interface Props {
/** The weapon's axType (1-3) */
axType: number
/** Current AX skills on the weapon */
currentSkills?: SimpleAxSkill[]
currentSkills?: AugmentSkill[]
/** 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 primarySkills = $derived(getAxSkillsForType(axType))
const { primaryAxSkills, secondaryAxSkills, findAxSkill, isLoading } = useWeaponStatModifiers()
// State for primary skill
let primaryModifier = $state(currentSkills?.[0]?.modifier ?? -1)
let primaryValue = $state(currentSkills?.[0]?.strength ?? 0)
let primaryError = $state('')
let selectedPrimaryId = $state<string>(currentSkills[0]?.modifier?.id ?? '')
let primaryStrength = $state<number>(currentSkills[0]?.strength ?? 0)
// State for secondary skill
let secondaryModifier = $state(currentSkills?.[1]?.modifier ?? -1)
let secondaryValue = $state(currentSkills?.[1]?.strength ?? 0)
let secondaryError = $state('')
let selectedSecondaryId = $state<string>(currentSkills[1]?.modifier?.id ?? '')
let secondaryStrength = $state<number>(currentSkills[1]?.strength ?? 0)
// Get the selected primary skill
const selectedPrimarySkill = $derived(findPrimarySkill(axType, primaryModifier))
// Get selected modifiers
const selectedPrimary = $derived(selectedPrimaryId ? findAxSkill(selectedPrimaryId) : undefined)
const selectedSecondary = $derived(
selectedSecondaryId ? findAxSkill(selectedSecondaryId) : undefined
)
// 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(
primaryModifier >= 0 &&
selectedPrimarySkill?.secondary &&
selectedPrimarySkill.secondary.length > 0
)
const showSecondary = $derived(!!selectedPrimary)
// Build primary skill options
const primaryOptions = $derived.by(() => {
const items: Array<{ value: number; label: string }> = [
{ value: -1, label: NO_AX_SKILL.name.en }
]
const items: Array<{ value: string; label: string }> = [{ value: '', label: 'No skill' }]
for (const skill of primarySkills) {
for (const skill of primaryAxSkills) {
items.push({
value: skill.id,
label: skill.name.en
label: locale === 'ja' ? skill.nameJp : skill.nameEn
})
}
return items
})
// Build secondary skill options based on selected primary
// Build secondary skill options
const secondaryOptions = $derived.by(() => {
const items: Array<{ value: number; label: string }> = [
{ value: -1, label: NO_AX_SKILL.name.en }
]
const items: Array<{ value: string; label: string }> = [{ value: '', label: 'No skill' }]
if (selectedPrimarySkill?.secondary) {
for (const skill of selectedPrimarySkill.secondary) {
items.push({
value: skill.id,
label: skill.name.en
})
}
for (const skill of secondaryAxSkills) {
items.push({
value: skill.id,
label: locale === 'ja' ? skill.nameJp : skill.nameEn
})
}
return items
})
// Get range string for input placeholder
function getRangeString(skill: AxSkill | undefined): string {
if (!skill) return ''
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 ''
// Get suffix for display
function getSuffix(modifier: WeaponStatModifier | undefined): string {
return modifier?.suffix ?? ''
}
// Handle primary skill change
function handlePrimaryChange(value: number | undefined) {
const newValue = value ?? -1
primaryModifier = newValue
primaryValue = 0
primaryError = ''
// Reset secondary when primary changes
secondaryModifier = -1
secondaryValue = 0
secondaryError = ''
function handlePrimaryChange(value: string | undefined) {
selectedPrimaryId = value ?? ''
if (!value) {
primaryStrength = 0
// Reset secondary when primary is cleared
selectedSecondaryId = ''
secondaryStrength = 0
}
emitChange()
}
// Handle primary value change
function handlePrimaryValueChange(event: Event) {
function handlePrimaryStrengthChange(event: Event) {
const input = event.target as HTMLInputElement
const value = parseFloat(input.value)
primaryValue = value
primaryError = validateValue(value, selectedPrimarySkill)
primaryStrength = parseFloat(input.value) || 0
emitChange()
}
// Handle secondary skill change
function handleSecondaryChange(value: number | undefined) {
const newValue = value ?? -1
secondaryModifier = newValue
secondaryValue = 0
secondaryError = ''
function handleSecondaryChange(value: string | undefined) {
selectedSecondaryId = value ?? ''
if (!value) {
secondaryStrength = 0
}
emitChange()
}
// Handle secondary value change
function handleSecondaryValueChange(event: Event) {
function handleSecondaryStrengthChange(event: Event) {
const input = event.target as HTMLInputElement
const value = parseFloat(input.value)
secondaryValue = value
const secondarySkill = findSecondarySkill(selectedPrimarySkill!, secondaryModifier)
secondaryError = validateValue(value, secondarySkill)
secondaryStrength = parseFloat(input.value) || 0
emitChange()
}
// Emit change to parent
function emitChange() {
const skills: SimpleAxSkill[] = [
{ modifier: primaryModifier, strength: primaryValue },
{ modifier: secondaryModifier, strength: secondaryValue }
]
const skills: AugmentSkill[] = []
if (selectedPrimary && primaryStrength > 0) {
skills.push({ modifier: selectedPrimary, strength: primaryStrength })
}
if (selectedSecondary && secondaryStrength > 0) {
skills.push({ modifier: selectedSecondary, strength: secondaryStrength })
}
onChange?.(skills)
}
</script>
<div class="ax-skill-select">
<!-- Primary Skill -->
<div class="skill-row">
<div class="skill-fields">
<div class="skill-select">
<Select
options={primaryOptions}
value={primaryModifier}
onValueChange={handlePrimaryChange}
placeholder="Select skill"
size="medium"
fullWidth
contained
/>
</div>
{#if primaryModifier >= 0 && selectedPrimarySkill}
<input
type="number"
class="skill-value"
class:error={primaryError !== ''}
min={selectedPrimarySkill.minValue}
max={selectedPrimarySkill.maxValue}
step={selectedPrimarySkill.fractional ? '0.5' : '1'}
placeholder={getRangeString(selectedPrimarySkill)}
value={primaryValue || ''}
oninput={handlePrimaryValueChange}
/>
{/if}
</div>
{#if primaryError}
<p class="error-text">{primaryError}</p>
{/if}
{#if isLoading}
<div class="ax-skill-select loading">
<div class="skeleton"></div>
</div>
<!-- Secondary Skill (only shown when primary has secondaries) -->
{#if showSecondary}
{:else}
<div class="ax-skill-select">
<!-- Primary Skill -->
<div class="skill-row">
<div class="skill-fields">
<div class="skill-select">
<Select
options={secondaryOptions}
value={secondaryModifier}
onValueChange={handleSecondaryChange}
options={primaryOptions}
value={selectedPrimaryId}
onValueChange={handlePrimaryChange}
placeholder="Select skill"
size="medium"
fullWidth
@ -217,30 +140,56 @@
/>
</div>
{#if secondaryModifier >= 0}
{@const secondarySkill = findSecondarySkill(selectedPrimarySkill!, secondaryModifier)}
{#if secondarySkill}
<input
type="number"
class="skill-value"
class:error={secondaryError !== ''}
min={secondarySkill.minValue}
max={secondarySkill.maxValue}
step={secondarySkill.fractional ? '0.5' : '1'}
placeholder={getRangeString(secondarySkill)}
value={secondaryValue || ''}
oninput={handleSecondaryValueChange}
/>
{#if selectedPrimary}
<input
type="number"
class="skill-value"
step="0.5"
placeholder="Value"
value={primaryStrength || ''}
oninput={handlePrimaryStrengthChange}
/>
{#if getSuffix(selectedPrimary)}
<span class="suffix">{getSuffix(selectedPrimary)}</span>
{/if}
{/if}
</div>
{#if secondaryError}
<p class="error-text">{secondaryError}</p>
{/if}
</div>
{/if}
</div>
<!-- Secondary Skill -->
{#if showSecondary}
<div class="skill-row">
<div class="skill-fields">
<div class="skill-select">
<Select
options={secondaryOptions}
value={selectedSecondaryId}
onValueChange={handleSecondaryChange}
placeholder="Select skill"
size="medium"
fullWidth
contained
/>
</div>
{#if selectedSecondary}
<input
type="number"
class="skill-value"
step="0.5"
placeholder="Value"
value={secondaryStrength || ''}
oninput={handleSecondaryStrengthChange}
/>
{#if getSuffix(selectedSecondary)}
<span class="suffix">{getSuffix(selectedSecondary)}</span>
{/if}
{/if}
</div>
</div>
{/if}
</div>
{/if}
<style lang="scss">
@use '$src/themes/colors' as colors;
@ -252,6 +201,27 @@
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;
}
}
.skill-row {
@ -272,7 +242,7 @@
}
.skill-value {
width: 90px;
width: 80px;
flex-shrink: 0;
padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bg, colors.$grey-85);
@ -287,10 +257,6 @@
border-color: var(--accent-primary);
}
&.error {
border-color: colors.$error;
}
// Remove spin buttons
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
@ -300,9 +266,9 @@
}
}
.error-text {
margin: 0;
.suffix {
color: var(--text-secondary);
font-size: typography.$font-small;
color: colors.$error;
flex-shrink: 0;
}
</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
import type { Character, Weapon, Summon, JobAccessory, Awakening } from './entities'
import type { AugmentSkill, Befoulment } from './weaponStatModifier'
/**
* Extended mastery modifier (used for rings and earrings)
@ -43,7 +44,10 @@ export interface CollectionWeapon {
uncapLevel: number
transcendenceStep: number
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: {
type: Awakening
level: number
@ -122,10 +126,15 @@ export interface CollectionWeaponInput {
weaponKey4Id?: string
awakeningId?: string
awakeningLevel?: number
axModifier1?: number
// AX skills (uses FK IDs for API payload)
axModifier1Id?: string
axStrength1?: number
axModifier2?: number
axModifier2Id?: string
axStrength2?: number
// Befoulment (for Odiant weapons)
befoulmentModifierId?: string
befoulmentStrength?: number
exorcismLevel?: number
}
/**

View file

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

View file

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

View file

@ -7,6 +7,8 @@
* @module types/api/weaponSeries
*/
import type { AugmentType } from './weaponStatModifier'
/**
* Embedded series reference on weapons.
* This is the structure returned in weapon.series field.
@ -18,7 +20,8 @@ export interface WeaponSeriesRef {
name: { en: string; ja: string }
hasWeaponKeys: boolean
hasAwakening: boolean
hasAxSkills: boolean
/** Type of augment this series supports: "ax", "befoulment", or "none" */
augmentType: AugmentType
extra: boolean
elementChangeable: boolean
}
@ -37,7 +40,8 @@ export interface WeaponSeries {
elementChangeable: boolean
hasWeaponKeys: boolean
hasAwakening: boolean
hasAxSkills: boolean
/** Type of augment this series supports: "ax", "befoulment", or "none" */
augmentType: AugmentType
// Only included in :full view (show endpoint)
weaponCount?: number
}
@ -54,7 +58,7 @@ export interface WeaponSeriesInput {
element_changeable: boolean
has_weapon_keys: boolean
has_awakening: boolean
has_ax_skills: boolean
augment_type: AugmentType
}
/**
@ -69,7 +73,7 @@ export interface CreateWeaponSeriesPayload {
element_changeable?: boolean
has_weapon_keys?: boolean
has_awakening?: boolean
has_ax_skills?: boolean
augment_type?: AugmentType
}
/**
@ -84,7 +88,7 @@ export interface UpdateWeaponSeriesPayload {
element_changeable?: boolean
has_weapon_keys?: 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
hasWeaponKeys: boolean
hasAxSkills: boolean
hasBefoulment: boolean
hasRings: boolean
hasEarring: boolean
hasPerpetuity: boolean
@ -25,6 +26,7 @@ export function detectModifications(
hasAwakening: false,
hasWeaponKeys: false,
hasAxSkills: false,
hasBefoulment: false,
hasRings: false,
hasEarring: false,
hasPerpetuity: false,
@ -60,6 +62,7 @@ export function detectModifications(
status.hasAwakening = !!weapon.awakening
status.hasWeaponKeys = !!(weapon.weaponKeys && weapon.weaponKeys.length > 0)
status.hasAxSkills = !!(weapon.ax && weapon.ax.length > 0)
status.hasBefoulment = !!weapon.befoulment?.modifier
status.hasTranscendence = !!(weapon.transcendenceStep && weapon.transcendenceStep > 0)
status.hasUncapLevel = weapon.uncapLevel !== undefined && weapon.uncapLevel !== null
status.hasElement = !!(weapon.element && weapon.weapon?.element === 0)
@ -68,6 +71,7 @@ export function detectModifications(
status.hasAwakening ||
status.hasWeaponKeys ||
status.hasAxSkills ||
status.hasBefoulment ||
status.hasTranscendence ||
status.hasUncapLevel ||
status.hasElement
@ -111,13 +115,14 @@ export function canWeaponBeModified(gridWeapon: GridWeapon | undefined): boolean
// Weapon keys (series-specific) - use utility function that handles both formats
const hasWeaponKeys = seriesHasWeaponKeys(weapon.series)
// AX skills
const hasAxSkills = weapon.ax === true
// AX skills or Befoulment - check augmentType from series
const augmentType = weapon.series?.augmentType ?? 'none'
const hasAugments = augmentType !== 'none'
// Awakening (maxAwakeningLevel > 0 means it can have awakening)
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 { 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(
modifier: number,
strength: number,
@ -46,9 +32,9 @@ export function formatEarringStat(
return `${statName} +${strength}${stat.suffix}`
}
export function formatAxSkill(ax: SimpleAxSkill): string {
const skillName = AX_SKILL_NAMES[ax.modifier] || `Unknown (${ax.modifier})`
const suffix = ax.modifier <= 2 ? '' : '%'
export function formatAxSkill(ax: AugmentSkill, locale: 'en' | 'ja' = 'en'): string {
const skillName = locale === 'ja' ? ax.modifier.nameJp : ax.modifier.nameEn
const suffix = ax.modifier.suffix ?? ''
return `${skillName} +${ax.strength}${suffix}`
}

View file

@ -3,7 +3,7 @@
*/
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 { 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
* Note: This is a placeholder until ax data structure is fully implemented
*/
export function getAxSkillImages(ax?: SimpleAxSkill[]): Array<{ url: string; alt: string }> {
// TODO: Implement when ax data reference is available
// This would need to map ax modifiers to actual ax skill data
return []
export function getAxSkillImages(ax?: AugmentSkill[]): Array<{ url: string; alt: string }> {
if (!ax || ax.length === 0) 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: 'マグナ' },
hasWeaponKeys: false,
hasAwakening: true,
hasAxSkills: true,
augmentType: 'ax' as const,
extra: false,
elementChangeable: false
};
@ -19,7 +19,7 @@ const mockOpusSeries = {
name: { en: 'Opus', ja: 'オプス' },
hasWeaponKeys: true,
hasAwakening: true,
hasAxSkills: false,
augmentType: 'none' as const,
extra: false,
elementChangeable: false
};
@ -30,7 +30,7 @@ const mockDraconicSeries = {
name: { en: 'Draconic', ja: 'ドラゴニック' },
hasWeaponKeys: true,
hasAwakening: false,
hasAxSkills: false,
augmentType: 'none' as const,
extra: false,
elementChangeable: false
};