import starterProfile from './offline-starter-profile.json' import type { Account, 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' lootSourceEncounterId?: number roguelikeStage?: number }, ): Promise allocateTalent(talentId: number): Promise resetTalents(): Promise equipItem(itemId: number): Promise discardExtraItem(itemId: number): Promise breakdownItem(itemId: number): Promise craftItem(recipeId: number): Promise upgradeItem(itemId: 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 } type OnlineCache = { version: 1 account: Account save: OfflineSave dirty: boolean } export type CloudSyncStatus = { available: boolean dirty: boolean } type RepositoryMode = 'online' | 'offline-local' | 'offline-cached' type NetworkError = Error & { network?: boolean } type LocalSaveStore = { readSave: () => OfflineSave | null writeSave: (save: OfflineSave) => void readAccount: () => Account | null } const modeKey = 'chronicle.repositoryMode' const offlineSaveKey = 'chronicle.offlineSave.v1' const onlineCacheKey = 'chronicle.onlineCache.v1' const authTokenKey = 'chronicle.authToken.v1' const offlineAccount = { id: -1, username: 'Offline' } function clone(value: T): T { return structuredClone(value) } function toGameMode(mode: RepositoryMode): GameMode { return mode === 'online' ? 'online' : 'offline' } function dispatchModeChange(mode: RepositoryMode) { if (typeof window === 'undefined') return window.dispatchEvent(new CustomEvent('chronicle:mode-changed', { detail: toGameMode(mode), })) } function readMode(): RepositoryMode { const stored = localStorage.getItem(modeKey) if (stored === 'offline-cached' || stored === 'offline-local' || stored === 'online') { return stored } if (stored === 'offline') return 'offline-local' return 'online' } function writeMode(mode: RepositoryMode) { localStorage.setItem(modeKey, mode) dispatchModeChange(mode) } function upgradeV1Save(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) : [], } } return { version: 3, characterName: p.character.name, activeClassId: p.character.classId, completedDungeonParts: p.completedDungeonParts, completedRaidPhases: p.completedRaidPhases ?? 0, characters, lootRolls: v1.lootRolls ?? {}, } } function upgradeV2Save(v2: Omit & { version: 2 }): OfflineSave { return { ...v2, version: 3, completedRaidPhases: 0, } } function normalizeOfflineSave(raw: unknown): OfflineSave | null { if (!raw || typeof raw !== 'object') return null const candidate = raw as { version?: number profile?: CharacterProfile lootRolls?: Record } if (candidate.version === 3) return candidate as OfflineSave if (candidate.version === 2) { return upgradeV2Save(candidate as Omit & { version: 2 }) } if (candidate.version === 1 && candidate.profile) { return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record }) } return null } function readSaveKey(key: string): OfflineSave | null { const serialized = localStorage.getItem(key) if (!serialized) return null try { const raw = JSON.parse(serialized) const save = normalizeOfflineSave(raw) if (!save) return null if (raw.version !== 3) { localStorage.setItem(key, JSON.stringify(save)) } return save } catch { return null } } function readOfflineSave(): OfflineSave | null { return readSaveKey(offlineSaveKey) } function writeOfflineSave(save: OfflineSave) { localStorage.setItem(offlineSaveKey, JSON.stringify(save)) } function readOnlineCache(): OnlineCache | null { const serialized = localStorage.getItem(onlineCacheKey) if (!serialized) return null try { const raw = JSON.parse(serialized) as { version?: number account?: Account save?: unknown dirty?: boolean } if ( raw.version !== 1 || !raw.account || typeof raw.account.id !== 'number' || typeof raw.account.username !== 'string' ) { return null } const save = normalizeOfflineSave(raw.save) if (!save) return null const cache: OnlineCache = { version: 1, account: raw.account, save, dirty: Boolean(raw.dirty), } if ((raw.save as { version?: number } | undefined)?.version !== 3) { writeOnlineCache(cache) } return cache } catch { return null } } function writeOnlineCache(cache: OnlineCache) { localStorage.setItem(onlineCacheKey, JSON.stringify(cache)) } function clearOnlineCache() { localStorage.removeItem(onlineCacheKey) } 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.' }, 10: { id: 602, slug: 'refined-component', name: 'Refined Component', itemLevel: 10, glyph: '◈', description: 'A refined 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 CAPACITOR_AUTH_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 mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineSave): OfflineSave { const characters = clone(existingSave?.characters ?? {}) for (const gameClass of profile.classes) { if (!characters[gameClass.id]) { characters[gameClass.id] = emptyCharacterData(gameClass.id) } } const activeClass = profile.classes.find((candidate) => candidate.id === profile.character.classId) if (!activeClass) { throw new Error('The active class does not exist in the cached profile.') } const talentRanks: Record = {} for (const talent of activeClass.talents) { talentRanks[String(talent.id)] = talent.rank } characters[profile.character.classId] = { level: profile.character.level, experience: profile.character.experience, talentPoints: profile.character.talentPoints, abilitySlots: [...profile.abilitySlots], talentRanks, inventory: clone(profile.inventory), } return { version: 3, characterName: profile.character.name, activeClassId: profile.character.classId, completedDungeonParts: profile.completedDungeonParts, completedRaidPhases: profile.completedRaidPhases, characters, lootRolls: clone(existingSave?.lootRolls ?? {}), } } 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 coinDropQuantity() { const roll = Math.random() if (roll < 0.15) return 3 if (roll < 0.5) return 2 return 1 } function roguelikeCoinItemLevel(stage: number) { return Math.min(25, 5 + Math.max(0, Math.floor(stage / 5)) * 5) } function awardRoguelikeCoin( profile: CharacterProfile, sourceEncounterId: number | undefined, stage: number | undefined, ): DungeonReward['bonusItem'] { if (!sourceEncounterId || !stage) return null const targetItemLevel = roguelikeCoinItemLevel(stage) const sourceEncounter = profile.dungeons .flatMap((dungeon) => dungeon.encounters) .find((encounter) => encounter.id === sourceEncounterId) const coin = sourceEncounter?.lootTables .filter((entry) => entry.itemLevel === targetItemLevel) .sort((left, right) => left.difficultyId - right.difficultyId)[0] if (!coin) return null const { encounterId: _encounterId, difficultyId: _difficultyId, dropWeight: _dropWeight, dropChance: _dropChance, ...coinItem } = coin void _encounterId void _difficultyId void _dropWeight void _dropChance const quantity = coinDropQuantity() const added = addInventoryItem(profile.inventory, { ...coinItem, slot: coinItem.slot as EquipmentSlot, rarity: coinItem.rarity as Item['rarity'], }, quantity) return { ...coinItem, quantity, duplicate: added.duplicate, quantityAfter: added.quantityAfter, } } function readAuthToken(): string { return localStorage.getItem(authTokenKey) ?? '' } function writeAuthToken(token: string) { localStorage.setItem(authTokenKey, token) } function clearAuthToken() { localStorage.removeItem(authTokenKey) } function configuredBaseUrl(value: string | undefined): string { return value ? value.replace(/\/+$/, '') : '' } function getApiBaseUrl(path: string): string { const browserWindow = typeof window === 'undefined' ? undefined : window as WindowWithApiBase if (path.startsWith('/api/auth/')) { if (browserWindow?.CAPACITOR_AUTH_API_BASE_URL) { return configuredBaseUrl(browserWindow.CAPACITOR_AUTH_API_BASE_URL) } if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_AUTH_API_BASE_URL) { return configuredBaseUrl(import.meta.env.VITE_AUTH_API_BASE_URL) } } if (browserWindow?.CAPACITOR_API_BASE_URL) { return configuredBaseUrl(browserWindow.CAPACITOR_API_BASE_URL) } if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_API_BASE_URL) { return configuredBaseUrl(import.meta.env.VITE_API_BASE_URL) } return '' } async function requestJson(path: string, init?: RequestInit): Promise { const baseUrl = getApiBaseUrl(path) const url = baseUrl ? `${baseUrl}${path}` : path const headers = new Headers(init?.headers) const token = readAuthToken() if (token && !headers.has('Authorization')) { headers.set('Authorization', `Bearer ${token}`) } let response: Response try { response = await fetch(url, { ...init, headers, }) } catch (reason) { const networkError = new Error('Unable to reach the game server.') as NetworkError networkError.network = true networkError.cause = reason throw networkError } const body = await response.json() if (!response.ok) throw new Error(body.error ?? 'Unable to reach the game server.') return body } function isNetworkError(reason: unknown): reason is NetworkError { return reason instanceof Error && Boolean((reason as NetworkError).network) } function cachedOnlineSession(): AuthSession | null { const cache = readOnlineCache() if (!cache) return null return { account: cache.account, profile: buildProfile(cache.save), } } function resumeCachedOnlineSession(): AuthSession | null { const session = cachedOnlineSession() if (!session) return null writeMode('offline-cached') return session } function cacheActiveOnlineProfile(profile: CharacterProfile, dirty = false) { const cache = readOnlineCache() if (!cache) return writeOnlineCache({ ...cache, save: mergeProfileIntoSave(profile, cache.save), dirty, }) } async function loadServerSyncSave(): Promise { return requestJson('/api/profile/sync-save') } async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: CharacterProfile; save: OfflineSave }> { return requestJson('/api/profile/sync-save', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ save }), }) } async function finalizeOnlineSession(session: AuthSession): Promise { const cache = readOnlineCache() if (session.token) writeAuthToken(session.token) if (!session.account || !session.profile) { if (session.account && cache?.account.id === session.account.id) { return { account: session.account, profile: buildProfile(cache.save), token: session.token, } } return session } if (cache?.account.id === session.account.id && cache.dirty) { writeOnlineCache({ ...cache, account: session.account, }) return { account: session.account, profile: buildProfile(cache.save), token: session.token, } } try { const save = await loadServerSyncSave() writeOnlineCache({ version: 1, account: session.account, save, dirty: false, }) } catch { writeOnlineCache({ version: 1, account: session.account, save: mergeProfileIntoSave(session.profile, cache?.account.id === session.account.id ? cache.save : undefined), dirty: false, }) } return session } async function fallbackToCachedOnline(reason: unknown, localAction: () => Promise): Promise { if (!isNetworkError(reason) || !readOnlineCache()) throw reason writeMode('offline-cached') return localAction() } async function withOnlineFallback( onlineAction: () => Promise, localAction: () => Promise, onSuccess?: (result: T) => void | Promise, ): Promise { try { const result = await onlineAction() await onSuccess?.(result) return result } catch (reason) { return fallbackToCachedOnline(reason, localAction) } } async function loadOnlineSessionFromServer(): Promise { const session = await requestJson('/api/auth/session') return finalizeOnlineSession(session) } const serverRepository: GameRepository = { loadSession: async () => { try { return await loadOnlineSessionFromServer() } catch (reason) { if (isNetworkError(reason)) { const fallback = resumeCachedOnlineSession() if (fallback) return fallback } throw reason } }, register: async (username, password, characterName) => finalizeOnlineSession(await requestJson('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, characterName }), })), login: async (username, password) => finalizeOnlineSession(await requestJson('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), })), logout: async () => { try { await requestJson('/api/auth/logout', { method: 'POST' }) } catch (reason) { if (!isNetworkError(reason)) throw reason } clearAuthToken() clearOnlineCache() writeMode('online') }, loadProfile: () => readOnlineCache() ? cachedOnlineLocalRepository.loadProfile() : withOnlineFallback( () => requestJson('/api/profile'), () => cachedOnlineLocalRepository.loadProfile(), (profile) => { cacheActiveOnlineProfile(profile) }, ), saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => cachedOnlineLocalRepository.completeDungeon( dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, ), completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => cachedOnlineLocalRepository.completeRoguelike( dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options, ), allocateTalent: (talentId) => cachedOnlineLocalRepository.allocateTalent(talentId), resetTalents: () => cachedOnlineLocalRepository.resetTalents(), equipItem: (itemId) => cachedOnlineLocalRepository.equipItem(itemId), discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId), breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId), craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId), upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId), rollEncounterLoot: (encounterId, difficultyId, runToken) => cachedOnlineLocalRepository.rollEncounterLoot(encounterId, 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 inventory: Item[] = [] 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, } } function requireStoredSave(store: LocalSaveStore): OfflineSave { const save = store.readSave() if (!save) throw new Error('No local character exists yet.') return save } function createLocalRepository(store: LocalSaveStore): GameRepository { return { async loadSession() { const save = store.readSave() return { account: save ? (store.readAccount() ?? 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(requireStoredSave(store)) }, async saveProfile(classId, abilitySlots) { const save = requireStoredSave(store) 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 store.writeSave(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 = requireStoredSave(store) 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, quantity: 1, duplicate, quantityAfter } } } store.writeSave(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 = requireStoredSave(store) 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, ) const bonusItem = awardRoguelikeCoin( profile, options?.lootSourceEncounterId, options?.roguelikeStage, ) cd.inventory = profile.inventory store.writeSave(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, profile: updatedProfile, } }, async allocateTalent(talentId) { const save = requireStoredSave(store) 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 store.writeSave(save) return buildProfile(save) }, async resetTalents() { const save = requireStoredSave(store) 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, ) store.writeSave(save) return buildProfile(save) }, async equipItem(itemId) { const save = requireStoredSave(store) 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 store.writeSave(save) return buildProfile(save) }, async discardExtraItem(itemId) { const save = requireStoredSave(store) 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 store.writeSave(save) return buildProfile(save) }, async breakdownItem(itemId) { const save = requireStoredSave(store) 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 store.writeSave(save) return buildProfile(save) }, async craftItem(recipeId) { const save = requireStoredSave(store) 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 requiresUpgrade = profile.craftingRecipes.some((candidate) => candidate.sourceEncounterId === recipe.sourceEncounterId && candidate.item.slot === recipe.item.slot && candidate.item.itemLevel < recipe.item.itemLevel, ) if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.') 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 store.writeSave(save) return buildProfile(save) }, async upgradeItem(itemId) { const save = requireStoredSave(store) 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 upgraded.') const currentRecipe = profile.craftingRecipes.find((recipe) => recipe.item.id === item.id) const targetRecipe = currentRecipe ? profile.craftingRecipes .filter((recipe) => recipe.sourceEncounterId === currentRecipe.sourceEncounterId && recipe.item.slot === item.slot && recipe.item.itemLevel > item.itemLevel, ) .sort((left, right) => left.item.itemLevel - right.item.itemLevel)[0] : null if (!targetRecipe) throw new Error('No upgrade is available for this item.') const missing = targetRecipe.components.find((component) => component.owned < component.quantity) if (missing) { throw new Error(`Need ${missing.quantity} ${missing.item.name} to upgrade this item.`) } for (const component of targetRecipe.components) { const owned = profile.inventory.find((candidate) => candidate.id === component.item.id) if (!owned) throw new Error(`Need ${component.quantity} ${component.item.name} to upgrade this item.`) owned.quantity -= component.quantity } const wasEquipped = item.equipped item.quantity -= 1 item.equipped = false for (let index = profile.inventory.length - 1; index >= 0; index -= 1) { if (profile.inventory[index].quantity <= 0) profile.inventory.splice(index, 1) } if (wasEquipped) { for (const candidate of profile.inventory) { if (candidate.slot === targetRecipe.item.slot) candidate.equipped = false } } addInventoryItem(profile.inventory, targetRecipe.item, 1) if (wasEquipped) { const upgraded = profile.inventory.find((candidate) => candidate.id === targetRecipe.item.id) if (upgraded) upgraded.equipped = true } save.characters[save.activeClassId].inventory = profile.inventory store.writeSave(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 = requireStoredSave(store) 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() if (Math.random() < dropChance) { const selected = rollWeightedLootEntry(entries) selectedQuantities.set(selected.id, { entry: selected, quantity: coinDropQuantity(), }) } 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 store.writeSave(save) return clone(result) }, } } const offlineRepository = createLocalRepository({ readSave: readOfflineSave, writeSave: writeOfflineSave, readAccount: () => offlineAccount, }) const cachedOnlineLocalRepository = createLocalRepository({ readSave: () => readOnlineCache()?.save ?? null, writeSave: (save) => { const cache = readOnlineCache() if (!cache) throw new Error('No cached account save exists yet.') writeOnlineCache({ ...cache, save, dirty: true, }) }, readAccount: () => readOnlineCache()?.account ?? null, }) const cachedOnlineRepository: GameRepository = { async loadSession() { try { const session = await loadOnlineSessionFromServer() if (session.account && session.profile) { writeMode('online') return session } } catch { // Fall through to local cached mirror. } return cachedOnlineLocalRepository.loadSession() }, async register() { throw new Error('Account registration requires online mode.') }, async login() { throw new Error('Account login requires online mode.') }, async logout() { clearAuthToken() clearOnlineCache() writeMode('online') }, loadProfile: () => cachedOnlineLocalRepository.loadProfile(), saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => cachedOnlineLocalRepository.completeDungeon( dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, ), completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => cachedOnlineLocalRepository.completeRoguelike( dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options, ), allocateTalent: (talentId) => cachedOnlineLocalRepository.allocateTalent(talentId), resetTalents: () => cachedOnlineLocalRepository.resetTalents(), equipItem: (itemId) => cachedOnlineLocalRepository.equipItem(itemId), discardExtraItem: (itemId) => cachedOnlineLocalRepository.discardExtraItem(itemId), breakdownItem: (itemId) => cachedOnlineLocalRepository.breakdownItem(itemId), craftItem: (recipeId) => cachedOnlineLocalRepository.craftItem(recipeId), upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId), rollEncounterLoot: (encounterId, difficultyId, runToken) => cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), } export function getGameMode(): GameMode { return toGameMode(readMode()) } export function getCloudSyncStatus(): CloudSyncStatus { const cache = readOnlineCache() return { available: Boolean(cache), dirty: Boolean(cache?.dirty), } } export async function syncCloudSave(): Promise { const cache = readOnlineCache() if (!cache) { throw new Error('No signed-in save is available for cloud sync.') } const synced = await pushServerSyncSave(cache.save) writeOnlineCache({ version: 1, account: cache.account, save: synced.save, dirty: false, }) writeMode('online') return synced.profile } 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-local') return { account: offlineAccount, profile: buildProfile(save) } } export function resumeOfflineCharacter(): AuthSession | null { const save = readOfflineSave() if (!save) return null writeMode('offline-local') return { account: offlineAccount, profile: buildProfile(save) } } export function hasOfflineCharacter(): boolean { return readOfflineSave() !== null } export function activeGameRepository(): GameRepository { const mode = readMode() if (mode === 'offline-local') return offlineRepository if (mode === 'offline-cached') return cachedOnlineRepository return serverRepository }