From 32c1c9016e337094f455ae342f3b0cff1897d370 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 11 Sep 2025 10:46:29 -0700 Subject: [PATCH] Add new services for business logic --- src/lib/services/conflict.service.ts | 192 ++++++++++++ src/lib/services/grid.service.ts | 450 +++++++++++++++++++++++++++ src/lib/services/party.service.ts | 214 +++++++++++++ 3 files changed, 856 insertions(+) create mode 100644 src/lib/services/conflict.service.ts create mode 100644 src/lib/services/grid.service.ts create mode 100644 src/lib/services/party.service.ts diff --git a/src/lib/services/conflict.service.ts b/src/lib/services/conflict.service.ts new file mode 100644 index 00000000..53258c8a --- /dev/null +++ b/src/lib/services/conflict.service.ts @@ -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 { + 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 + ): Promise { + // 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 + ): Promise { + // 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 { + const headers: Record = {} + 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 + } + } +} \ No newline at end of file diff --git a/src/lib/services/grid.service.ts b/src/lib/services/grid.service.ts new file mode 100644 index 00000000..cd2c3b43 --- /dev/null +++ b/src/lib/services/grid.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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( + 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 { + const headers: Record = {} + if (editKey) { + headers['X-Edit-Key'] = editKey + } + return headers + } +} \ No newline at end of file diff --git a/src/lib/services/party.service.ts b/src/lib/services/party.service.ts new file mode 100644 index 00000000..22652fc0 --- /dev/null +++ b/src/lib/services/party.service.ts @@ -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 + 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 { + return partiesApi.getByShortcode(this.fetch, shortcode) + } + + /** + * Create a new party + */ + async create(payload: PartyUpdatePayload, editKey?: string): Promise { + 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 { + 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 { + 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 { + return partiesApi.favorite(this.fetch, id) + } + + /** + * Unfavorite a party + */ + async unfavorite(id: string): Promise { + return partiesApi.unfavorite(this.fetch, id) + } + + /** + * Delete a party + */ + async delete(id: string, editKey?: string): Promise { + 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 { + const headers: Record = {} + if (editKey) { + headers['X-Edit-Key'] = editKey + } + return headers + } + + private mapToApiPayload(payload: PartyUpdatePayload): Partial { + 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 + } +}