import starterProfile from './offline-starter-profile.json' import type { AuthSession, CharacterProfile, DungeonReward, LootRoll, Item, EquipmentSlot, } from './profile' export type GameMode = 'online' | 'offline' export interface GameRepository { loadSession(): Promise register(username: string, password: string, characterName: string): Promise login(username: string, password: string): Promise logout(): Promise loadProfile(): Promise saveProfile(classId: number, abilitySlots: Array): Promise completeDungeon( dungeonId: number, difficultyId: number, resourceSpent: number, durationSeconds: number, completedPart?: number, startPart?: number, partDurationSeconds?: [number, number, number], ): Promise completeRoguelike( dungeonId: number, difficultyId: number, encountersCleared: number, resourceSpent: number, durationSeconds: number, options?: { bossesCleared?: number experienceMode?: 'default' | 'pvp-boss-quarter-level' }, ): Promise allocateTalent(talentId: number): Promise resetTalents(): Promise equipItem(itemId: number): Promise discardExtraItem(itemId: number): Promise breakdownItem(itemId: number): Promise craftItem(recipeId: number): Promise rollEncounterLoot( encounterId: number, difficultyId: number, runToken: string, ): Promise } type CharacterData = { level: number experience: number talentPoints: number abilitySlots: Array talentRanks: Record inventory: Item[] } type OfflineSave = { version: 3 characterName: string activeClassId: number completedDungeonParts: number completedRaidPhases: number characters: Record lootRolls: Record } const modeKey = 'chronicle.gameMode' const offlineSaveKey = 'chronicle.offlineSave.v1' const offlineAccount = { id: -1, username: 'Offline' } function clone(value: T): T { return structuredClone(value) } function readMode(): GameMode { return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online' } function writeMode(mode: GameMode) { localStorage.setItem(modeKey, mode) } function readOfflineSave(): OfflineSave | null { const serialized = localStorage.getItem(offlineSaveKey) if (!serialized) return null try { const raw = JSON.parse(serialized) if (raw.version === 3) return raw as OfflineSave if (raw.version === 2) return migrateV2ToV3(raw) if (raw.version === 1) return migrateV1ToV2(raw) return null } catch { return null } } function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record }): OfflineSave { const p = v1.profile const classes = [1, 2, 3] const characters: Record = {} for (const cid of classes) { const gameClass = p.classes.find((c) => c.id === cid)! const talentRanks: Record = {} for (const t of gameClass.talents) { talentRanks[String(t.id)] = t.rank } characters[cid] = { level: cid === p.character.classId ? p.character.level : 1, experience: cid === p.character.classId ? p.character.experience : 0, talentPoints: cid === p.character.classId ? p.character.talentPoints : 1, abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [], talentRanks, inventory: cid === p.character.classId ? clone(p.inventory) : [], } } const v2: OfflineSave = { version: 3, characterName: p.character.name, activeClassId: p.character.classId, completedDungeonParts: p.completedDungeonParts, completedRaidPhases: p.completedRaidPhases ?? 0, characters, lootRolls: v1.lootRolls ?? {}, } localStorage.setItem(offlineSaveKey, JSON.stringify(v2)) return v2 } function migrateV2ToV3(v2: Omit & { version: 2 }): OfflineSave { const v3: OfflineSave = { ...v2, version: 3, completedRaidPhases: 0, } localStorage.setItem(offlineSaveKey, JSON.stringify(v3)) return v3 } function writeOfflineSave(save: OfflineSave) { localStorage.setItem(offlineSaveKey, JSON.stringify(save)) } function requireOfflineSave(): OfflineSave { const save = readOfflineSave() if (!save) throw new Error('No offline character exists yet.') return save } function buildProfile(save: OfflineSave): CharacterProfile { const static_ = clone(starterProfile) as CharacterProfile const cd = save.characters[save.activeClassId] const gameClass = static_.classes.find((c) => c.id === save.activeClassId)! static_.character.name = save.characterName static_.character.level = cd.level static_.character.experience = cd.experience static_.character.talentPoints = cd.talentPoints static_.character.classId = gameClass.id static_.character.classSlug = gameClass.slug static_.character.className = gameClass.name static_.character.resourceName = gameClass.resourceName static_.character.maxResource = gameClass.maxResource static_.character.themeColor = gameClass.themeColor static_.character.classDescription = gameClass.description static_.character.currentLevelExperience = experienceForLevel(cd.level) static_.character.nextLevelExperience = cd.level >= static_.maxLevel ? experienceForLevel(static_.maxLevel) : experienceForLevel(cd.level + 1) static_.abilitySlots = cd.abilitySlots for (const c of static_.classes) { for (const t of c.talents) { t.rank = cd.talentRanks[String(t.id)] ?? 0 } } static_.allocatedTalentPoints = gameClass.talents.reduce((s, t) => s + t.rank, 0) static_.inventory = cd.inventory updateGearStats(static_) updateSetBonuses(static_) updateCraftingRecipes(static_) static_.completedDungeonParts = save.completedDungeonParts static_.completedRaidPhases = save.completedRaidPhases return static_ } function updateGearStats(profile: CharacterProfile) { const equipped = profile.inventory.filter((item) => item.equipped) profile.gearStats = { averageItemLevel: profile.equipmentSlots.length === 0 ? 0 : equipped.reduce((total, item) => total + item.itemLevel, 0) / profile.equipmentSlots.length, healingPower: equipped.reduce((total, item) => total + item.healingPower, 0), maxResourceBonus: equipped.reduce( (total, item) => total + item.maxResourceBonus, 0, ), } } function updateSetBonuses(profile: CharacterProfile) { const equippedSetCounts = new Map() for (const item of profile.inventory) { if (!item.equipped || !item.setId) continue equippedSetCounts.set(item.setId, (equippedSetCounts.get(item.setId) ?? 0) + 1) } profile.setBonuses = (profile.setBonuses ?? []).map((bonus) => { const equippedPieces = equippedSetCounts.get(bonus.setId) ?? 0 return { ...bonus, equippedPieces, active: equippedPieces >= bonus.requiredPieces, } }) } function updateCraftingRecipes(profile: CharacterProfile) { const owned = new Map(profile.inventory.map((item) => [item.id, item.quantity])) profile.craftingRecipes = (profile.craftingRecipes ?? []).map((recipe) => { const components = recipe.components.map((component) => ({ ...component, owned: owned.get(component.item.id) ?? 0, })) return { ...recipe, components, canCraft: components.every((component) => component.owned >= component.quantity), } }) } function addInventoryItem(inventory: Item[], item: Omit, quantity: number) { const existing = inventory.find((candidate) => candidate.id === item.id) if (existing) { existing.quantity += quantity return { duplicate: true, quantityAfter: existing.quantity } } inventory.push({ ...item, quantity, equipped: false, }) return { duplicate: false, quantityAfter: quantity } } function experienceForLevel(level: number) { return (level - 1) * (level - 1) * 100 } function scaledPvpBossExperience( startingExperience: number, startingLevel: number, bossesCleared: number, maxLevel: number, ) { let experience = startingExperience let level = startingLevel const maxExperience = experienceForLevel(maxLevel) for (let bossIndex = 0; bossIndex < bossesCleared && experience < maxExperience; bossIndex += 1) { const currentLevelFloor = experienceForLevel(level) const nextLevelExperience = level >= maxLevel ? maxExperience : experienceForLevel(level + 1) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25)) while (level < maxLevel && experienceForLevel(level + 1) <= experience) { level += 1 } } return { experience, level } } type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string } const COMPONENT_ITEMS: Record = { 1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' }, 5: { id: 601, slug: 'basic-component', name: 'Basic Component', itemLevel: 5, glyph: '◇', description: 'A standard crafting component.' }, 10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined crafting component.' }, 15: { id: 603, slug: 'advanced-component', name: 'Advanced Component', itemLevel: 15, glyph: '◉', description: 'An advanced crafting component.' }, 20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' }, 25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' }, } type WindowWithApiBase = Window & { CAPACITOR_API_BASE_URL?: string } function componentForItemLevel(itemLevel: number): ComponentTemplate | undefined { const levels = Object.keys(COMPONENT_ITEMS).map(Number).sort((a, b) => a - b) let best = levels[0] for (const level of levels) { if (level <= itemLevel) best = level } return COMPONENT_ITEMS[best] } function componentDropQuantity(itemLevel: number) { const tier = Math.max(0, Math.floor((itemLevel - 5) / 5)) const secondChance = Math.min(0.85, 0.35 + tier * 0.12) const thirdChance = Math.min(0.6, 0.1 + tier * 0.1) return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0) } function rollWeightedLootEntry(entries: T[]): T { const totalWeight = entries.reduce((total, entry) => total + entry.dropWeight, 0) let weightedRoll = Math.random() * totalWeight for (const entry of entries) { weightedRoll -= entry.dropWeight if (weightedRoll < 0) return entry } return entries[entries.length - 1] } function getApiBaseUrl(): string { const browserWindow = typeof window === 'undefined' ? undefined : window as WindowWithApiBase if (browserWindow?.CAPACITOR_API_BASE_URL) { return browserWindow.CAPACITOR_API_BASE_URL } if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) { return import.meta.env.VITE_API_BASE_URL } return '' } async function requestJson(path: string, init?: RequestInit): Promise { const baseUrl = getApiBaseUrl() const url = baseUrl ? `${baseUrl}${path}` : path const response = await fetch(url, init) const body = await response.json() if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.') return body } const serverRepository: GameRepository = { loadSession: () => requestJson('/api/auth/session'), register: (username, password, characterName) => requestJson('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, characterName }), }), login: (username, password) => requestJson('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }), logout: async () => { await requestJson('/api/auth/logout', { method: 'POST' }) }, loadProfile: () => requestJson('/api/profile'), saveProfile: (classId, abilitySlots) => requestJson('/api/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ classId, abilitySlots }), }), completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => requestJson(`/api/dungeons/${dungeonId}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }), }), completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => requestJson('/api/roguelike/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }), }), allocateTalent: (talentId) => requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }), resetTalents: () => requestJson('/api/talents/reset', { method: 'POST' }), equipItem: (itemId) => requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }), discardExtraItem: (itemId) => requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }), breakdownItem: (itemId) => requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }), craftItem: (recipeId) => requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }), rollEncounterLoot: (encounterId, difficultyId, runToken) => requestJson(`/api/encounters/${encounterId}/loot-roll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ difficultyId, runToken }), }), } function emptyCharacterData(classId: number): CharacterData { const static_ = clone(starterProfile) as CharacterProfile const gc = static_.classes.find((c) => c.id === classId)! const talentRanks: Record = {} for (const t of gc.talents) talentRanks[String(t.id)] = 0 const starterItemIds = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109] const inventory: Item[] = static_.inventory.filter((i) => starterItemIds.includes(i.id)).map((i) => ({ ...i })) const startingAbilitySlots: Array = gc.spells .filter((s) => s.unlockLevel === 1) .slice(0, 5) .map((s) => s.id) while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null) return { level: 1, experience: 0, talentPoints: 1, abilitySlots: startingAbilitySlots, talentRanks, inventory, } } const offlineRepository: GameRepository = { async loadSession() { const save = readOfflineSave() return { account: save ? offlineAccount : null, profile: save ? buildProfile(save) : null, } }, async register() { throw new Error('Account registration requires online mode.') }, async login() { throw new Error('Account login requires online mode.') }, async logout() { writeMode('online') }, async loadProfile() { return buildProfile(requireOfflineSave()) }, async saveProfile(classId, abilitySlots) { const save = requireOfflineSave() const static_ = clone(starterProfile) as CharacterProfile const gameClass = static_.classes.find((candidate) => candidate.id === classId) if (!gameClass) throw new Error('Selected class does not exist.') const slots = abilitySlots.slice(0, 6) while (slots.length < 6) slots.push(null) const selectedIds = slots.filter((id): id is number => id !== null) if (new Set(selectedIds).size !== selectedIds.length) { throw new Error('The same ability cannot be equipped twice.') } const activeChar = save.characters[save.activeClassId] const validIds = new Set( gameClass.spells .filter((spell) => spell.unlockLevel <= activeChar.level) .map((spell) => spell.id), ) if (selectedIds.some((id) => !validIds.has(id))) { throw new Error('One or more abilities are locked or belong to another class.') } if (!save.characters[classId]) { save.characters[classId] = emptyCharacterData(classId) } save.characters[classId].abilitySlots = slots save.activeClassId = classId writeOfflineSave(save) return buildProfile(save) }, async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) { void startPart void partDurationSeconds if (!Number.isInteger(resourceSpent) || resourceSpent < 0) { throw new Error('The run resource total is invalid.') } if (!Number.isInteger(durationSeconds) || durationSeconds < 1) { throw new Error('The run duration is invalid.') } const save = requireOfflineSave() const profile = buildProfile(save) const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId) const difficulty = dungeon?.difficulties.find( (candidate) => candidate.id === difficultyId, ) if (!dungeon || !difficulty) { throw new Error('That difficulty is not available for this dungeon.') } const cd = save.characters[save.activeClassId] if (cd.level < difficulty.unlockLevel) { throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`) } const previousLevel = cd.level const previousExperience = cd.experience const partCount = completedPart ?? 1 const experienceReward = Math.round( dungeon.experienceReward * difficulty.experienceMultiplier * partCount, ) const maxExperience = experienceForLevel(profile.maxLevel) const newExperience = Math.min(previousExperience + experienceReward, maxExperience) let newLevel = previousLevel while ( newLevel < profile.maxLevel && experienceForLevel(newLevel + 1) <= newExperience ) { newLevel += 1 } const levelsGained = newLevel - previousLevel const gameClass = profile.classes.find( (candidate) => candidate.id === save.activeClassId, )! const unlockedAbilities = gameClass.spells .filter( (spell) => spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel, ) .map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph })) cd.experience = newExperience cd.level = newLevel cd.talentPoints = Math.min( profile.maxTalentPoints, cd.talentPoints + levelsGained, ) if (dungeon.contentType === 'raid') { save.completedRaidPhases = Math.max(save.completedRaidPhases, partCount) } else { save.completedDungeonParts = Math.max(save.completedDungeonParts, partCount) } let bonusItem: DungeonReward['bonusItem'] = null if ((startPart ?? 1) === 1 && partCount >= 3 && dungeon.completionLoot.length > 0) { const targetItemLevel = dungeon.completionItemLevel ?? difficulty.droppedItemLevel + 3 const eligibleLoot = dungeon.completionLoot.filter( (item) => item.itemLevel >= targetItemLevel, ) if (eligibleLoot.length > 0) { const rewardItemLevel = Math.min(...eligibleLoot.map((item) => item.itemLevel)) const rewardPool = eligibleLoot.filter((item) => item.itemLevel === rewardItemLevel) const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)] const existing = profile.inventory.find((item) => item.id === selected.id) const duplicate = Boolean(existing) let quantityAfter = 1 if (existing) { existing.quantity += 1 quantityAfter = existing.quantity } else { profile.inventory.push({ ...selected, quantity: 1, equipped: false, }) } cd.inventory = profile.inventory bonusItem = { ...selected, duplicate, quantityAfter } } } writeOfflineSave(save) const updatedProfile = buildProfile(save) return { dungeonName: dungeon.name, difficultyName: difficulty.name, droppedItemLevel: difficulty.droppedItemLevel, experienceGained: newExperience - previousExperience, previousLevel, newLevel, levelsGained, talentPointsGained: levelsGained, resourceSpent, durationSeconds, averageItemLevel: updatedProfile.gearStats.averageItemLevel, unlockedAbilities, bonusItem, profile: updatedProfile, } }, async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) { if (!Number.isInteger(encountersCleared) || encountersCleared < 0) { throw new Error('The roguelike progress total is invalid.') } if (!Number.isInteger(resourceSpent) || resourceSpent < 0) { throw new Error('The run resource total is invalid.') } if (!Number.isInteger(durationSeconds) || durationSeconds < 1) { throw new Error('The run duration is invalid.') } const save = requireOfflineSave() const profile = buildProfile(save) const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId) const difficulty = dungeon?.difficulties.find( (candidate) => candidate.id === difficultyId, ) if (!dungeon || !difficulty) { throw new Error('That difficulty is not available for this roguelike.') } const cd = save.characters[save.activeClassId] if (cd.level < difficulty.unlockLevel) { throw new Error(`${difficulty.name} unlocks at level ${difficulty.unlockLevel}.`) } const previousLevel = cd.level const previousExperience = cd.experience const maxExperience = experienceForLevel(profile.maxLevel) const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel) : null const newExperience = scaledReward ? scaledReward.experience : Math.min( previousExperience + Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)), maxExperience, ) let newLevel = scaledReward?.level ?? previousLevel while ( newLevel < profile.maxLevel && experienceForLevel(newLevel + 1) <= newExperience ) { newLevel += 1 } const levelsGained = newLevel - previousLevel const gameClass = profile.classes.find( (candidate) => candidate.id === save.activeClassId, )! const unlockedAbilities = gameClass.spells .filter( (spell) => spell.unlockLevel > previousLevel && spell.unlockLevel <= newLevel, ) .map(({ id, name, unlockLevel, glyph }) => ({ id, name, unlockLevel, glyph })) cd.experience = newExperience cd.level = newLevel cd.talentPoints = Math.min( profile.maxTalentPoints, cd.talentPoints + levelsGained, ) writeOfflineSave(save) const updatedProfile = buildProfile(save) return { dungeonName: `${dungeon.name} Roguelike`, difficultyName: difficulty.name, droppedItemLevel: difficulty.droppedItemLevel, experienceGained: newExperience - previousExperience, previousLevel, newLevel, levelsGained, talentPointsGained: levelsGained, resourceSpent, durationSeconds, averageItemLevel: updatedProfile.gearStats.averageItemLevel, unlockedAbilities, bonusItem: null, profile: updatedProfile, } }, async allocateTalent(talentId) { const save = requireOfflineSave() const profile = buildProfile(save) const cd = save.characters[save.activeClassId] const gameClass = profile.classes.find( (candidate) => candidate.id === save.activeClassId, )! const talent = gameClass.talents.find((candidate) => candidate.id === talentId) if (!talent) throw new Error('That talent does not belong to the active class.') if (cd.talentPoints <= 0) { throw new Error('No talent points are available.') } if (talent.rank >= talent.maxRank) { throw new Error('That talent is already at maximum rank.') } const lowerTierPoints = gameClass.talents .filter((candidate) => candidate.tier < talent.tier) .reduce((total, candidate) => total + candidate.rank, 0) const requiredTierPoints = (talent.tier - 1) * 5 if (lowerTierPoints < requiredTierPoints) { throw new Error(`Spend ${requiredTierPoints} points in earlier tiers first.`) } if (talent.prerequisiteTalentId) { const prerequisite = gameClass.talents.find( (candidate) => candidate.id === talent.prerequisiteTalentId, ) if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) { throw new Error( `The prerequisite talent requires rank ${talent.prerequisiteRank}.`, ) } } cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1 cd.talentPoints -= 1 writeOfflineSave(save) return buildProfile(save) }, async resetTalents() { const save = requireOfflineSave() const profile = buildProfile(save) const cd = save.characters[save.activeClassId] const gameClass = profile.classes.find( (candidate) => candidate.id === save.activeClassId, )! const refunded = gameClass.talents.reduce( (total, talent) => total + talent.rank, 0, ) for (const talent of gameClass.talents) { cd.talentRanks[String(talent.id)] = 0 } cd.talentPoints = Math.min( profile.maxTalentPoints, cd.talentPoints + refunded, ) writeOfflineSave(save) return buildProfile(save) }, async equipItem(itemId) { const save = requireOfflineSave() const profile = buildProfile(save) const item = profile.inventory.find((candidate) => candidate.id === itemId) if (!item) throw new Error('That item is not in the character inventory.') for (const candidate of profile.inventory) { if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id } save.characters[save.activeClassId].inventory = profile.inventory writeOfflineSave(save) return buildProfile(save) }, async discardExtraItem(itemId) { const save = requireOfflineSave() const profile = buildProfile(save) const item = profile.inventory.find((candidate) => candidate.id === itemId) if (!item) throw new Error('That item is not in the character inventory.') if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.') item.quantity -= 1 save.characters[save.activeClassId].inventory = profile.inventory writeOfflineSave(save) return buildProfile(save) }, async breakdownItem(itemId) { const save = requireOfflineSave() const profile = buildProfile(save) const item = profile.inventory.find((candidate) => candidate.id === itemId) if (!item) throw new Error('That item is not in the character inventory.') if (item.slot === 'component') throw new Error('Components cannot be broken down.') if (item.equipped && item.quantity <= 1) { throw new Error('Equipped items cannot be broken down.') } const componentTemplate = componentForItemLevel(item.itemLevel) if (!componentTemplate) throw new Error('No component type exists for this item level.') if (item.quantity <= 1) { profile.inventory.splice(profile.inventory.indexOf(item), 1) } else { item.quantity -= 1 } const existing = profile.inventory.find((c) => c.id === componentTemplate.id) const count = Math.floor(Math.random() * 3) + 1 if (existing) { existing.quantity += count } else { profile.inventory.push({ id: componentTemplate.id, slug: componentTemplate.slug, name: componentTemplate.name, slot: 'component' as EquipmentSlot, rarity: 'common' as const, itemLevel: componentTemplate.itemLevel, healingPower: 0, maxResourceBonus: 0, glyph: componentTemplate.glyph, description: componentTemplate.description, quantity: count, equipped: false, }) } save.characters[save.activeClassId].inventory = profile.inventory writeOfflineSave(save) return buildProfile(save) }, async craftItem(recipeId) { const save = requireOfflineSave() const profile = buildProfile(save) const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId) if (!recipe) throw new Error('That crafting recipe does not exist.') const missing = recipe.components.find((component) => component.owned < component.quantity) if (missing) { throw new Error(`Need ${missing.quantity} ${missing.item.name} to craft this item.`) } for (const component of recipe.components) { const owned = profile.inventory.find((candidate) => candidate.id === component.item.id) if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to craft this item.`) owned.quantity -= component.quantity } for (let index = profile.inventory.length - 1; index >= 0; index -= 1) { if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1) } addInventoryItem(profile.inventory, recipe.item, 1) save.characters[save.activeClassId].inventory = profile.inventory writeOfflineSave(save) return buildProfile(save) }, async rollEncounterLoot(encounterId, difficultyId, runToken) { if (runToken.length < 8 || runToken.length > 100) { throw new Error('A valid dungeon run token is required.') } const save = requireOfflineSave() const rollKey = `${runToken}:${encounterId}:${difficultyId}` if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey]) const profile = buildProfile(save) const encounter = profile.dungeons .flatMap((dungeon) => dungeon.encounters) .find((candidate) => candidate.id === encounterId) const difficulty = profile.dungeons .flatMap((dungeon) => dungeon.difficulties) .find((candidate) => candidate.id === difficultyId) const entries = encounter?.lootTables.filter( (entry) => entry.difficultyId === difficultyId, ) ?? [] if (!encounter || !difficulty || entries.length === 0) { throw new Error('This encounter has no configured loot.') } const dropChance = entries[0].dropChance const items: LootRoll['items'] = [] const selectedQuantities = new Map() const dungeon = profile.dungeons.find((candidate) => candidate.encounters.some((dungeonEncounter) => dungeonEncounter.id === encounterId), ) const lootChanceSlots = dungeon?.contentType === 'raid' ? 8 : 5 for (let index = 0; index < lootChanceSlots; index += 1) { if (Math.random() >= dropChance) continue const selected = rollWeightedLootEntry(entries) const current = selectedQuantities.get(selected.id) selectedQuantities.set(selected.id, { entry: selected, quantity: (current?.quantity ?? 0) + componentDropQuantity(difficulty.droppedItemLevel), }) } for (const { entry, quantity } of selectedQuantities.values()) { const { encounterId: _encounterId, difficultyId: _difficultyId, dropWeight: _dropWeight, dropChance: _dropChance, ...rolledItem } = entry void _encounterId void _difficultyId void _dropWeight void _dropChance const added = addInventoryItem(profile.inventory, { ...rolledItem, slot: rolledItem.slot as EquipmentSlot, rarity: rolledItem.rarity as Item['rarity'], }, quantity) items.push({ ...rolledItem, slot: rolledItem.slot as EquipmentSlot, rarity: rolledItem.rarity as Item['rarity'], quantity, duplicate: added.duplicate, quantityAfter: added.quantityAfter, }) } const item = items[0] ?? null const result: LootRoll = { encounterId, encounterName: encounter.enemyName, difficultyId, difficultyName: difficulty.name, dropChance, dropped: items.length > 0, item, items, awarded: Boolean(item), duplicate: items.some((candidate) => candidate.duplicate), quantityAfter: item?.quantityAfter ?? 0, } save.lootRolls[rollKey] = result save.characters[save.activeClassId].inventory = profile.inventory writeOfflineSave(save) return clone(result) }, } export function getGameMode(): GameMode { return readMode() } export function selectOnlineMode() { writeMode('online') } export function createOfflineCharacter(characterName: string): AuthSession { const name = characterName.trim() || 'Mira' if (!/^[A-Za-z][A-Za-z0-9 '-]{1,19}$/.test(name)) { throw new Error('Character name must be 2-20 characters and start with a letter.') } const characters: Record = {} for (const cid of [1, 2, 3]) { characters[cid] = emptyCharacterData(cid) } const save: OfflineSave = { version: 3, characterName: name, activeClassId: 1, completedDungeonParts: 0, completedRaidPhases: 0, characters, lootRolls: {}, } writeOfflineSave(save) writeMode('offline') return { account: offlineAccount, profile: buildProfile(save) } } export function resumeOfflineCharacter(): AuthSession | null { const save = readOfflineSave() if (!save) return null writeMode('offline') return { account: offlineAccount, profile: buildProfile(save) } } export function hasOfflineCharacter(): boolean { return readOfflineSave() !== null } export function activeGameRepository(): GameRepository { return readMode() === 'offline' ? offlineRepository : serverRepository }