Add new services for business logic

This commit is contained in:
Justin Edmund 2025-09-11 10:46:29 -07:00
parent cf351ef1fc
commit 32c1c9016e
3 changed files with 856 additions and 0 deletions

View file

@ -0,0 +1,192 @@
import type { Party, GridWeapon, GridCharacter } from '$lib/api/schemas/party'
import type { FetchLike } from '$lib/api/core'
import * as partiesApi from '$lib/api/resources/parties'
export interface ConflictData {
conflicts: string[]
incoming: string
position: number
}
export interface ConflictResolution {
action: 'replace' | 'cancel'
removeIds: string[]
addId: string
position: number
}
/**
* Conflict service - handles conflict resolution for weapons and characters
*/
export class ConflictService {
constructor(private fetch: FetchLike) {}
/**
* Resolve a conflict by choosing which items to keep
*/
async resolveConflict(
partyId: string,
conflictType: 'weapon' | 'character',
resolution: ConflictResolution,
editKey?: string
): Promise<Party> {
const headers = this.buildHeaders(editKey)
if (conflictType === 'weapon') {
return this.resolveWeaponConflict(partyId, resolution, headers)
} else {
return this.resolveCharacterConflict(partyId, resolution, headers)
}
}
/**
* Check if adding an item would cause conflicts
*/
checkConflicts(
party: Party,
itemType: 'weapon' | 'character',
itemId: string
): ConflictData | null {
if (itemType === 'weapon') {
return this.checkWeaponConflicts(party, itemId)
} else {
return this.checkCharacterConflicts(party, itemId)
}
}
/**
* Format conflict message for display
*/
formatConflictMessage(
conflictType: 'weapon' | 'character',
conflictingItems: Array<{ name: string; position: number }>,
incomingItem: { name: string }
): string {
const itemTypeLabel = conflictType === 'weapon' ? 'weapon' : 'character'
const conflictNames = conflictingItems.map(i => i.name).join(', ')
if (conflictingItems.length === 1) {
return `Adding ${incomingItem.name} would conflict with ${conflictNames}. Which ${itemTypeLabel} would you like to keep?`
}
return `Adding ${incomingItem.name} would conflict with: ${conflictNames}. Which ${itemTypeLabel}s would you like to keep?`
}
// Private methods
private async resolveWeaponConflict(
partyId: string,
resolution: ConflictResolution,
headers: Record<string, string>
): Promise<Party> {
// Build payload to remove conflicting weapons and add the new one
const payload = {
weapons: [
// Remove conflicting weapons
...resolution.removeIds.map(id => ({
id,
_destroy: true
})),
// Add the new weapon
{
weaponId: resolution.addId,
position: resolution.position,
uncapLevel: 0,
transcendenceLevel: 0
}
]
}
return partiesApi.updateWeaponGrid(this.fetch, partyId, payload, headers)
}
private async resolveCharacterConflict(
partyId: string,
resolution: ConflictResolution,
headers: Record<string, string>
): Promise<Party> {
// Build payload to remove conflicting characters and add the new one
const payload = {
characters: [
// Remove conflicting characters
...resolution.removeIds.map(id => ({
id,
_destroy: true
})),
// Add the new character
{
characterId: resolution.addId,
position: resolution.position,
uncapLevel: 0,
transcendenceLevel: 0
}
]
}
return partiesApi.updateCharacterGrid(this.fetch, partyId, payload, headers)
}
private checkWeaponConflicts(party: Party, weaponId: string): ConflictData | null {
// Check for duplicate weapons (simplified - actual logic would be more complex)
const existingWeapon = party.weapons.find(w => w.weapon.id === weaponId)
if (existingWeapon) {
return {
conflicts: [existingWeapon.id],
incoming: weaponId,
position: existingWeapon.position
}
}
// Could check for other conflict types here (e.g., same series weapons)
return null
}
private checkCharacterConflicts(party: Party, characterId: string): ConflictData | null {
// Check for duplicate characters
const existingCharacter = party.characters.find(c => c.character.id === characterId)
if (existingCharacter) {
return {
conflicts: [existingCharacter.id],
incoming: characterId,
position: existingCharacter.position
}
}
// Check for conflicts with other versions of the same character
// This would need character metadata to determine conflicts
return null
}
private buildHeaders(editKey?: string): Record<string, string> {
const headers: Record<string, string> = {}
if (editKey) {
headers['X-Edit-Key'] = editKey
}
return headers
}
/**
* Get conflict constraints for a specific type
*/
getConflictConstraints(itemType: 'weapon' | 'character'): {
allowDuplicates: boolean
maxPerType?: number
checkVariants: boolean
} {
if (itemType === 'weapon') {
return {
allowDuplicates: false,
checkVariants: true // Check for same series weapons
}
}
return {
allowDuplicates: false,
checkVariants: true // Check for different versions of same character
}
}
}

View file

@ -0,0 +1,450 @@
import type { Party, GridWeapon, GridSummon, GridCharacter } from '$lib/api/schemas/party'
import * as partiesApi from '$lib/api/resources/parties'
import type { FetchLike } from '$lib/api/core'
export interface GridOperation {
type: 'add' | 'replace' | 'remove' | 'move' | 'swap'
itemId?: string
position?: number
targetPosition?: number
uncapLevel?: number
transcendenceLevel?: number
data?: any
}
export interface GridUpdateResult {
party: Party
conflicts?: {
conflicts: string[]
incoming: string
position: number
}
}
/**
* Grid service - handles grid operations for weapons, summons, and characters
*/
export class GridService {
constructor(private fetch: FetchLike) {}
// Weapon Grid Operations
async addWeapon(
partyId: string,
weaponId: string,
position: number,
editKey?: string
): Promise<GridUpdateResult> {
const payload = {
weaponId,
position,
uncapLevel: 0,
transcendenceLevel: 0
}
try {
const party = await partiesApi.updateWeaponGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
return { party }
} catch (error: any) {
if (error.type === 'conflict') {
return {
party: null as any, // Will be handled by conflict resolution
conflicts: error
}
}
throw error
}
}
async replaceWeapon(
partyId: string,
gridWeaponId: string,
newWeaponId: string,
editKey?: string
): Promise<GridUpdateResult> {
const payload = {
id: gridWeaponId,
weaponId: newWeaponId
}
try {
const party = await partiesApi.updateWeaponGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
return { party }
} catch (error: any) {
if (error.type === 'conflict') {
return {
party: null as any,
conflicts: error
}
}
throw error
}
}
async removeWeapon(
partyId: string,
gridWeaponId: string,
editKey?: string
): Promise<Party> {
const payload = {
id: gridWeaponId,
_destroy: true
}
return partiesApi.updateWeaponGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async moveWeapon(
partyId: string,
gridWeaponId: string,
newPosition: number,
editKey?: string
): Promise<Party> {
const payload = {
id: gridWeaponId,
position: newPosition
}
return partiesApi.updateWeaponGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async swapWeapons(
partyId: string,
gridWeaponId1: string,
gridWeaponId2: string,
editKey?: string
): Promise<Party> {
const payload = {
swap: [gridWeaponId1, gridWeaponId2]
}
return partiesApi.updateWeaponGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async updateWeaponUncap(
partyId: string,
gridWeaponId: string,
uncapLevel: number,
transcendenceLevel: number,
editKey?: string
): Promise<Party> {
const payload = {
id: gridWeaponId,
uncapLevel,
transcendenceLevel
}
// Set uncap to 6 when transcending
if (transcendenceLevel > 0 && uncapLevel < 6) {
payload.uncapLevel = 6
}
return partiesApi.updateWeaponGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
// Summon Grid Operations
async addSummon(
partyId: string,
summonId: string,
position: number,
editKey?: string
): Promise<Party> {
const payload = {
summonId,
position,
uncapLevel: 0,
transcendenceLevel: 0
}
return partiesApi.updateSummonGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async replaceSummon(
partyId: string,
gridSummonId: string,
newSummonId: string,
editKey?: string
): Promise<Party> {
const payload = {
id: gridSummonId,
summonId: newSummonId
}
return partiesApi.updateSummonGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async removeSummon(
partyId: string,
gridSummonId: string,
editKey?: string
): Promise<Party> {
const payload = {
id: gridSummonId,
_destroy: true
}
return partiesApi.updateSummonGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async updateSummonUncap(
partyId: string,
gridSummonId: string,
uncapLevel: number,
transcendenceLevel: number,
editKey?: string
): Promise<Party> {
const payload = {
id: gridSummonId,
uncapLevel,
transcendenceLevel
}
// Set uncap to 6 when transcending
if (transcendenceLevel > 0 && uncapLevel < 6) {
payload.uncapLevel = 6
}
return partiesApi.updateSummonGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
// Character Grid Operations
async addCharacter(
partyId: string,
characterId: string,
position: number,
editKey?: string
): Promise<GridUpdateResult> {
const payload = {
characterId,
position,
uncapLevel: 0,
transcendenceLevel: 0
}
try {
const party = await partiesApi.updateCharacterGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
return { party }
} catch (error: any) {
if (error.type === 'conflict') {
return {
party: null as any,
conflicts: error
}
}
throw error
}
}
async replaceCharacter(
partyId: string,
gridCharacterId: string,
newCharacterId: string,
editKey?: string
): Promise<GridUpdateResult> {
const payload = {
id: gridCharacterId,
characterId: newCharacterId
}
try {
const party = await partiesApi.updateCharacterGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
return { party }
} catch (error: any) {
if (error.type === 'conflict') {
return {
party: null as any,
conflicts: error
}
}
throw error
}
}
async removeCharacter(
partyId: string,
gridCharacterId: string,
editKey?: string
): Promise<Party> {
const payload = {
id: gridCharacterId,
_destroy: true
}
return partiesApi.updateCharacterGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
async updateCharacterUncap(
partyId: string,
gridCharacterId: string,
uncapLevel: number,
transcendenceLevel: number,
perpetuity: boolean,
editKey?: string
): Promise<Party> {
const payload = {
id: gridCharacterId,
uncapLevel,
transcendenceLevel,
perpetuity
}
return partiesApi.updateCharacterGrid(
this.fetch,
partyId,
payload,
this.buildHeaders(editKey)
)
}
// Drag and Drop Helpers
/**
* Normalize drag and drop intent to a grid operation
*/
normalizeDragIntent(
dragType: 'weapon' | 'summon' | 'character',
draggedItem: any,
targetPosition: number,
targetItem?: any
): GridOperation {
// If dropping on an empty slot
if (!targetItem) {
return {
type: 'add',
itemId: draggedItem.id,
position: targetPosition
}
}
// If dragging from grid to grid
if (draggedItem.gridId && targetItem.gridId) {
return {
type: 'swap',
itemId: draggedItem.gridId,
targetPosition: targetItem.gridId
}
}
// If dragging from outside to occupied slot
return {
type: 'replace',
itemId: targetItem.gridId,
targetPosition: draggedItem.id
}
}
/**
* Apply optimistic update to local state
*/
applyOptimisticUpdate<T extends GridWeapon | GridSummon | GridCharacter>(
items: T[],
operation: GridOperation
): T[] {
const updated = [...items]
switch (operation.type) {
case 'add':
// Add new item at position
break
case 'remove':
return updated.filter(item => item.id !== operation.itemId)
case 'move':
const item = updated.find(i => i.id === operation.itemId)
if (item && operation.targetPosition !== undefined) {
item.position = operation.targetPosition
}
break
case 'swap':
const item1 = updated.find(i => i.id === operation.itemId)
const item2 = updated.find(i => i.id === operation.targetPosition)
if (item1 && item2) {
const tempPos = item1.position
item1.position = item2.position
item2.position = tempPos
}
break
}
return updated
}
// Private helpers
private buildHeaders(editKey?: string): Record<string, string> {
const headers: Record<string, string> = {}
if (editKey) {
headers['X-Edit-Key'] = editKey
}
return headers
}
}

View file

@ -0,0 +1,214 @@
import type { Party } from '$lib/api/schemas/party'
import * as partiesApi from '$lib/api/resources/parties'
import type { FetchLike } from '$lib/api/core'
export interface EditabilityResult {
canEdit: boolean
headers?: Record<string, string>
reason?: string
}
export interface PartyUpdatePayload {
name?: string | null
description?: string | null
raidId?: string
chargeAttack?: boolean
fullAuto?: boolean
autoGuard?: boolean
autoSummon?: boolean
clearTime?: number | null
buttonCount?: number | null
chainCount?: number | null
turnCount?: number | null
jobId?: string
visibility?: number
localId?: string
}
/**
* Party service - handles business logic for party operations
*/
export class PartyService {
constructor(private fetch: FetchLike) {}
/**
* Get party by shortcode
*/
async getByShortcode(shortcode: string): Promise<Party> {
return partiesApi.getByShortcode(this.fetch, shortcode)
}
/**
* Create a new party
*/
async create(payload: PartyUpdatePayload, editKey?: string): Promise<Party> {
const headers = this.buildHeaders(editKey)
const apiPayload = this.mapToApiPayload(payload)
return partiesApi.create(this.fetch, apiPayload, headers)
}
/**
* Update party details
*/
async update(id: string, payload: PartyUpdatePayload, editKey?: string): Promise<Party> {
const headers = this.buildHeaders(editKey)
const apiPayload = this.mapToApiPayload(payload)
return partiesApi.update(this.fetch, id, apiPayload, headers)
}
/**
* Update party guidebooks
*/
async updateGuidebooks(
id: string,
position: number,
guidebookId: string | null,
editKey?: string
): Promise<Party> {
const headers = this.buildHeaders(editKey)
const payload: any = {}
// Map position to guidebook1_id, guidebook2_id, guidebook3_id
if (position >= 0 && position <= 2) {
payload[`guidebook${position + 1}Id`] = guidebookId
}
return partiesApi.update(this.fetch, id, payload, headers)
}
/**
* Remix a party (create a copy)
*/
async remix(shortcode: string, localId?: string, editKey?: string): Promise<{
party: Party
editKey?: string
}> {
const headers = this.buildHeaders(editKey)
const result = await partiesApi.remix(this.fetch, shortcode, localId, headers)
// Store edit key if returned
if (result.editKey && typeof window !== 'undefined') {
localStorage.setItem(`edit_key_${result.party.shortcode}`, result.editKey)
}
return result
}
/**
* Favorite a party
*/
async favorite(id: string): Promise<void> {
return partiesApi.favorite(this.fetch, id)
}
/**
* Unfavorite a party
*/
async unfavorite(id: string): Promise<void> {
return partiesApi.unfavorite(this.fetch, id)
}
/**
* Delete a party
*/
async delete(id: string, editKey?: string): Promise<void> {
const headers = this.buildHeaders(editKey)
return partiesApi.deleteParty(this.fetch, id, headers)
}
/**
* Compute editability for a party
*/
computeEditability(
party: Party,
authUserId?: string,
localId?: string,
editKey?: string
): EditabilityResult {
// Owner can always edit
if (authUserId && party.user?.id === authUserId) {
return { canEdit: true, reason: 'owner' }
}
// Local owner can edit if no server user
const isLocalOwner = localId && party.localId === localId
const hasNoServerUser = !party.user?.id
if (isLocalOwner && hasNoServerUser) {
const base = { canEdit: true, reason: 'local_owner' as const }
return editKey ? { ...base, headers: { 'X-Edit-Key': editKey } } : base
}
// Check for edit key permission
if (editKey && typeof window !== 'undefined') {
const storedKey = localStorage.getItem(`edit_key_${party.shortcode}`)
if (storedKey === editKey) {
return { canEdit: true, headers: { 'X-Edit-Key': editKey }, reason: 'edit_key' }
}
}
return { canEdit: false, reason: 'no_permission' }
}
/**
* Get or create local ID for anonymous users
*/
getLocalId(): string {
if (typeof window === 'undefined') return ''
let localId = localStorage.getItem('local_id')
if (!localId) {
localId = crypto.randomUUID()
localStorage.setItem('local_id', localId)
}
return localId
}
/**
* Get edit key for a party
*/
getEditKey(shortcode: string): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(`edit_key_${shortcode}`)
}
/**
* Store edit key for a party
*/
storeEditKey(shortcode: string, editKey: string): void {
if (typeof window !== 'undefined') {
localStorage.setItem(`edit_key_${shortcode}`, editKey)
}
}
// Private helpers
private buildHeaders(editKey?: string): Record<string, string> {
const headers: Record<string, string> = {}
if (editKey) {
headers['X-Edit-Key'] = editKey
}
return headers
}
private mapToApiPayload(payload: PartyUpdatePayload): Partial<Party> {
const mapped: any = {}
if (payload.name !== undefined) mapped.name = payload.name
if (payload.description !== undefined) mapped.description = payload.description
if (payload.raidId !== undefined) mapped.raid = { id: payload.raidId }
if (payload.chargeAttack !== undefined) mapped.chargeAttack = payload.chargeAttack
if (payload.fullAuto !== undefined) mapped.fullAuto = payload.fullAuto
if (payload.autoGuard !== undefined) mapped.autoGuard = payload.autoGuard
if (payload.autoSummon !== undefined) mapped.autoSummon = payload.autoSummon
if (payload.clearTime !== undefined) mapped.clearTime = payload.clearTime
if (payload.buttonCount !== undefined) mapped.buttonCount = payload.buttonCount
if (payload.chainCount !== undefined) mapped.chainCount = payload.chainCount
if (payload.turnCount !== undefined) mapped.turnCount = payload.turnCount
if (payload.jobId !== undefined) mapped.job = { id: payload.jobId }
if (payload.visibility !== undefined) mapped.visibility = payload.visibility
if (payload.localId !== undefined) mapped.localId = payload.localId
return mapped
}
}