hensei-web/src/lib/utils/jobUtils.ts

292 lines
7 KiB
TypeScript

/**
* 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'
import { getImageBaseUrl } from '$lib/api/adapters/config'
import { getGenericPlaceholder } from './images'
/**
* Gets the base path for images
* Returns AWS S3/CDN URL if configured, otherwise local /images path
*/
function getBasePath(): string {
const remoteUrl = getImageBaseUrl()
return remoteUrl || '/images'
}
/**
* 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 getGenericPlaceholder()
}
// 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 `${getBasePath()}/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 getGenericPlaceholder()
}
const genderSuffix = gender === Gender.Djeeta ? 'b' : 'a'
return `${getBasePath()}/job-zoom/${job.granblueId}_${genderSuffix}.png`
}
/**
* Generate job icon URL
* Job icons are small square icons representing the job
*/
export function getJobIconUrl(granblueId: string | undefined): string {
if (!granblueId) {
return getGenericPlaceholder()
}
return `${getBasePath()}/job-icons/${granblueId}.png`
}
/**
* Generate job wide banner image URL for JobItem component
* These are wider banner-style images stored in /static/images/job-wide/
*/
export function getJobWideImageUrl(job: Job | undefined, gender: Gender = Gender.Gran): string {
if (!job) {
return getGenericPlaceholder()
}
const genderSuffix = gender === Gender.Djeeta ? 'b' : 'a'
return `${getBasePath()}/job-wide/${job.granblueId}_${genderSuffix}.jpg`
}
/**
* Get job tier display name
* Converts internal row codes to user-friendly names
*/
export function getJobTierName(row: string | number): string {
const tierNames: Record<string, string> = {
'1': 'Class I',
'2': 'Class II',
'3': 'Class III',
'4': 'Class IV',
'5': 'Class V',
ex: 'EX',
ex1: 'EX',
ex2: 'EXII',
o1: 'Origin I'
}
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<string, number> = {
'1': 1,
'2': 2,
'3': 3,
'4': 4,
'5': 5,
ex: 6,
ex1: 6,
ex2: 7,
o1: 8
}
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 ? 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'
if (skill.emp) return 'EMP'
if (skill.base) 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, #ff6b6b)'
if (skill.sub) return 'var(--skill-sub, #4ecdc4)'
if (skill.emp) return 'var(--skill-emp, #45b7d1)'
if (skill.base) return 'var(--skill-base, #96ceb4)'
return 'var(--skill-default, #888)'
}
/**
* Format job proficiency for display
* Converts proficiency numbers to weapon type names
*/
export function formatJobProficiency(proficiency: [number, number]): string[] {
const weaponTypes: Record<number, string> = {
1: 'Sabre',
2: 'Dagger',
3: 'Axe',
4: 'Spear',
5: 'Bow',
6: 'Staff',
7: 'Melee',
8: 'Harp',
9: 'Gun',
10: 'Katana'
}
const result: string[] = []
const type1 = proficiency[0] ? weaponTypes[proficiency[0]] : undefined
if (type1) {
result.push(type1)
}
const type2 = proficiency[1] ? weaponTypes[proficiency[1]] : undefined
if (type2) {
result.push(type2)
}
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++
else if (skill.emp) counts.emp++
else if (skill.base) 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 && skills[3]) {
errors.push('Row I jobs only support 3 skill slots')
}
// Check for duplicate skills
const skillIds = new Set<string>()
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
}
}