made some changes to the UI, removed leaderboards. updated gamesaves
This commit is contained in:
+508
-135
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user