diff --git a/IWantToHeal-Thor-v1.0.50.apk b/IWantToHeal-Thor-v1.0.50.apk new file mode 100644 index 0000000..3fbdcda Binary files /dev/null and b/IWantToHeal-Thor-v1.0.50.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 5c6a8a8..9cc0cd1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 68 - versionName "1.0.49" + versionCode 69 + versionName "1.0.50" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/scripts/export-offline-profile.mjs b/scripts/export-offline-profile.mjs index 8ca48ef..f2eb613 100644 --- a/scripts/export-offline-profile.mjs +++ b/scripts/export-offline-profile.mjs @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' +import { catalogPayload } from '../server/catalog.mjs' import { getProfile } from '../server/game-api.mjs' const database = new DatabaseSync(':memory:') @@ -7,10 +8,14 @@ const database = new DatabaseSync(':memory:') try { database.exec(readFileSync('db/schema.sql', 'utf8')) database.exec(readFileSync('db/seed.sql', 'utf8')) - const profile = getProfile(database, 1) + const catalog = catalogPayload(getProfile(database, 1)) writeFileSync( 'src/offline-starter-profile.json', - `${JSON.stringify(profile, null, 2)}\n`, + `${JSON.stringify(catalog.profile, null, 2)}\n`, + ) + writeFileSync( + 'src/offline-catalog-meta.ts', + `export const bundledCatalogHash = '${catalog.hash}'\n`, ) console.log('Offline starter profile exported from SQLite.') } finally { diff --git a/server/catalog.mjs b/server/catalog.mjs new file mode 100644 index 0000000..d22e43c --- /dev/null +++ b/server/catalog.mjs @@ -0,0 +1,74 @@ +import { createHash } from 'node:crypto' + +function normalizeRecipe(recipe) { + return { + ...recipe, + components: recipe.components.map((component) => ({ + ...component, + owned: 0, + })), + canCraft: false, + } +} + +function normalizeDungeon(dungeon) { + return { + ...dungeon, + completionCount: 0, + leaderboard: [], + leaderboards: { + part_1: [], + part_2: [], + part_3: [], + full_run: [], + }, + } +} + +export function normalizeCatalogProfile(profile) { + return { + ...profile, + character: { + ...profile.character, + id: 1, + name: 'Mira', + level: 1, + experience: 0, + talentPoints: 1, + currentLevelExperience: 0, + nextLevelExperience: 100, + }, + abilitySlots: profile.abilitySlots, + allocatedTalentPoints: 0, + inventory: [], + completedDungeonParts: 0, + completedRaidPhases: 0, + gearStats: { + averageItemLevel: 0, + healingPower: 0, + maxResourceBonus: 0, + }, + setBonuses: profile.setBonuses.map((bonus) => ({ + ...bonus, + equippedPieces: 0, + active: false, + })), + craftingRecipes: profile.craftingRecipes.map(normalizeRecipe), + dungeons: profile.dungeons.map(normalizeDungeon), + } +} + +export function catalogHash(profile) { + return createHash('sha256') + .update(JSON.stringify(profile)) + .digest('hex') +} + +export function catalogPayload(profile) { + const normalized = normalizeCatalogProfile(profile) + return { + version: 1, + hash: catalogHash(normalized), + profile: normalized, + } +} diff --git a/server/game-api.mjs b/server/game-api.mjs index 3b21170..a0fffe1 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -9,6 +9,7 @@ import { import { isIP } from 'node:net' import { extname, resolve, sep } from 'node:path' import { DatabaseSync } from 'node:sqlite' +import { catalogPayload } from './catalog.mjs' const databasePath = fileURLToPath(new URL('../data/game.db', import.meta.url)) const bossImageDirectory = fileURLToPath(new URL('../data/uploads/bosses/', import.meta.url)) @@ -2696,6 +2697,11 @@ export async function handleApiRequest(request, response, next) { return } + if (request.url === '/api/catalog' && request.method === 'GET') { + sendJson(response, 200, catalogPayload(getProfile(database, 1))) + return + } + const session = requireSession(database, request) if (request.url === '/api/profile/sync-save' && request.method === 'GET') { diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 3fbf340..01f3429 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -1,4 +1,5 @@ import starterProfile from './offline-starter-profile.json' +import { bundledCatalogHash } from './offline-catalog-meta' import type { Account, AuthSession, @@ -82,6 +83,12 @@ type OnlineCache = { dirty: boolean } +type CatalogCache = { + version: 1 + hash: string + profile: CharacterProfile +} + export type CloudSyncStatus = { available: boolean dirty: boolean @@ -102,6 +109,8 @@ type LocalSaveStore = { const modeKey = 'chronicle.repositoryMode' const offlineSaveKey = 'chronicle.offlineSave.v1' const onlineCacheKey = 'chronicle.onlineCache.v1' +const catalogCacheKey = 'chronicle.catalog.v1' +const catalogBundleKey = 'chronicle.catalog.bundleHash.v1' const authTokenKey = 'chronicle.authToken.v1' const offlineAccount = { id: -1, username: 'Offline' } const ABILITY_SLOT_COUNT = 6 @@ -281,8 +290,42 @@ function clearOnlineCache() { localStorage.removeItem(onlineCacheKey) } +function bundledCatalog(): CatalogCache { + return { + version: 1, + hash: bundledCatalogHash, + profile: starterProfile as CharacterProfile, + } +} + +function readCatalogCache(): CatalogCache | null { + if (localStorage.getItem(catalogBundleKey) !== bundledCatalogHash) { + localStorage.removeItem(catalogCacheKey) + localStorage.setItem(catalogBundleKey, bundledCatalogHash) + return null + } + const serialized = localStorage.getItem(catalogCacheKey) + if (!serialized) return null + try { + const raw = JSON.parse(serialized) as CatalogCache + if (raw.version !== 1 || typeof raw.hash !== 'string' || !raw.profile) return null + return raw + } catch { + return null + } +} + +function writeCatalogCache(cache: CatalogCache) { + localStorage.setItem(catalogBundleKey, bundledCatalogHash) + localStorage.setItem(catalogCacheKey, JSON.stringify(cache)) +} + +function activeCatalog(): CatalogCache { + return readCatalogCache() ?? bundledCatalog() +} + function buildProfile(save: OfflineSave): CharacterProfile { - const static_ = clone(starterProfile) as CharacterProfile + const static_ = clone(activeCatalog().profile) const cd = save.characters[save.activeClassId] const gameClass = static_.classes.find((c) => c.id === save.activeClassId)! @@ -657,6 +700,23 @@ function isNetworkError(reason: unknown): reason is NetworkError { return reason instanceof Error && Boolean((reason as NetworkError).network) } +async function loadServerCatalog(): Promise { + return requestJson('/api/catalog') +} + +async function refreshCatalogFromServer(): Promise { + try { + const catalog = await loadServerCatalog() + if (catalog.version !== 1 || !catalog.hash || !catalog.profile) return null + if (catalog.hash !== activeCatalog().hash || !readCatalogCache()) { + writeCatalogCache(catalog) + } + return catalog + } catch { + return null + } +} + function cachedOnlineSession(): AuthSession | null { const cache = readOnlineCache() if (!cache) return null @@ -698,6 +758,7 @@ async function pushServerSyncSave(save: OfflineSave): Promise<{ profile: Charact async function finalizeOnlineSession(session: AuthSession): Promise { const cache = readOnlineCache() if (session.token) writeAuthToken(session.token) + await refreshCatalogFromServer() if (!session.account || !session.profile) { if (session.account && cache?.account.id === session.account.id) { return { @@ -848,7 +909,7 @@ const serverRepository: GameRepository = { } function emptyCharacterData(classId: number): CharacterData { - const static_ = clone(starterProfile) as CharacterProfile + const static_ = clone(activeCatalog().profile) const gc = static_.classes.find((c) => c.id === classId)! const talentRanks: Record = {} for (const t of gc.talents) talentRanks[String(t.id)] = 0 @@ -1544,7 +1605,9 @@ export async function syncCloudSave(): Promise { if (!cache) { throw new Error('No signed-in save is available for cloud sync.') } + await refreshCatalogFromServer() const synced = await pushServerSyncSave(cache.save) + await refreshCatalogFromServer() writeOnlineCache({ version: 1, account: cache.account, @@ -1552,7 +1615,7 @@ export async function syncCloudSave(): Promise { dirty: false, }) writeMode('online') - return synced.profile + return buildProfile(synced.save) } export function selectOnlineMode() { diff --git a/src/offline-catalog-meta.ts b/src/offline-catalog-meta.ts new file mode 100644 index 0000000..92e22df --- /dev/null +++ b/src/offline-catalog-meta.ts @@ -0,0 +1 @@ +export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937'