rewrite ax skill components to use API

- add useWeaponStatModifiers composable
- rewrite AxSkillSelect to fetch from API
- add BefoulmentSelect for odiant weapons
This commit is contained in:
Justin Edmund 2025-12-31 00:16:43 -08:00
parent c612aeab74
commit 1d8a22c725
3 changed files with 486 additions and 172 deletions

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