Files
i-want-to-heal/src/gameRepository.ts
T
Warren H 3a8d5ad8c5 changes
2026-06-18 22:28:04 -04:00

1461 lines
49 KiB
TypeScript

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<AuthSession>
register(username: string, password: string, characterName: string): Promise<AuthSession>
login(username: string, password: string): Promise<AuthSession>
logout(): Promise<void>
loadProfile(): Promise<CharacterProfile>
saveProfile(classId: number, abilitySlots: Array<number | null>): Promise<CharacterProfile>
completeDungeon(
dungeonId: number,
difficultyId: number,
resourceSpent: number,
durationSeconds: number,
completedPart?: number,
startPart?: number,
partDurationSeconds?: [number, number, number],
): Promise<DungeonReward>
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<DungeonReward>
allocateTalent(talentId: number): Promise<CharacterProfile>
resetTalents(): Promise<CharacterProfile>
equipItem(itemId: number): Promise<CharacterProfile>
discardExtraItem(itemId: number): Promise<CharacterProfile>
breakdownItem(itemId: number): Promise<CharacterProfile>
craftItem(recipeId: number): Promise<CharacterProfile>
upgradeItem(itemId: number): Promise<CharacterProfile>
rollEncounterLoot(
encounterId: number,
difficultyId: number,
runToken: string,
): Promise<LootRoll>
}
type CharacterData = {
level: number
experience: number
talentPoints: number
abilitySlots: Array<number | null>
talentRanks: Record<string, number>
inventory: Item[]
}
type OfflineSave = {
version: 3
characterName: string
activeClassId: number
completedDungeonParts: number
completedRaidPhases: number
characters: Record<number, CharacterData>
lootRolls: Record<string, LootRoll>
}
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<T>(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<GameMode>('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<string, LootRoll> }): OfflineSave {
const p = v1.profile
const classes = [1, 2, 3]
const characters: Record<number, CharacterData> = {}
for (const cid of classes) {
const gameClass = p.classes.find((c) => c.id === cid)!
const talentRanks: Record<string, number> = {}
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<OfflineSave, 'version' | 'completedRaidPhases'> & { 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<string, LootRoll>
}
if (candidate.version === 3) return candidate as OfflineSave
if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
}
if (candidate.version === 1 && candidate.profile) {
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
}
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<number, number>()
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<Item, 'quantity' | 'equipped'>, 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<number, ComponentTemplate> = {
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
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<string, number> = {}
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<T extends { dropWeight: number }>(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<T>(path: string, init?: RequestInit): Promise<T> {
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<OfflineSave> {
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<AuthSession> {
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<T>(reason: unknown, localAction: () => Promise<T>): Promise<T> {
if (!isNetworkError(reason) || !readOnlineCache()) throw reason
writeMode('offline-cached')
return localAction()
}
async function withOnlineFallback<T>(
onlineAction: () => Promise<T>,
localAction: () => Promise<T>,
onSuccess?: (result: T) => void | Promise<void>,
): Promise<T> {
try {
const result = await onlineAction()
await onSuccess?.(result)
return result
} catch (reason) {
return fallbackToCachedOnline(reason, localAction)
}
}
async function loadOnlineSessionFromServer(): Promise<AuthSession> {
const session = await requestJson<AuthSession>('/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<string, number> = {}
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<number | null> = 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 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.find((recipe) =>
recipe.sourceEncounterId === currentRecipe.sourceEncounterId
&& recipe.item.slot === item.slot
&& recipe.item.itemLevel === item.itemLevel + 5,
)
: 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<number, { entry: typeof entries[number]; quantity: number }>()
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<CharacterProfile> {
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<number, CharacterData> = {}
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
}