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,178 +1,137 @@
<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>
<div class="ax-skill-select"> {#if isLoading}
<div class="ax-skill-select loading">
<div class="skeleton"></div>
</div>
{:else}
<div class="ax-skill-select">
<!-- Primary Skill --> <!-- Primary Skill -->
<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={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
}
}