1461 lines
49 KiB
TypeScript
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
|
|
}
|