import { asIwt2HealerId, type Iwt2HealerId } from '../content/healerAbilities' import { coinDropQuantity } from '../../../shared/rewardRules.mjs' import { requestGameApiJson } from '../../../gameRepository' import { IWT2_BOSS_PET_DROP_RATE, IWT2_LEGACY_BOSS_MATERIAL_REWARDS, iwt2BossCoinRewardFor, iwt2BossPetRewardFor, } from '../content/bossRewards' import type { Iwt2BossId } from '../content/bosses' import { createDefaultIwt2GearProgress, IWT2_GEAR_SLOTS, iwt2GearUpgradeCosts, iwt2InfusionCosts, isIwt2InfusionUnlocked, type Iwt2GearLevel, type Iwt2GearProgress, type Iwt2GearSlotId, } from '../content/gear' import { IWT2_INFUSION_ABILITIES, iwt2InfusionAbilitiesForClass, type Iwt2InfusionAbilityId, } from '../content/infusionAbilities' import { IWT2_PARTY_ORDER, type Iwt2PlayerClassId } from '../content/classes' export type Iwt2InventoryItem = { id: string name: string quantity: number rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' itemLevel: number } export type Iwt2ArmorPaletteId = 'guild_green' | 'sun_gold' | 'ember_red' | 'moon_blue' export type Iwt2CollectionLog = { bossKills: Record bossPets: Record dropsFound: Record } export type Iwt2Character = { name: string level: number experience: number healerStyle: Iwt2HealerId armorPalette: Iwt2ArmorPaletteId } export type Iwt2CloudSlot = { savedAt: number character: Iwt2Character inventory: Iwt2InventoryItem[] collectionLog: Iwt2CollectionLog gearProgress: Iwt2GearProgress } export type Iwt2BossPetAward = { bossId: Iwt2BossId petId: string petName: string quantity: number duplicate: boolean quantityAfter: number } export type Iwt2BossDropAward = { bossId: Iwt2BossId dropId: string dropName: string rarity: Iwt2InventoryItem['rarity'] itemLevel: number quantity: number duplicate: boolean quantityAfter: number } export type Iwt2BossKillReward = { save: Iwt2Save dropAwarded: Iwt2BossDropAward petAwarded: Iwt2BossPetAward | null } export type Iwt2Save = { version: 2 updatedAt: number character: Iwt2Character inventory: Iwt2InventoryItem[] collectionLog: Iwt2CollectionLog gearProgress: Iwt2GearProgress cloudSlot?: Iwt2CloudSlot } export type Iwt2OnlineSaveResult = { save: Iwt2Save | null } const IWT2_SAVE_KEY = 'i-want-to-heal-2:save:v1' export function createDefaultIwt2Save(): Iwt2Save { return { version: 2, updatedAt: Date.now(), character: { name: 'Healer', level: 1, experience: 0, healerStyle: 'dawnweaver', armorPalette: 'guild_green', }, inventory: [], collectionLog: { bossKills: {}, bossPets: {}, dropsFound: {}, }, gearProgress: createDefaultIwt2GearProgress(), } } function normalizeSave(value: unknown): Iwt2Save { if (!value || typeof value !== 'object') return createDefaultIwt2Save() const candidate = value as Partial> & { version?: number } if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save() return { version: 2, updatedAt: typeof candidate.updatedAt === 'number' ? candidate.updatedAt : Date.now(), character: { name: candidate.character?.name || 'Healer', level: Math.max(1, Math.floor(candidate.character?.level ?? 1)), experience: Math.max(0, Math.floor(candidate.character?.experience ?? 0)), healerStyle: asIwt2HealerId(candidate.character?.healerStyle), armorPalette: asIwt2ArmorPaletteId(candidate.character?.armorPalette), }, inventory: normalizeInventory(candidate.inventory), collectionLog: { bossKills: candidate.collectionLog?.bossKills ?? {}, bossPets: candidate.collectionLog?.bossPets ?? {}, dropsFound: normalizeDropsFound(candidate.collectionLog?.dropsFound), }, gearProgress: normalizeGearProgress(candidate.gearProgress), cloudSlot: normalizeCloudSlot(candidate.cloudSlot), } } export function loadIwt2Save(): Iwt2Save { try { return normalizeSave(JSON.parse(window.localStorage.getItem(IWT2_SAVE_KEY) ?? 'null')) } catch { return createDefaultIwt2Save() } } export async function loadIwt2OnlineSave(): Promise { const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save') return { save: result.save ? normalizeSave(result.save) : null, } } export async function writeIwt2OnlineSave(save: Iwt2Save): Promise { const result = await requestGameApiJson<{ save: unknown | null }>('/api/iwt2/sync-save', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ save }), }) return { save: result.save ? normalizeSave(result.save) : normalizeSave(save), } } export function writeIwt2Save(save: Iwt2Save) { window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify({ ...save, updatedAt: Date.now(), })) } export function recordIwt2BossKill( save: Iwt2Save, bossId: Iwt2BossId, options?: { difficultySlug?: string, experienceMultiplier?: number }, ): Iwt2Save { return recordIwt2BossKillReward(save, bossId, options).save } export function recordIwt2BossKillReward( save: Iwt2Save, bossId: Iwt2BossId, options?: { difficultySlug?: string, experienceMultiplier?: number }, ): Iwt2BossKillReward { const drop = iwt2BossCoinRewardFor(bossId, options?.difficultySlug) const pet = iwt2BossPetRewardFor(bossId) const awardedPet = Math.random() < IWT2_BOSS_PET_DROP_RATE const previousPetQuantity = save.collectionLog.bossPets[pet.id] ?? 0 const experienceReward = Math.round(125 * Math.max(0, options?.experienceMultiplier ?? 1)) const quantity = coinDropQuantity() const inventoryResult = addInventoryItem(save.inventory, { id: drop.id, itemLevel: drop.itemLevel, name: drop.name, quantity, rarity: drop.rarity, }) const updatedSave: Iwt2Save = { ...save, updatedAt: Date.now(), character: { ...save.character, experience: save.character.experience + experienceReward, level: Math.max(save.character.level, 1 + Math.floor((save.character.experience + experienceReward) / 500)), }, inventory: inventoryResult.inventory, collectionLog: { ...save.collectionLog, bossKills: { ...save.collectionLog.bossKills, [bossId]: (save.collectionLog.bossKills[bossId] ?? 0) + 1, }, bossPets: awardedPet ? { ...save.collectionLog.bossPets, [pet.id]: (save.collectionLog.bossPets[pet.id] ?? 0) + 1, } : save.collectionLog.bossPets, dropsFound: { ...save.collectionLog.dropsFound, [drop.id]: (save.collectionLog.dropsFound[drop.id] ?? 0) + quantity, }, }, } return { save: updatedSave, dropAwarded: { bossId, dropId: drop.id, dropName: drop.name, duplicate: inventoryResult.duplicate, itemLevel: drop.itemLevel, quantity, quantityAfter: inventoryResult.quantityAfter, rarity: drop.rarity, }, petAwarded: awardedPet ? { bossId, duplicate: previousPetQuantity > 0, petId: pet.id, petName: pet.name, quantity: 1, quantityAfter: previousPetQuantity + 1, } : null, } } export function upgradeIwt2GearSlot( save: Iwt2Save, classId: Iwt2PlayerClassId, slotId: Iwt2GearSlotId, ): Iwt2Save { const classProgress = save.gearProgress[classId] const slot = classProgress.slots[slotId] if (slot.level >= 5) throw new Error('Gear slot already at +5.') const costs = iwt2GearUpgradeCosts(classId, slotId, slot.level) const inventory = spendInventoryCosts(save.inventory, costs) return { ...save, updatedAt: Date.now(), inventory, gearProgress: { ...save.gearProgress, [classId]: { ...classProgress, slots: { ...classProgress.slots, [slotId]: { level: (slot.level + 1) as Iwt2GearLevel, }, }, }, }, } } export function setIwt2InfusionAbility( save: Iwt2Save, classId: Iwt2PlayerClassId, slotId: Iwt2GearSlotId, abilityId: Iwt2InfusionAbilityId, ): Iwt2Save { const classProgress = save.gearProgress[classId] const ability = IWT2_INFUSION_ABILITIES[abilityId] if (!ability || ability.classId !== classId) throw new Error('Ability is not available for this class.') if (!isIwt2InfusionUnlocked(classProgress)) throw new Error('Upgrade any gear slot to +5 first.') if (classProgress.slots[slotId].level < 5) throw new Error('Select a +5 gear slot to anchor the infusion cost.') if (classProgress.infusionAbilityId === abilityId) return save const inventory = spendInventoryCosts(save.inventory, iwt2InfusionCosts(classId, slotId, abilityId)) return { ...save, updatedAt: Date.now(), inventory, gearProgress: { ...save.gearProgress, [classId]: { ...classProgress, infusionAbilityId: abilityId, }, }, } } export function canAffordIwt2Costs(save: Iwt2Save, costs: Array<{ itemId: string, quantity: number }>): boolean { return costs.every((cost) => inventoryQuantity(save.inventory, cost.itemId) >= cost.quantity) } export function inventoryQuantity(inventory: Iwt2InventoryItem[], itemId: string): number { return inventory.find((item) => item.id === itemId)?.quantity ?? 0 } function addInventoryItem( inventory: Iwt2InventoryItem[], item: Iwt2InventoryItem, ): { inventory: Iwt2InventoryItem[], duplicate: boolean, quantityAfter: number } { const nextInventory = inventory.map((candidate) => ({ ...candidate })) const existing = nextInventory.find((candidate) => candidate.id === item.id) if (existing) { existing.quantity += item.quantity return { inventory: nextInventory, duplicate: true, quantityAfter: existing.quantity } } nextInventory.push({ ...item }) return { inventory: nextInventory, duplicate: false, quantityAfter: item.quantity } } function spendInventoryCosts( inventory: Iwt2InventoryItem[], costs: Array<{ itemId: string, itemName: string, quantity: number }>, ): Iwt2InventoryItem[] { for (const cost of costs) { if (inventoryQuantity(inventory, cost.itemId) < cost.quantity) { throw new Error(`Need ${cost.quantity} ${cost.itemName}.`) } } return inventory.flatMap((item) => { const cost = costs.find((candidate) => candidate.itemId === item.id) if (!cost) return [{ ...item }] const quantity = item.quantity - cost.quantity return quantity > 0 ? [{ ...item, quantity }] : [] }) } export function updateIwt2CharacterSettings( save: Iwt2Save, settings: Partial>, ): Iwt2Save { return { ...save, updatedAt: Date.now(), character: { ...save.character, ...settings, name: normalizeCharacterName(settings.name ?? save.character.name), healerStyle: asIwt2HealerId(settings.healerStyle ?? save.character.healerStyle), armorPalette: asIwt2ArmorPaletteId(settings.armorPalette ?? save.character.armorPalette), }, } } export function snapshotIwt2CloudSlot(save: Iwt2Save): Iwt2Save { return { ...save, updatedAt: Date.now(), cloudSlot: { savedAt: Date.now(), character: { ...save.character }, inventory: save.inventory.map((item) => ({ ...item })), collectionLog: { bossKills: { ...save.collectionLog.bossKills }, bossPets: { ...save.collectionLog.bossPets }, dropsFound: { ...save.collectionLog.dropsFound }, }, gearProgress: cloneGearProgress(save.gearProgress), }, } } export function restoreIwt2CloudSlot(save: Iwt2Save): Iwt2Save { if (!save.cloudSlot) return save return { ...save, updatedAt: Date.now(), character: { ...save.cloudSlot.character }, inventory: save.cloudSlot.inventory.map((item) => ({ ...item })), collectionLog: { bossKills: { ...save.cloudSlot.collectionLog.bossKills }, bossPets: { ...save.cloudSlot.collectionLog.bossPets }, dropsFound: { ...save.cloudSlot.collectionLog.dropsFound }, }, gearProgress: cloneGearProgress(save.cloudSlot.gearProgress), } } function asIwt2ArmorPaletteId(value: unknown): Iwt2ArmorPaletteId { return value === 'sun_gold' || value === 'ember_red' || value === 'moon_blue' || value === 'guild_green' ? value : 'guild_green' } function normalizeCharacterName(name: string): string { const trimmed = name.trim().slice(0, 18) return trimmed || 'Healer' } function normalizeCloudSlot(value: unknown): Iwt2CloudSlot | undefined { if (!value || typeof value !== 'object') return undefined const candidate = value as Partial if (!candidate.character || !candidate.collectionLog) return undefined return { savedAt: typeof candidate.savedAt === 'number' ? candidate.savedAt : Date.now(), character: { name: normalizeCharacterName(candidate.character.name ?? 'Healer'), level: Math.max(1, Math.floor(candidate.character.level ?? 1)), experience: Math.max(0, Math.floor(candidate.character.experience ?? 0)), healerStyle: asIwt2HealerId(candidate.character.healerStyle), armorPalette: asIwt2ArmorPaletteId(candidate.character.armorPalette), }, inventory: normalizeInventory(candidate.inventory), collectionLog: { bossKills: candidate.collectionLog.bossKills ?? {}, bossPets: candidate.collectionLog.bossPets ?? {}, dropsFound: normalizeDropsFound(candidate.collectionLog.dropsFound), }, gearProgress: normalizeGearProgress(candidate.gearProgress), } } function normalizeGearProgress(value: unknown): Iwt2GearProgress { const defaults = createDefaultIwt2GearProgress() if (!value || typeof value !== 'object') return defaults const candidate = value as Partial> const next = createDefaultIwt2GearProgress() for (const classId of IWT2_PARTY_ORDER) { const rawClassProgress = candidate[classId] if (!rawClassProgress || typeof rawClassProgress !== 'object') continue const classProgress = rawClassProgress as { slots?: Partial> infusionAbilityId?: unknown } for (const slotId of IWT2_GEAR_SLOTS) { next[classId].slots[slotId] = { level: asGearLevel(classProgress.slots?.[slotId]?.level), } } const infusionAbilityId = classProgress.infusionAbilityId next[classId].infusionAbilityId = iwt2InfusionAbilitiesForClass(classId).some((ability) => ability.id === infusionAbilityId) && isIwt2InfusionUnlocked(next[classId]) ? infusionAbilityId as Iwt2InfusionAbilityId : null } return next } function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress { return normalizeGearProgress(progress) } function asGearLevel(value: unknown): Iwt2GearLevel { const level = Math.max(0, Math.min(5, Math.floor(Number(value) || 0))) return level as Iwt2GearLevel } function normalizeInventory(value: unknown): Iwt2InventoryItem[] { if (!Array.isArray(value)) return [] const byId = new Map() for (const rawItem of value) { if (!rawItem || typeof rawItem !== 'object') continue const item = rawItem as Partial if (!item.id || !item.name) continue const bossId = legacyBossIdForDropId(item.id) const normalizedItem = bossId ? iwt2BossCoinRewardFor(bossId, 'initiate') : { id: item.id, itemLevel: Math.max(1, Math.floor(item.itemLevel ?? 1)), name: item.name, rarity: asItemRarity(item.rarity), } const quantity = Math.max(1, Math.floor(item.quantity ?? 1)) const existing = byId.get(normalizedItem.id) if (existing) { existing.quantity += quantity } else { byId.set(normalizedItem.id, { id: normalizedItem.id, itemLevel: normalizedItem.itemLevel, name: normalizedItem.name, quantity, rarity: normalizedItem.rarity, }) } } return [...byId.values()] } function normalizeDropsFound(value: unknown): Record { if (!value || typeof value !== 'object') return {} const next: Record = {} for (const [rawId, rawQuantity] of Object.entries(value as Record)) { const bossId = legacyBossIdForDropId(rawId) const id = bossId ? iwt2BossCoinRewardFor(bossId, 'initiate').id : rawId const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0)) if (quantity > 0) next[id] = (next[id] ?? 0) + quantity } return next } function legacyBossIdForDropId(dropId: string): Iwt2BossId | undefined { return (Object.entries(IWT2_LEGACY_BOSS_MATERIAL_REWARDS) as Array<[Iwt2BossId, { id: string }]>) .find(([, legacy]) => dropId === legacy.id || dropId.startsWith(`${legacy.id}-`)) ?.[0] } function asItemRarity(value: unknown): Iwt2InventoryItem['rarity'] { return value === 'uncommon' || value === 'rare' || value === 'epic' || value === 'legendary' || value === 'common' ? value : 'common' }