made some changes to the UI, removed leaderboards. updated gamesaves

This commit is contained in:
Warren H
2026-06-18 13:00:29 -04:00
parent 3c90998a61
commit a604569a2f
44 changed files with 2301 additions and 435 deletions
+508 -135
View File
@@ -1,5 +1,6 @@
import starterProfile from './offline-starter-profile.json'
import type {
Account,
AuthSession,
CharacterProfile,
DungeonReward,
@@ -69,37 +70,65 @@ type OfflineSave = {
lootRolls: Record<string, LootRoll>
}
const modeKey = 'chronicle.gameMode'
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 offlineAccount = { id: -1, username: 'Offline' }
function clone<T>(value: T): T {
return structuredClone(value)
}
function readMode(): GameMode {
return localStorage.getItem(modeKey) === 'offline' ? 'offline' : 'online'
function toGameMode(mode: RepositoryMode): GameMode {
return mode === 'online' ? 'online' : 'offline'
}
function writeMode(mode: GameMode) {
localStorage.setItem(modeKey, mode)
function dispatchModeChange(mode: RepositoryMode) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent<GameMode>('chronicle:mode-changed', {
detail: toGameMode(mode),
}))
}
function readOfflineSave(): OfflineSave | null {
const serialized = localStorage.getItem(offlineSaveKey)
if (!serialized) return null
try {
const raw = JSON.parse(serialized)
if (raw.version === 3) return raw as OfflineSave
if (raw.version === 2) return migrateV2ToV3(raw)
if (raw.version === 1) return migrateV1ToV2(raw)
return null
} catch {
return null
function 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 migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }): OfflineSave {
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> = {}
@@ -118,7 +147,7 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
inventory: cid === p.character.classId ? clone(p.inventory) : [],
}
}
const v2: OfflineSave = {
return {
version: 3,
characterName: p.character.name,
activeClassId: p.character.classId,
@@ -127,28 +156,98 @@ function migrateV1ToV2(v1: { profile: CharacterProfile; lootRolls: Record<string
characters,
lootRolls: v1.lootRolls ?? {},
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v2))
return v2
}
function migrateV2ToV3(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
const v3: OfflineSave = {
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return {
...v2,
version: 3,
completedRaidPhases: 0,
}
localStorage.setItem(offlineSaveKey, JSON.stringify(v3))
return v3
}
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 requireOfflineSave(): OfflineSave {
const save = readOfflineSave()
if (!save) throw new Error('No offline character exists yet.')
return 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 {
@@ -309,6 +408,40 @@ function componentDropQuantity(itemLevel: number) {
return 1 + (Math.random() < secondChance ? 1 : 0) + (Math.random() < thirdChance ? 1 : 0)
}
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
@@ -335,66 +468,197 @@ function getApiBaseUrl(): string {
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const baseUrl = getApiBaseUrl()
const url = baseUrl ? `${baseUrl}${path}` : path
const response = await fetch(url, init)
let response: Response
try {
response = await fetch(url, init)
} 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> {
if (!session.account || !session.profile) return session
const cache = readOnlineCache()
if (cache?.account.id === session.account.id && cache.dirty) {
writeOnlineCache({
...cache,
account: session.account,
})
return {
account: session.account,
profile: buildProfile(cache.save),
}
}
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: () => requestJson('/api/auth/session'),
register: (username, password, characterName) =>
requestJson('/api/auth/register', {
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: (username, password) =>
requestJson('/api/auth/login', {
})),
login: async (username, password) =>
finalizeOnlineSession(await requestJson('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
})),
logout: async () => {
await requestJson('/api/auth/logout', { method: 'POST' })
try {
await requestJson('/api/auth/logout', { method: 'POST' })
} catch (reason) {
if (!isNetworkError(reason)) throw reason
}
clearOnlineCache()
writeMode('online')
},
loadProfile: () => requestJson('/api/profile'),
loadProfile: () =>
readOnlineCache()
? cachedOnlineLocalRepository.loadProfile()
: withOnlineFallback(
() => requestJson('/api/profile'),
() => cachedOnlineLocalRepository.loadProfile(),
(profile) => {
cacheActiveOnlineProfile(profile)
},
),
saveProfile: (classId, abilitySlots) =>
requestJson('/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ classId, abilitySlots }),
}),
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) =>
requestJson(`/api/dungeons/${dungeonId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds }),
}),
cachedOnlineLocalRepository.completeDungeon(
dungeonId,
difficultyId,
resourceSpent,
durationSeconds,
completedPart,
startPart,
partDurationSeconds,
),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
requestJson('/api/roguelike/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, ...options }),
}),
cachedOnlineLocalRepository.completeRoguelike(
dungeonId,
difficultyId,
encountersCleared,
resourceSpent,
durationSeconds,
options,
),
allocateTalent: (talentId) =>
requestJson(`/api/talents/${talentId}/allocate`, { method: 'POST' }),
cachedOnlineLocalRepository.allocateTalent(talentId),
resetTalents: () =>
requestJson('/api/talents/reset', { method: 'POST' }),
cachedOnlineLocalRepository.resetTalents(),
equipItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/equip`, { method: 'POST' }),
cachedOnlineLocalRepository.equipItem(itemId),
discardExtraItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/discard-extra`, { method: 'POST' }),
cachedOnlineLocalRepository.discardExtraItem(itemId),
breakdownItem: (itemId) =>
requestJson(`/api/equipment/${itemId}/breakdown`, { method: 'POST' }),
cachedOnlineLocalRepository.breakdownItem(itemId),
craftItem: (recipeId) =>
requestJson(`/api/crafting/recipes/${recipeId}/craft`, { method: 'POST' }),
cachedOnlineLocalRepository.craftItem(recipeId),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
requestJson(`/api/encounters/${encounterId}/loot-roll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ difficultyId, runToken }),
}),
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
function emptyCharacterData(classId: number): CharacterData {
@@ -419,28 +683,35 @@ function emptyCharacterData(classId: number): CharacterData {
}
}
const offlineRepository: GameRepository = {
async loadSession() {
const save = readOfflineSave()
return {
account: save ? offlineAccount : null,
profile: save ? buildProfile(save) : null,
}
},
async register() {
throw new Error('Account registration requires online mode.')
},
async login() {
throw new Error('Account login requires online mode.')
},
async logout() {
writeMode('online')
},
async loadProfile() {
return buildProfile(requireOfflineSave())
},
async saveProfile(classId, abilitySlots) {
const save = requireOfflineSave()
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.')
@@ -466,10 +737,10 @@ const offlineRepository: GameRepository = {
}
save.characters[classId].abilitySlots = slots
save.activeClassId = classId
writeOfflineSave(save)
return buildProfile(save)
},
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) {
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) {
@@ -478,7 +749,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
@@ -560,7 +831,7 @@ const offlineRepository: GameRepository = {
}
}
writeOfflineSave(save)
store.writeSave(save)
const updatedProfile = buildProfile(save)
return {
@@ -579,8 +850,8 @@ const offlineRepository: GameRepository = {
bonusItem,
profile: updatedProfile,
}
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
},
async completeRoguelike(dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) {
if (!Number.isInteger(encountersCleared) || encountersCleared < 0) {
throw new Error('The roguelike progress total is invalid.')
}
@@ -590,7 +861,7 @@ const offlineRepository: GameRepository = {
if (!Number.isInteger(durationSeconds) || durationSeconds < 1) {
throw new Error('The run duration is invalid.')
}
const save = requireOfflineSave()
const save = requireStoredSave(store)
const profile = buildProfile(save)
const dungeon = profile.dungeons.find((candidate) => candidate.id === dungeonId)
const difficulty = dungeon?.difficulties.find(
@@ -644,7 +915,7 @@ const offlineRepository: GameRepository = {
cd.talentPoints + levelsGained,
)
writeOfflineSave(save)
store.writeSave(save)
const updatedProfile = buildProfile(save)
return {
@@ -663,9 +934,9 @@ const offlineRepository: GameRepository = {
bonusItem: null,
profile: updatedProfile,
}
},
async allocateTalent(talentId) {
const save = requireOfflineSave()
},
async allocateTalent(talentId) {
const save = requireStoredSave(store)
const profile = buildProfile(save)
const cd = save.characters[save.activeClassId]
const gameClass = profile.classes.find(
@@ -698,11 +969,11 @@ const offlineRepository: GameRepository = {
}
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
cd.talentPoints -= 1
writeOfflineSave(save)
return buildProfile(save)
},
async resetTalents() {
const save = requireOfflineSave()
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(
@@ -719,11 +990,11 @@ const offlineRepository: GameRepository = {
profile.maxTalentPoints,
cd.talentPoints + refunded,
)
writeOfflineSave(save)
return buildProfile(save)
},
async equipItem(itemId) {
const save = requireOfflineSave()
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.')
@@ -731,22 +1002,22 @@ const offlineRepository: GameRepository = {
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async discardExtraItem(itemId) {
const save = requireOfflineSave()
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
writeOfflineSave(save)
return buildProfile(save)
},
async breakdownItem(itemId) {
const save = requireOfflineSave()
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.')
@@ -785,11 +1056,11 @@ const offlineRepository: GameRepository = {
}
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async craftItem(recipeId) {
const save = requireOfflineSave()
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.')
@@ -809,14 +1080,14 @@ const offlineRepository: GameRepository = {
addInventoryItem(profile.inventory, recipe.item, 1)
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return buildProfile(save)
},
async rollEncounterLoot(encounterId, difficultyId, runToken) {
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 = requireOfflineSave()
const save = requireStoredSave(store)
const rollKey = `${runToken}:${encounterId}:${difficultyId}`
if (save.lootRolls[rollKey]) return clone(save.lootRolls[rollKey])
@@ -894,13 +1165,112 @@ const offlineRepository: GameRepository = {
}
save.lootRolls[rollKey] = result
save.characters[save.activeClassId].inventory = profile.inventory
writeOfflineSave(save)
return clone(result)
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() {
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),
rollEncounterLoot: (encounterId, difficultyId, runToken) =>
cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken),
}
export function getGameMode(): GameMode {
return readMode()
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() {
@@ -926,14 +1296,14 @@ export function createOfflineCharacter(characterName: string): AuthSession {
lootRolls: {},
}
writeOfflineSave(save)
writeMode('offline')
writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) }
}
export function resumeOfflineCharacter(): AuthSession | null {
const save = readOfflineSave()
if (!save) return null
writeMode('offline')
writeMode('offline-local')
return { account: offlineAccount, profile: buildProfile(save) }
}
@@ -942,5 +1312,8 @@ export function hasOfflineCharacter(): boolean {
}
export function activeGameRepository(): GameRepository {
return readMode() === 'offline' ? offlineRepository : serverRepository
const mode = readMode()
if (mode === 'offline-local') return offlineRepository
if (mode === 'offline-cached') return cachedOnlineRepository
return serverRepository
}