Files
i-want-to-heal/src/modes/iwt2/save/iwt2Repository.ts
T
2026-07-09 21:57:51 -04:00

585 lines
18 KiB
TypeScript

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_ACTIVE_INFUSION_MIN_GEAR_LEVEL,
IWT2_GEAR_SLOTS,
IWT2_MAX_GEAR_LEVEL,
iwt2GearUpgradeCosts,
iwt2InfusionCosts,
isIwt2InfusionUnlocked,
isIwt2PassiveInfusionUnlocked,
type Iwt2GearLevel,
type Iwt2GearProgress,
type Iwt2GearSlotId,
type Iwt2PassiveInfusionId,
} 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<string, number>
bossPets: Record<string, number>
dropsFound: Record<string, number>
}
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<Omit<Iwt2Save, 'version'>> & { version?: number }
if (candidate.version !== 1 && candidate.version !== 2) return createDefaultIwt2Save()
return {
version: 2,
updatedAt: typeof candidate.updatedAt === 'number'
&& Number.isFinite(candidate.updatedAt)
&& candidate.updatedAt > 0
? 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 {
return loadExistingIwt2Save() ?? createDefaultIwt2Save()
}
export function loadExistingIwt2Save(): Iwt2Save | null {
const serialized = window.localStorage.getItem(IWT2_SAVE_KEY)
if (!serialized) return null
try {
return normalizeSave(JSON.parse(serialized))
} catch {
return null
}
}
export async function loadIwt2OnlineSave(): Promise<Iwt2OnlineSaveResult> {
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<Iwt2OnlineSaveResult> {
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) {
replaceIwt2Save(save)
}
export function replaceIwt2Save(save: Iwt2Save) {
window.localStorage.setItem(IWT2_SAVE_KEY, JSON.stringify(normalizeSave(save)))
}
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 >= IWT2_MAX_GEAR_LEVEL) throw new Error(`Gear slot already at +${IWT2_MAX_GEAR_LEVEL}.`)
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 +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} first.`)
if (classProgress.slots[slotId].level < IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL) {
throw new Error(`Select a +${IWT2_ACTIVE_INFUSION_MIN_GEAR_LEVEL} 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 setIwt2PassiveInfusion(
save: Iwt2Save,
passiveInfusionId: Iwt2PassiveInfusionId,
): Iwt2Save {
if (!isIwt2PassiveInfusionUnlocked(save.gearProgress)) {
throw new Error(`Upgrade any gear slot to +${IWT2_MAX_GEAR_LEVEL} first.`)
}
if (save.gearProgress.healer.passiveInfusionId === passiveInfusionId) return save
return {
...save,
updatedAt: Date.now(),
gearProgress: {
...save.gearProgress,
healer: {
...save.gearProgress.healer,
passiveInfusionId,
},
},
}
}
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<Pick<Iwt2Save['character'], 'name' | 'healerStyle' | 'armorPalette'>>,
): 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<Iwt2CloudSlot>
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<Record<Iwt2PlayerClassId, unknown>>
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<Record<Iwt2GearSlotId, { level?: unknown }>>
infusionAbilityId?: unknown
passiveInfusionId?: 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
next[classId].passiveInfusionId = classId === 'healer'
&& isIwt2PassiveInfusionUnlocked(next)
? asPassiveInfusionId(classProgress.passiveInfusionId)
: null
}
return next
}
function cloneGearProgress(progress: Iwt2GearProgress): Iwt2GearProgress {
return normalizeGearProgress(progress)
}
function asGearLevel(value: unknown): Iwt2GearLevel {
const level = Math.max(0, Math.min(IWT2_MAX_GEAR_LEVEL, Math.floor(Number(value) || 0)))
return level as Iwt2GearLevel
}
function asPassiveInfusionId(value: unknown): Iwt2PassiveInfusionId | null {
if (typeof value !== 'string' || value === 'revive-party-members') return null
return value as Iwt2PassiveInfusionId
}
function normalizeInventory(value: unknown): Iwt2InventoryItem[] {
if (!Array.isArray(value)) return []
const byId = new Map<string, Iwt2InventoryItem>()
for (const rawItem of value) {
if (!rawItem || typeof rawItem !== 'object') continue
const item = rawItem as Partial<Iwt2InventoryItem>
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<string, number> {
if (!value || typeof value !== 'object') return {}
const next: Record<string, number> = {}
for (const [rawId, rawQuantity] of Object.entries(value as Record<string, unknown>)) {
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'
}