From 4161a615ba2db26b06e1425b07e8d5ff94e21950 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 29 Sep 2025 23:45:50 -0700 Subject: [PATCH] add job system --- src/lib/api/adapters/job.adapter.ts | 220 +++++++++ .../adapters/resources/job.resource.svelte.ts | 370 +++++++++++++++ src/lib/components/job/JobItem.svelte | 150 ++++++ src/lib/components/job/JobPortrait.svelte | 200 ++++++++ src/lib/components/job/JobSection.svelte | 418 +++++++++++++++++ src/lib/components/job/JobSkillItem.svelte | 181 ++++++++ src/lib/components/job/JobSkillSlot.svelte | 251 ++++++++++ src/lib/components/job/JobTierSelector.svelte | 118 +++++ src/lib/components/party/Party.svelte | 225 ++++++++- .../sidebar/JobSelectionSidebar.svelte | 282 ++++++++++++ .../sidebar/JobSkillSelectionSidebar.svelte | 410 +++++++++++++++++ src/lib/data/overMastery.ts | 429 ++++++++++++++++++ src/lib/features/job/openJobSidebar.svelte.ts | 62 +++ src/lib/utils/jobUtils.ts | 268 +++++++++++ 14 files changed, 3583 insertions(+), 1 deletion(-) create mode 100644 src/lib/api/adapters/job.adapter.ts create mode 100644 src/lib/api/adapters/resources/job.resource.svelte.ts create mode 100644 src/lib/components/job/JobItem.svelte create mode 100644 src/lib/components/job/JobPortrait.svelte create mode 100644 src/lib/components/job/JobSection.svelte create mode 100644 src/lib/components/job/JobSkillItem.svelte create mode 100644 src/lib/components/job/JobSkillSlot.svelte create mode 100644 src/lib/components/job/JobTierSelector.svelte create mode 100644 src/lib/components/sidebar/JobSelectionSidebar.svelte create mode 100644 src/lib/components/sidebar/JobSkillSelectionSidebar.svelte create mode 100644 src/lib/data/overMastery.ts create mode 100644 src/lib/features/job/openJobSidebar.svelte.ts create mode 100644 src/lib/utils/jobUtils.ts diff --git a/src/lib/api/adapters/job.adapter.ts b/src/lib/api/adapters/job.adapter.ts new file mode 100644 index 00000000..4d6643d3 --- /dev/null +++ b/src/lib/api/adapters/job.adapter.ts @@ -0,0 +1,220 @@ +/** + * Job Adapter + * + * Handles all job-related API operations including fetching jobs, skills, and accessories. + * Provides a clean interface for job management with automatic + * request handling, caching, and error management. + * + * @module adapters/job + */ + +import { BaseAdapter } from './base.adapter' +import type { AdapterOptions } from './types' +import { DEFAULT_ADAPTER_CONFIG } from './config' +import type { Job, JobSkill, JobAccessory } from '$lib/types/api/entities' + +/** + * Parameters for searching job skills + */ +export interface SearchJobSkillsParams { + query?: string + jobId: string // Required for API + page?: number + per?: number +} + +/** + * Job skill search response + */ +export interface JobSkillSearchResponse { + results: JobSkill[] + total: number + page: number + totalPages: number + meta?: { + count: number + page: number + perPage: number + totalPages: number + } +} + +/** + * Job adapter for managing jobs and their related data + */ +export class JobAdapter extends BaseAdapter { + constructor(options?: AdapterOptions) { + super(options) + } + + /** + * Gets all available jobs + * Jobs are returned as a flat array from the API + */ + async getAll(): Promise { + const response = await this.request('/jobs', { + method: 'GET', + cacheTTL: 300000 // Cache for 5 minutes - jobs don't change often + }) + return response + } + + /** + * Gets a single job by ID + */ + async getById(id: string): Promise { + const response = await this.request<{ job: Job }>(`/jobs/${id}`, { + method: 'GET', + cacheTTL: 300000 // Cache for 5 minutes + }) + return response.job + } + + /** + * Gets all skills for a specific job + * Returns skills categorized by type (main, sub, emp, base) + */ + async getSkills(jobId: string): Promise { + const response = await this.request<{ skills: JobSkill[] }>(`/jobs/${jobId}/skills`, { + method: 'GET', + cacheTTL: 300000 // Cache for 5 minutes + }) + return response.skills + } + + /** + * Gets all accessories available for a specific job + * Only returns data if the job supports accessories + */ + async getAccessories(jobId: string): Promise { + const response = await this.request<{ accessories: JobAccessory[] }>( + `/jobs/${jobId}/accessories`, + { + method: 'GET', + cacheTTL: 300000 // Cache for 5 minutes + } + ) + return response.accessories + } + + /** + * Searches for job skills based on query and filters + * Used for the skill selection interface with pagination + */ + async searchSkills(params: SearchJobSkillsParams): Promise { + const response = await this.request<{ + results: JobSkill[] + meta?: { + count?: number + total_pages?: number + per_page?: number + } + }>('/search/job_skills', { + method: 'POST', + body: { + search: { + query: params.query || '', + job: params.jobId + }, + page: params.page || 1, + per: params.per || 50 + }, + cacheTTL: 60000 // Cache for 1 minute + }) + + // Transform the response to match the expected format + return { + results: response.results, + page: params.page || 1, + total: response.meta?.count || 0, + totalPages: response.meta?.total_pages || 1, + meta: response.meta + } + } + + /** + * Gets all available job skills (not filtered by job) + * Useful for browsing all skills + */ + async getAllSkills(): Promise { + const response = await this.request<{ skills: JobSkill[] }>('/jobs/skills', { + method: 'GET', + cacheTTL: 300000 // Cache for 5 minutes + }) + return response.skills + } + + /** + * Updates the job for a party + * @param partyId The party's ID (UUID) + * @param jobId The job ID to set + */ + async updatePartyJob(partyId: string, jobId: string): Promise { + await this.request(`/parties/${partyId}/jobs`, { + method: 'PUT', + body: { + job_id: jobId + } + }) + // Clear party cache to reflect the change + this.clearCache(`/parties/${partyId}`) + } + + /** + * Updates job skills for a party + * @param partyId The party's ID (UUID) + * @param skills Array of skill assignments with slot positions + */ + async updatePartyJobSkills( + partyId: string, + skills: Array<{ id: string; slot: number }> + ): Promise { + // Convert skills array to Rails expected format + const party: Record = {} + + // Initialize all slots with null + for (let i = 1; i <= 4; i++) { + party[`skill${i}_id`] = null + } + + // Set the provided skills + skills.forEach(skill => { + // Rails expects skill1_id, skill2_id, skill3_id, skill4_id + party[`skill${skill.slot + 1}_id`] = skill.id + }) + + await this.request(`/parties/${partyId}/job_skills`, { + method: 'PUT', + body: { party } + }) + // Clear party cache to reflect the change + this.clearCache(`/parties/${partyId}`) + } + + /** + * Removes a job skill from a party + * @param partyId The party's ID (UUID) + * @param slot The skill slot to clear (0-3) + */ + async removePartyJobSkill(partyId: string, slot: number): Promise { + await this.request(`/parties/${partyId}/job_skills`, { + method: 'DELETE', + body: { slot } + }) + // Clear party cache to reflect the change + this.clearCache(`/parties/${partyId}`) + } + + /** + * Clears the cache for job-related data + */ + clearJobCache() { + this.clearCache('/jobs') + this.clearCache('/search/job_skills') + } +} + +/** + * Default job adapter instance + */ +export const jobAdapter = new JobAdapter(DEFAULT_ADAPTER_CONFIG) \ No newline at end of file diff --git a/src/lib/api/adapters/resources/job.resource.svelte.ts b/src/lib/api/adapters/resources/job.resource.svelte.ts new file mode 100644 index 00000000..7bcff0e1 --- /dev/null +++ b/src/lib/api/adapters/resources/job.resource.svelte.ts @@ -0,0 +1,370 @@ +/** + * Reactive Job Resource using Svelte 5 Runes and Runed + * + * Provides reactive state management for job-related operations with + * automatic loading states, error handling, and caching. + * + * @module adapters/resources/job + */ + +import { JobAdapter, jobAdapter } from '../job.adapter' +import type { Job, JobSkill, JobAccessory } from '$lib/types/api/entities' +import type { AdapterError, AdapterOptions } from '../types' + +/** + * Job resource configuration options + */ +export interface JobResourceOptions { + /** Job adapter instance to use */ + adapter?: JobAdapter + /** Cache duration in milliseconds */ + cacheDuration?: number +} + +/** + * State for job data + */ +interface JobState { + data?: T + loading: boolean + error?: AdapterError + lastFetch?: number +} + +/** + * Creates a reactive job resource for job data management + * This is a Svelte 5 universal reactive state (works in both components and modules) + * + * @example + * ```svelte + * + * + * {#if jobResource.jobs.loading} + *

Loading jobs...

+ * {:else if jobResource.jobs.error} + *

Error: {jobResource.jobs.error.message}

+ * {:else if jobResource.jobs.data} + * {#each jobResource.jobs.data as job} + *
{job.name.en}
+ * {/each} + * {/if} + * ``` + */ +export class JobResource { + // Private adapter instance + private adapter: JobAdapter + private cacheDuration: number + + // Reactive state for job data + jobs = $state>({ loading: false }) + currentJob = $state>({ loading: false }) + jobSkills = $state>({ loading: false }) + jobAccessories = $state>({ loading: false }) + allSkills = $state>({ loading: false }) + + // Track active requests + private activeRequests = new Map() + + constructor(options: JobResourceOptions = {}) { + this.adapter = options.adapter || jobAdapter + this.cacheDuration = options.cacheDuration || 5 * 60 * 1000 // 5 minutes default + } + + /** + * Check if cached data is still valid + */ + private isCacheValid(state: JobState): boolean { + if (!state.data || !state.lastFetch) return false + return Date.now() - state.lastFetch < this.cacheDuration + } + + /** + * Fetch all jobs + */ + async fetchJobs(force = false): Promise { + // Return cached data if valid and not forced + if (!force && this.isCacheValid(this.jobs) && this.jobs.data) { + return this.jobs.data + } + + // Cancel any existing request + this.cancelRequest('jobs') + + // Create new abort controller + const controller = new AbortController() + this.activeRequests.set('jobs', controller) + + // Update loading state + this.jobs = { ...this.jobs, loading: true, error: undefined } + + try { + const data = await this.adapter.getAll() + this.jobs = { data, loading: false, lastFetch: Date.now() } + return data + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.jobs = { + ...this.jobs, + loading: false, + error: error as AdapterError + } + } + throw error + } finally { + this.activeRequests.delete('jobs') + } + } + + /** + * Fetch a single job by ID + */ + async fetchJob(id: string, force = false): Promise { + // Check if this job is already loaded + if (!force && this.currentJob.data?.id === id && this.isCacheValid(this.currentJob)) { + return this.currentJob.data + } + + // Cancel any existing request + this.cancelRequest('currentJob') + + // Create new abort controller + const controller = new AbortController() + this.activeRequests.set('currentJob', controller) + + // Update loading state + this.currentJob = { ...this.currentJob, loading: true, error: undefined } + + try { + const data = await this.adapter.getById(id) + this.currentJob = { data, loading: false, lastFetch: Date.now() } + return data + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.currentJob = { + ...this.currentJob, + loading: false, + error: error as AdapterError + } + } + throw error + } finally { + this.activeRequests.delete('currentJob') + } + } + + /** + * Fetch skills for a specific job + */ + async fetchJobSkills(jobId: string, force = false): Promise { + // Cancel any existing request + this.cancelRequest('jobSkills') + + // Create new abort controller + const controller = new AbortController() + this.activeRequests.set('jobSkills', controller) + + // Update loading state + this.jobSkills = { ...this.jobSkills, loading: true, error: undefined } + + try { + const data = await this.adapter.getSkills(jobId) + this.jobSkills = { data, loading: false, lastFetch: Date.now() } + return data + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.jobSkills = { + ...this.jobSkills, + loading: false, + error: error as AdapterError + } + } + throw error + } finally { + this.activeRequests.delete('jobSkills') + } + } + + /** + * Fetch accessories for a specific job + */ + async fetchJobAccessories(jobId: string, force = false): Promise { + // Cancel any existing request + this.cancelRequest('jobAccessories') + + // Create new abort controller + const controller = new AbortController() + this.activeRequests.set('jobAccessories', controller) + + // Update loading state + this.jobAccessories = { ...this.jobAccessories, loading: true, error: undefined } + + try { + const data = await this.adapter.getAccessories(jobId) + this.jobAccessories = { data, loading: false, lastFetch: Date.now() } + return data + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.jobAccessories = { + ...this.jobAccessories, + loading: false, + error: error as AdapterError + } + } + throw error + } finally { + this.activeRequests.delete('jobAccessories') + } + } + + /** + * Fetch all available job skills + */ + async fetchAllSkills(force = false): Promise { + // Return cached data if valid and not forced + if (!force && this.isCacheValid(this.allSkills) && this.allSkills.data) { + return this.allSkills.data + } + + // Cancel any existing request + this.cancelRequest('allSkills') + + // Create new abort controller + const controller = new AbortController() + this.activeRequests.set('allSkills', controller) + + // Update loading state + this.allSkills = { ...this.allSkills, loading: true, error: undefined } + + try { + const data = await this.adapter.getAllSkills() + this.allSkills = { data, loading: false, lastFetch: Date.now() } + return data + } catch (error: any) { + if (error.code !== 'CANCELLED') { + this.allSkills = { + ...this.allSkills, + loading: false, + error: error as AdapterError + } + } + throw error + } finally { + this.activeRequests.delete('allSkills') + } + } + + /** + * Update party job + */ + async updatePartyJob(partyId: string, jobId: string): Promise { + await this.adapter.updatePartyJob(partyId, jobId) + } + + /** + * Update party job skills + */ + async updatePartyJobSkills( + partyId: string, + skills: Array<{ id: string; slot: number }> + ): Promise { + await this.adapter.updatePartyJobSkills(partyId, skills) + } + + /** + * Remove party job skill + */ + async removePartyJobSkill(partyId: string, slot: number): Promise { + await this.adapter.removePartyJobSkill(partyId, slot) + } + + /** + * Cancel an active request + */ + private cancelRequest(key: string) { + const controller = this.activeRequests.get(key) + if (controller) { + controller.abort() + this.activeRequests.delete(key) + } + } + + /** + * Cancel all active requests + */ + cancelAll() { + this.activeRequests.forEach(controller => controller.abort()) + this.activeRequests.clear() + } + + /** + * Clear cached data + */ + clearCache() { + this.jobs = { loading: false } + this.currentJob = { loading: false } + this.jobSkills = { loading: false } + this.jobAccessories = { loading: false } + this.allSkills = { loading: false } + } +} + +/** + * Create a new job resource instance + */ +export function createJobResource(options?: JobResourceOptions): JobResource { + return new JobResource(options) +} + +/** + * Helper to group jobs by tier (row) + */ +export function groupJobsByTier(jobs: Job[]): Record { + const tiers: Record = { + '1': [], + '2': [], + '3': [], + '4': [], + '5': [], + 'ex': [], + 'ex2': [] + } + + for (const job of jobs) { + const tier = job.row.toString().toLowerCase() + if (tier in tiers) { + tiers[tier].push(job) + } + } + + // Sort jobs within each tier by order + for (const tier in tiers) { + tiers[tier].sort((a, b) => a.order - b.order) + } + + return tiers +} + +/** + * Helper to get tier display name + */ +export function getTierDisplayName(tier: string): string { + const tierNames: Record = { + '1': 'Class I', + '2': 'Class II', + '3': 'Class III', + '4': 'Class IV', + '5': 'Class V', + 'ex': 'Extra', + 'ex2': 'Extra II' + } + return tierNames[tier] || tier +} \ No newline at end of file diff --git a/src/lib/components/job/JobItem.svelte b/src/lib/components/job/JobItem.svelte new file mode 100644 index 00000000..476c899e --- /dev/null +++ b/src/lib/components/job/JobItem.svelte @@ -0,0 +1,150 @@ + + + + + diff --git a/src/lib/components/job/JobPortrait.svelte b/src/lib/components/job/JobPortrait.svelte new file mode 100644 index 00000000..5ad64a3e --- /dev/null +++ b/src/lib/components/job/JobPortrait.svelte @@ -0,0 +1,200 @@ + + +
+ {#if job} + {job.name.en} + {#if size !== 'small'} + {job.name.en} icon + {/if} + {:else if showPlaceholder} +
+ No Job +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/lib/components/job/JobSection.svelte b/src/lib/components/job/JobSection.svelte new file mode 100644 index 00000000..23ff2620 --- /dev/null +++ b/src/lib/components/job/JobSection.svelte @@ -0,0 +1,418 @@ + + +
+
+ {#if job} + {job.name.en} +
+ {:else} +
+ {#if canEdit} + + {:else} + No Job Selected + {/if} +
+ {/if} + + {#if canEdit && job} + + {/if} +
+ + +
+ {#if job} +
+ {#if canEdit} + + {:else} +
+ {job.name.en} icon +

{job.name.en}

+
+ {/if} + + {#if job.masterLevel || job.ultimateMastery} +
+ {#if job.masterLevel} + Master Lv.{job.masterLevel} + {/if} + {#if job.ultimateMastery} + Ultimate + {/if} +
+ {/if} +
+ +
+ {#each Array(4) as _, slot} + {#if isSkillSlotAvailable(job, slot)} + handleSelectSkill(slot)} + onRemove={() => handleRemoveSkill(slot)} + /> + {/if} + {/each} +
+ + {#if job.accessory} +
+
canEdit && onSelectAccessory?.()} + on:keydown={(e) => { + if (canEdit && onSelectAccessory && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onSelectAccessory() + } + }} + > + {#if accessory} + {accessory.name.en} + {accessory.name.en} + {:else} + + Select Accessory + {/if} +
+
+ {/if} + {:else} +
+

Select a job to view skills and details

+
+ {/if} +
+
+ + diff --git a/src/lib/components/job/JobSkillItem.svelte b/src/lib/components/job/JobSkillItem.svelte new file mode 100644 index 00000000..cc635499 --- /dev/null +++ b/src/lib/components/job/JobSkillItem.svelte @@ -0,0 +1,181 @@ + + + + {/if} + + + diff --git a/src/lib/components/job/JobSkillSlot.svelte b/src/lib/components/job/JobSkillSlot.svelte new file mode 100644 index 00000000..342fa33a --- /dev/null +++ b/src/lib/components/job/JobSkillSlot.svelte @@ -0,0 +1,251 @@ + + +{#if isEditable} +
+ + {#if allowsRemove} + + {/if} +
+{:else} +
+ {@render SlotBody({ locked })} +
+{/if} + +{#snippet SlotBody({ locked })} + {#if isFilled} + {@render SkillContent({ skill: skill!, skillIconUrl, locked })} + {:else if !isUnavailable} + {@render EmptyState({ slot })} + {:else} + {@render UnavailableState()} + {/if} +{/snippet} + +{#snippet SkillContent({ skill, skillIconUrl, locked })} +
+ {#if skillIconUrl} + {skill.name.en} + {/if} +
+ {skill.name.en} +
+ {#if locked} + + {#snippet children()} + + {/snippet} + + {/if} +
+{/snippet} + +{#snippet EmptyState({ slot })} +
+ + Slot {slot + 1} +
+{/snippet} + +{#snippet UnavailableState()} +
+ +
+{/snippet} + + diff --git a/src/lib/components/job/JobTierSelector.svelte b/src/lib/components/job/JobTierSelector.svelte new file mode 100644 index 00000000..fc696644 --- /dev/null +++ b/src/lib/components/job/JobTierSelector.svelte @@ -0,0 +1,118 @@ + + +
+
+ {#each firstRowTiers as tier (tier.value)} + + {/each} +
+
+ {#each secondRowTiers as tier (tier.value)} + + {/each} +
+
+ + diff --git a/src/lib/components/party/Party.svelte b/src/lib/components/party/Party.svelte index bcac1e8e..3383833c 100644 --- a/src/lib/components/party/Party.svelte +++ b/src/lib/components/party/Party.svelte @@ -19,6 +19,10 @@ import { openDescriptionSidebar } from '$lib/features/description/openDescriptionSidebar.svelte.ts' import { DropdownMenu } from 'bits-ui' import DropdownItem from '$lib/components/ui/dropdown/DropdownItem.svelte' + import JobSection from '$lib/components/job/JobSection.svelte' + import { Gender } from '$lib/utils/jobUtils' + import { openJobSelectionSidebar, openJobSkillSelectionSidebar } from '$lib/features/job/openJobSidebar.svelte' + import { partyAdapter } from '$lib/api/adapters/party.adapter' interface Props { party?: Party @@ -354,6 +358,197 @@ } } + // Handle job selection + async function handleSelectJob() { + if (!canEdit()) return + + openJobSelectionSidebar({ + currentJobId: party.job?.id, + onSelectJob: async (job) => { + loading = true + error = null + + try { + // Update job via API + const updated = await partyAdapter.updateJob(party.id, job.id) + party = updated + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update job' + console.error('Failed to update job:', e) + } finally { + loading = false + } + } + }) + } + + // Handle job skill selection + async function handleSelectJobSkill(slot: number) { + if (!canEdit()) return + + openJobSkillSelectionSidebar({ + job: party.job, + currentSkills: party.jobSkills, + targetSlot: slot, + onSelectSkill: async (skill) => { + loading = true + error = null + + try { + // Update skills with the new skill in the slot + const updatedSkills = { ...party.jobSkills } + updatedSkills[slot as keyof typeof updatedSkills] = skill + + console.log('[Party] Current jobSkills:', party.jobSkills) + console.log('[Party] Updated jobSkills object:', updatedSkills) + console.log('[Party] Slot being updated:', slot) + console.log('[Party] New skill:', skill) + + // Convert skills object to array format expected by API + const skillsArray = Object.entries(updatedSkills) + .filter(([_, skill]) => skill !== null && skill !== undefined) + .map(([slotKey, skill]) => ({ + id: skill!.id, + slot: parseInt(slotKey) + })) + + console.log('[Party] Skills array to send:', skillsArray) + + const updated = await partyAdapter.updateJobSkills( + party.id, + skillsArray + ) + party = updated + } catch (e: any) { + // Extract detailed error message from nested structure + let errorDetails = e?.details + + // Navigate through nested details structure + while (errorDetails?.details) { + errorDetails = errorDetails.details + } + + if (errorDetails?.errors) { + if (errorDetails.errors.message) { + // Simple message format + error = errorDetails.errors.message + } else { + // Field-based errors + const errorMessages = Object.entries(errorDetails.errors) + .map(([field, messages]) => { + if (Array.isArray(messages)) { + return messages.join(', ') + } + return String(messages) + }) + .join('; ') + error = errorMessages || e.message || 'Failed to update skill' + } + } else { + error = e?.message || 'Failed to update skill' + } + console.error('Failed to update skill:', e) + } finally { + loading = false + } + }, + onRemoveSkill: async () => { + loading = true + error = null + + try { + // Remove skill from slot + const updatedSkills = { ...party.jobSkills } + delete updatedSkills[slot as keyof typeof updatedSkills] + + console.log('[Party] Removing skill from slot:', slot) + console.log('[Party] Current jobSkills:', party.jobSkills) + console.log('[Party] Updated jobSkills after removal:', updatedSkills) + + // Convert skills object to array format expected by API + const skillsArray = Object.entries(updatedSkills) + .filter(([_, skill]) => skill !== null && skill !== undefined) + .map(([slotKey, skill]) => ({ + id: skill!.id, + slot: parseInt(slotKey) + })) + + console.log('[Party] Skills array to send after removal:', skillsArray) + + const updated = await partyAdapter.updateJobSkills( + party.id, + skillsArray + ) + party = updated + } catch (e: any) { + // Extract detailed error message from nested structure + let errorDetails = e?.details + + // Navigate through nested details structure + while (errorDetails?.details) { + errorDetails = errorDetails.details + } + + if (errorDetails?.errors) { + if (errorDetails.errors.message) { + // Simple message format + error = errorDetails.errors.message + } else { + // Field-based errors + const errorMessages = Object.entries(errorDetails.errors) + .map(([field, messages]) => { + if (Array.isArray(messages)) { + return messages.join(', ') + } + return String(messages) + }) + .join('; ') + error = errorMessages || e.message || 'Failed to remove skill' + } + } else { + error = e?.message || 'Failed to remove skill' + } + console.error('Failed to remove skill:', e) + } finally { + loading = false + } + } + }) + } + + // Handle removing a skill directly + async function handleRemoveJobSkill(slot: number) { + if (!canEdit()) return + + loading = true + error = null + + try { + // Remove skill from slot + const updatedSkills = { ...party.jobSkills } + delete updatedSkills[slot as keyof typeof updatedSkills] + + // Convert skills object to array format expected by API + const skillsArray = Object.entries(updatedSkills) + .filter(([_, skill]) => skill !== null && skill !== undefined) + .map(([slotKey, skill]) => ({ + id: skill!.id, + slot: parseInt(slotKey) + })) + + const updated = await partyAdapter.updateJobSkills( + party.id, + skillsArray + ) + party = updated + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to remove skill' + console.error('Failed to remove skill:', e) + } finally { + loading = false + } + } + // Handle adding items from the search sidebar async function handleAddItems(items: SearchResult[]) { if (items.length === 0 || !canEdit()) return @@ -863,7 +1058,29 @@ {:else if activeTab === GridType.Summon} {:else} - +
+ { + // TODO: Open accessory selection sidebar + console.log('Open accessory selection sidebar') + }} + /> + +
{/if} @@ -1181,6 +1398,12 @@ min-height: 400px; } + .character-tab-content { + display: flex; + flex-direction: column; + gap: $unit-2x; + } + // Edit form styles .edit-form { display: flex; diff --git a/src/lib/components/sidebar/JobSelectionSidebar.svelte b/src/lib/components/sidebar/JobSelectionSidebar.svelte new file mode 100644 index 00000000..74d64422 --- /dev/null +++ b/src/lib/components/sidebar/JobSelectionSidebar.svelte @@ -0,0 +1,282 @@ + + +
+
+ + + +
+ +
+ {#if loading} +
+ +

Loading jobs...

+
+ {:else if error} +
+ +

{error}

+ +
+ {:else if Object.keys(filteredJobs).length === 0} +
+ +

No jobs found

+ {#if searchQuery || selectedTiers.size > 0} + + {/if} +
+ {:else} +
+ {#each Object.entries(filteredJobs) as [tierName, jobs]} +
+
+

{tierName}

+
+
+ {#each jobs as job (job.id)} + handleSelectJob(job)} /> + {/each} +
+
+ {/each} +
+ {/if} +
+
+ + diff --git a/src/lib/components/sidebar/JobSkillSelectionSidebar.svelte b/src/lib/components/sidebar/JobSkillSelectionSidebar.svelte new file mode 100644 index 00000000..43a33a57 --- /dev/null +++ b/src/lib/components/sidebar/JobSkillSelectionSidebar.svelte @@ -0,0 +1,410 @@ + + + + +
+ {#if slotLocked && currentSkill} +
+ +

This slot cannot be changed

+
+ {/if} + + {#if currentSkill && !slotLocked} +
+

Current Skill

+ +
+ {/if} + + {#if error} +
+ +

{error}

+ +
+ {/if} + +
+ +
+ +
+ {#if !job} +
+ +

Select a job first

+
+ {:else if slotLocked} +
+ +

This slot cannot be changed

+
+ {:else if skillsResource?.loading} +
+ +

Loading skills...

+
+ {:else if skillsResource?.error} +
+ +

{skillsResource.error.message || 'Failed to load skills'}

+ +
+ {:else} +
+ {#each skillsResource?.items || [] as skill (skill.id)} + handleSelectSkill(skill)} + /> + {/each} + + {#if skillsResource?.isEmpty} +
+ +

No skills found

+ {#if searchQuery} + + {/if} +
+ {/if} + + {#if skillsResource?.hasMore && !skillsResource?.loadingMore} +
+ {/if} + + {#if skillsResource?.loadingMore} +
+ + Loading more skills... +
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/data/overMastery.ts b/src/lib/data/overMastery.ts new file mode 100644 index 00000000..b993843d --- /dev/null +++ b/src/lib/data/overMastery.ts @@ -0,0 +1,429 @@ +const overMasteryPrimary: ItemSkill[] = [ + { + name: { + en: 'ATK', + ja: '攻撃', + }, + id: 1, + granblue_id: '', + slug: 'atk', + minValue: 300, + maxValue: 3000, + suffix: '', + fractional: false, + values: [300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000], + }, + { + name: { + en: 'HP', + ja: 'HP', + }, + id: 2, + granblue_id: '', + slug: 'hp', + minValue: 150, + maxValue: 1500, + suffix: '', + fractional: false, + values: [150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500], + }, +] + +const overMasterySecondary: ItemSkill[] = [ + { + name: { + en: 'Debuff Success', + ja: '弱体成功率', + }, + id: 3, + granblue_id: '', + slug: 'debuff-success', + minValue: 6, + maxValue: 15, + suffix: '%', + fractional: false, + values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }, + { + name: { + en: 'Skill DMG Cap', + ja: 'アビダメ上限', + }, + id: 4, + granblue_id: '', + slug: 'skill-cap', + minValue: 6, + maxValue: 15, + suffix: '%', + fractional: false, + values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }, + { + name: { + en: 'C.A. DMG', + ja: '奥義ダメージ', + }, + id: 5, + granblue_id: '', + slug: 'ca-dmg', + minValue: 10, + maxValue: 30, + suffix: '%', + fractional: false, + values: [10, 12, 14, 16, 18, 20, 22, 24, 27, 30], + }, + { + name: { + en: 'C.A. DMG Cap', + ja: '奥義ダメージ上限', + }, + id: 6, + granblue_id: '', + slug: 'ca-cap', + minValue: 6, + maxValue: 15, + suffix: '%', + fractional: false, + values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }, + { + name: { + en: 'Stamina', + ja: '渾身', + }, + id: 7, + granblue_id: '', + slug: 'stamina', + minValue: 1, + maxValue: 10, + suffix: '', + fractional: false, + values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }, + { + name: { + en: 'Enmity', + ja: '背水', + }, + id: 8, + granblue_id: '', + slug: 'enmity', + minValue: 1, + maxValue: 10, + suffix: '', + fractional: false, + values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }, + { + name: { + en: 'Critical Hit', + ja: 'クリティカル確率', + }, + id: 9, + granblue_id: '', + slug: 'crit', + minValue: 10, + maxValue: 30, + suffix: '%', + fractional: false, + values: [10, 12, 14, 16, 18, 20, 22, 24, 27, 30], + }, +] + +const overMasteryTertiary: ItemSkill[] = [ + { + name: { + en: 'Double Attack', + ja: 'ダブルアタック確率', + }, + id: 10, + granblue_id: '', + slug: 'da', + minValue: 6, + maxValue: 15, + suffix: '%', + fractional: false, + values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }, + { + name: { + en: 'Triple Attack', + ja: 'トリプルアタック確率', + }, + id: 11, + granblue_id: '', + slug: 'ta', + minValue: 1, + maxValue: 10, + suffix: '%', + fractional: false, + values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }, + { + name: { + en: 'DEF', + ja: '防御', + }, + id: 12, + granblue_id: '', + slug: 'def', + minValue: 6, + maxValue: 20, + suffix: '%', + fractional: false, + values: [6, 7, 8, 9, 10, 12, 14, 16, 18, 20], + }, + { + name: { + en: 'Healing', + ja: '回復性能', + }, + id: 13, + granblue_id: '', + slug: 'heal', + minValue: 3, + maxValue: 30, + suffix: '%', + fractional: false, + values: [3, 6, 9, 12, 15, 18, 21, 24, 27, 30], + }, + { + name: { + en: 'Debuff Resistance', + ja: '弱体耐性', + }, + id: 14, + granblue_id: '', + slug: 'debuff-resist', + minValue: 6, + maxValue: 15, + suffix: '%', + fractional: false, + values: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + }, + { + name: { + en: 'Dodge', + ja: '回避', + }, + id: 15, + granblue_id: '', + slug: 'dodge', + minValue: 1, + maxValue: 10, + suffix: '%', + fractional: false, + values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + }, +] + +export const overMastery = { + a: overMasteryPrimary, + b: overMasterySecondary, + c: overMasteryTertiary, +} + +export const aetherialMastery: ItemSkill[] = [ + { + name: { + en: 'Double Attack', + ja: 'ダブルアタック確率', + }, + id: 1, + granblue_id: '', + slug: 'da', + minValue: 10, + maxValue: 17, + suffix: '%', + fractional: false, + }, + { + name: { + en: 'Triple Attack', + ja: 'トリプルアタック確率', + }, + id: 2, + granblue_id: '', + slug: 'ta', + minValue: 5, + maxValue: 12, + suffix: '%', + fractional: false, + }, + { + name: { + en: '{Element} ATK Up', + ja: '{属性}攻撃', + }, + id: 3, + granblue_id: '', + slug: 'element-atk', + minValue: 15, + maxValue: 22, + suffix: '%', + fractional: false, + }, + { + name: { + en: '{Element} Resistance', + ja: '{属性}軽減', + }, + id: 4, + granblue_id: '', + slug: 'element-resist', + minValue: 5, + maxValue: 12, + suffix: '%', + fractional: false, + }, + { + name: { + en: 'Stamina', + ja: '渾身', + }, + id: 5, + granblue_id: '', + slug: 'stamina', + minValue: 5, + maxValue: 12, + suffix: '', + fractional: false, + }, + { + name: { + en: 'Enmity', + ja: '背水', + }, + id: 6, + granblue_id: '', + slug: 'enmity', + minValue: 5, + maxValue: 12, + suffix: '', + fractional: false, + }, + { + name: { + en: 'Supplemental DMG', + ja: '与ダメ上昇', + }, + id: 7, + granblue_id: '', + slug: 'supplemental', + minValue: 5, + maxValue: 12, + suffix: '', + fractional: false, + }, + { + name: { + en: 'Critical Hit', + ja: 'クリティカル', + }, + id: 8, + granblue_id: '', + slug: 'crit', + minValue: 18, + maxValue: 35, + suffix: '%', + fractional: false, + }, + { + name: { + en: 'Counters on Dodge', + ja: 'カウンター(回避)', + }, + id: 9, + granblue_id: '', + slug: 'counter-dodge', + minValue: 5, + maxValue: 12, + suffix: '%', + fractional: false, + }, + { + name: { + en: 'Counters on DMG', + ja: 'カウンター(被ダメ)', + }, + id: 10, + granblue_id: '', + slug: 'counter-dmg', + minValue: 10, + maxValue: 17, + suffix: '%', + fractional: false, + }, +] + +export const permanentMastery: ItemSkill[] = [ + { + name: { + en: 'Extended Mastery Star Cap', + ja: 'LB強化回数上限', + }, + id: 1, + granblue_id: '', + slug: 'star-cap', + minValue: 10, + maxValue: 10, + suffix: '', + fractional: false, + }, + { + name: { + en: 'ATK', + ja: '攻撃', + }, + id: 2, + granblue_id: '', + slug: 'atk', + minValue: 10, + maxValue: 10, + suffix: '%', + fractional: false, + }, + { + name: { + en: 'HP', + ja: 'HP', + }, + id: 3, + granblue_id: '', + slug: 'hp', + minValue: 10, + maxValue: 10, + suffix: '', + fractional: false, + }, + { + name: { + en: 'DMG Cap', + ja: 'ダメージ上限', + }, + id: 4, + granblue_id: '', + slug: 'dmg-cap', + minValue: 5, + maxValue: 5, + suffix: '%', + fractional: false, + }, +] + +// Type for ItemSkill +export interface ItemSkill { + name: { + en: string + ja: string + } + id: number + granblue_id: string + slug: string + minValue: number + maxValue: number + suffix: string + fractional: boolean + values?: number[] +} + diff --git a/src/lib/features/job/openJobSidebar.svelte.ts b/src/lib/features/job/openJobSidebar.svelte.ts new file mode 100644 index 00000000..c001fdc0 --- /dev/null +++ b/src/lib/features/job/openJobSidebar.svelte.ts @@ -0,0 +1,62 @@ +import { sidebar } from '$lib/stores/sidebar.svelte' +import JobSelectionSidebar from '$lib/components/sidebar/JobSelectionSidebar.svelte' +import JobSkillSelectionSidebar from '$lib/components/sidebar/JobSkillSelectionSidebar.svelte' +import type { Job, JobSkill } from '$lib/types/api/entities' +import type { JobSkillList } from '$lib/types/api/party' + +interface JobSelectionOptions { + currentJobId?: string + onSelectJob?: (job: Job) => void +} + +interface JobSkillSelectionOptions { + job?: Job + currentSkills?: JobSkillList + targetSlot: number + onSelectSkill?: (skill: JobSkill) => void + onRemoveSkill?: () => void +} + +export function openJobSelectionSidebar(options: JobSelectionOptions) { + const { currentJobId, onSelectJob } = options + + sidebar.openWithComponent( + 'Select Job', + JobSelectionSidebar, + { + currentJobId, + onSelectJob: (job: Job) => { + onSelectJob?.(job) + sidebar.close() + } + }, + false // scrollable = false + ) +} + +export function openJobSkillSelectionSidebar(options: JobSkillSelectionOptions) { + const { job, currentSkills, targetSlot, onSelectSkill, onRemoveSkill } = options + + sidebar.openWithComponent( + `Select Skill - Slot ${targetSlot + 1}`, + JobSkillSelectionSidebar, + { + job, + currentSkills, + targetSlot, + onSelectSkill: (skill: JobSkill) => { + onSelectSkill?.(skill) + sidebar.close() + }, + onRemoveSkill: () => { + onRemoveSkill?.() + sidebar.close() + } + }, + false // scrollable = false + ) +} + +export function closeJobSidebar() { + sidebar.close() +} \ No newline at end of file diff --git a/src/lib/utils/jobUtils.ts b/src/lib/utils/jobUtils.ts new file mode 100644 index 00000000..cac07db0 --- /dev/null +++ b/src/lib/utils/jobUtils.ts @@ -0,0 +1,268 @@ +/** + * Job Utility Functions + * + * Helper functions for job-related operations including + * image URL generation, tier naming, and validation. + */ + +import type { Job, JobSkill } from '$lib/types/api/entities' +import type { JobSkillList } from '$lib/types/api/party' + +/** + * Gender options for job portraits + */ +export enum Gender { + Gran = 0, // Male protagonist (a) + Djeeta = 1 // Female protagonist (b) +} + +/** + * Generate job portrait URL for protagonist slot (CharacterRep/CharacterUnit) + * These are smaller portrait images stored in /static/images/job-portraits/ + */ +export function getJobPortraitUrl(job: Job | undefined, gender: Gender = Gender.Gran): string { + if (!job) { + return '/images/placeholders/placeholder-weapon-grid.png' + } + + // Convert job name to slug format (lowercase, spaces to hyphens) + const slug = job.name.en.toLowerCase().replace(/\s+/g, '-') + const genderSuffix = gender === Gender.Djeeta ? 'b' : 'a' + + return `/images/job-portraits/${slug}_${genderSuffix}.png` +} + +/** + * Generate full job image URL for JobSection component + * These are full job images stored in /static/images/jobs/ + */ +export function getJobFullImageUrl(job: Job | undefined, gender: Gender = Gender.Gran): string { + if (!job) { + return '/images/placeholders/placeholder-weapon-grid.png' + } + + // Convert job name to slug format (lowercase, spaces to hyphens) + const slug = job.name.en.toLowerCase().replace(/\s+/g, '-') + const genderSuffix = gender === Gender.Djeeta ? 'b' : 'a' + + return `/images/jobs/${slug}_${genderSuffix}.png` +} + +/** + * Generate job icon URL + * Job icons are small square icons representing the job + * Images are stored locally in /static/images/job-icons/ + */ +export function getJobIconUrl(granblueId: string | undefined): string { + if (!granblueId) { + return '/images/placeholders/placeholder-weapon-grid.png' + } + + return `/images/job-icons/${granblueId}.png` +} + +/** + * Get job tier display name + * Converts internal row codes to user-friendly names + */ +export function getJobTierName(row: string | number): string { + const tierNames: Record = { + '1': 'Class I', + '2': 'Class II', + '3': 'Class III', + '4': 'Class IV', + '5': 'Class V', + ex: 'Extra', + ex2: 'Extra II' + } + + const rowStr = row.toString().toLowerCase() + return tierNames[rowStr] || `Class ${row}` +} + +/** + * Get job tier order for sorting + * Returns a numeric value for sorting tiers + */ +export function getJobTierOrder(row: string | number): number { + const tierOrder: Record = { + '1': 1, + '2': 2, + '3': 3, + '4': 4, + '5': 5, + ex: 6, + ex2: 7 + } + + const rowStr = row.toString().toLowerCase() + return tierOrder[rowStr] || 99 +} + +/** + * Check if a job supports accessories + */ +export function jobSupportsAccessories(job: Job | undefined): boolean { + return job?.accessory === true +} + +/** + * Get the number of skill slots for a job + * Row 1 jobs have 3 slots, all others have 4 + */ +export function getJobSkillSlotCount(job: Job | undefined): number { + if (!job) return 0 + return job.row === 1 || job.row === '1' ? 3 : 4 +} + +/** + * Check if a skill slot is available for a job + */ +export function isSkillSlotAvailable(job: Job | undefined, slot: number): boolean { + if (!job) return false + const slotCount = getJobSkillSlotCount(job) + return slot >= 0 && slot < slotCount +} + +/** + * Check if a skill slot is locked (cannot be changed) + * Slot 0 is locked when it contains a main skill + */ +export function isSkillSlotLocked( + slot: number, + job: Job | undefined, + jobSkills: JobSkillList | undefined +): boolean { + // Slot 0 is locked if it contains a main skill + return slot === 0 && jobSkills?.['0']?.main === true +} + +/** + * Get skill category display name + */ +export function getSkillCategoryName(skill: JobSkill): string { + if (skill.main) return 'Main' + if (skill.sub) return 'Subskill' + // Use category field for additional classification + if (skill.category === 2) return 'EMP' + if (skill.category === 1) return 'Base' + return 'Unknown' +} + +/** + * Get skill category color + * Returns CSS color variable name + */ +export function getSkillCategoryColor(skill: JobSkill): string { + if (skill.main) return 'var(--skill-main)' + if (skill.sub) return 'var(--skill-sub)' + // Use category field for additional classification + if (skill.category === 2) return 'var(--skill-emp)' + if (skill.category === 1) return 'var(--skill-base)' + return 'var(--skill-default)' +} + +/** + * Format job proficiency for display + * Converts proficiency numbers to weapon type names + */ +export function formatJobProficiency(proficiency: [number, number]): string[] { + const weaponTypes: Record = { + 1: 'Sword', + 2: 'Dagger', + 3: 'Spear', + 4: 'Axe', + 5: 'Staff', + 6: 'Gun', + 7: 'Melee', + 8: 'Bow', + 9: 'Harp', + 10: 'Katana' + } + + const result: string[] = [] + if (proficiency[0] && weaponTypes[proficiency[0]]) { + result.push(weaponTypes[proficiency[0]]) + } + if (proficiency[1] && weaponTypes[proficiency[1]]) { + result.push(weaponTypes[proficiency[1]]) + } + + return result +} + +/** + * Check if a job is an advanced job (Row IV, V, or Extra II) + */ +export function isAdvancedJob(job: Job): boolean { + const row = job.row.toString().toLowerCase() + return row === '4' || row === '5' || row === 'ex2' +} + +/** + * Count skills by type in current skill list + */ +export function countSkillsByType(skills: JobSkillList): { + main: number + sub: number + emp: number + base: number +} { + const counts = { main: 0, sub: 0, emp: 0, base: 0 } + + for (let i = 0; i < 4; i++) { + const skill = skills[i as keyof JobSkillList] + if (skill) { + if (skill.main) counts.main++ + else if (skill.sub) counts.sub++ + // Use category field for additional classification + else if (skill.category === 2) counts.emp++ + else if (skill.category === 1) counts.base++ + } + } + + return counts +} + +/** + * Validate if a skill configuration is valid for a job + */ +export function validateSkillConfiguration( + job: Job, + skills: JobSkillList +): { valid: boolean; errors: string[] } { + const errors: string[] = [] + const counts = countSkillsByType(skills) + + // Check for advanced job constraints + if (isAdvancedJob(job)) { + if (counts.sub > 2) { + errors.push('Maximum 2 subskills allowed for advanced jobs') + } + if (counts.emp > 2) { + errors.push('Maximum 2 EMP skills allowed for advanced jobs') + } + } + + // Check for Row 1 constraint + if ((job.row === 1 || job.row === '1') && skills[3]) { + errors.push('Row I jobs only support 3 skill slots') + } + + // Check for duplicate skills + const skillIds = new Set() + for (let i = 0; i < 4; i++) { + const skill = skills[i as keyof JobSkillList] + if (skill) { + if (skillIds.has(skill.id)) { + errors.push(`Duplicate skill: ${skill.name.en}`) + } + skillIds.add(skill.id) + } + } + + return { + valid: errors.length === 0, + errors + } +}