537 lines
17 KiB
TypeScript
537 lines
17 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_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<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' ? 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<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) {
|
|
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<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
|
|
}
|
|
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<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'
|
|
}
|