diff --git a/AGENTS.md b/AGENTS.md index 548ae9b..b66b210 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,20 @@ - Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical. - Apply game changes to both web version and mobile app version. +## I Want To Heal 1 vs I Want To Heal 2 + +- I Want To Heal 1 is the existing healer-first menu/combat game. Preserve its current progression, saves, inventories, collection logs, combat screens, and content unless the user explicitly asks to change IWT1. +- I Want To Heal 2 is the new 2D boss-arena version. Keep IWT2 code, content, save data, inventories, character levels, and collection logs separate from IWT1 so the two modes are not confused. +- The app should offer a game-select screen before the main menu: `I Want To Heal 1` launches the old version, and `I Want To Heal 2` launches the new 2D version. +- Both games must remain usable from the same Android APK and from `iwanttoheal.phenomrom.com`. +- Prefer an explicit folder split for IWT2, such as `src/modes/iwt2/`, with subfolders for screens, simulation, rendering, content, and save/repository code. +- Keep IWT2 simulation state independent from rendering. Arena movement, AI, boss attacks, collisions, damage, stun/knockdown, progression, inventory, and collection logs should live in pure TypeScript modules where practical. +- IWT2 rendering can use a 2D canvas/game runtime, but renderer objects must not become the source of truth for saveable gameplay state. +- IWT2 needs continuous movement input: left analog stick on AYN Thor and WASD on keyboard. Do not rely only on existing menu-style navigation actions for arena movement. +- IWT2 menus, dialogs, inventory, collection log, pause overlays, and game-select screen must still follow controller navigation requirements. +- First IWT2 boss conversion target: Bulldrome. Bulldrome should have normal melee tank damage, a charge across the arena that damages and knocks down/stuns hit players for 0.75 seconds, and every 3rd charge should be followed by an AoE ground slam around the boss that also damages and knocks down/stuns hit players for 0.75 seconds. +- IWT2 starter party fantasy: player-controlled healer plus visible party members moving in the arena. Paladin tank holds aggro; ranger fires arrows; mage fires fireballs; rogue flanks; warrior fights in melee. Each class should have a distinct icon and color scheme. + ## Performance Requirements - Treat CPU, GPU, memory, battery, and startup cost as first-class constraints. diff --git a/IWantToHeal-Thor-v1.1.14.apk b/IWantToHeal-Thor-v1.1.14.apk new file mode 100644 index 0000000..f242016 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.14.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index fb23655..2df01bb 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 92 - versionName "1.1.13" + versionCode 93 + versionName "1.1.14" 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/db/schema.sql b/db/schema.sql index ec45fdb..f40d988 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts ( completed_dungeon_parts INTEGER NOT NULL DEFAULT 0, completed_raid_phases INTEGER NOT NULL DEFAULT 0, created_ip TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS account_ip_allowances ( diff --git a/scripts/init-db.mjs b/scripts/init-db.mjs index 9d11ac6..c7ee629 100644 --- a/scripts/init-db.mjs +++ b/scripts/init-db.mjs @@ -241,6 +241,12 @@ addColumnIfMissing('encounters', 'image_url', "TEXT NOT NULL DEFAULT '/boss-plac addColumnIfMissing('accounts', 'completed_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0') addColumnIfMissing('accounts', 'completed_raid_phases', 'INTEGER NOT NULL DEFAULT 0') +addColumnIfMissing('accounts', 'last_saved_at', 'TEXT') +database.prepare(` + UPDATE accounts + SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP) + WHERE last_saved_at IS NULL OR last_saved_at = '' +`).run() addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)') migrateCharacterAccountConstraint() diff --git a/server/game-api.mjs b/server/game-api.mjs index 76345b7..44d9a39 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -37,6 +37,35 @@ const pvpMatches = new Map() const pvpQueueTtlMs = 15 * 1000 const pvpMatchTtlMs = 60 * 60 * 1000 +function addColumnIfMissing(database, table, column, definition) { + const columns = database.prepare(`PRAGMA table_info(${table})`).all() + if (!columns.some((candidate) => candidate.name === column)) { + database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`) + } +} + +function ensureRuntimeMigrations(database) { + addColumnIfMissing(database, 'accounts', 'last_saved_at', 'TEXT') + database.prepare(` + UPDATE accounts + SET last_saved_at = COALESCE(NULLIF(last_saved_at, ''), created_at, CURRENT_TIMESTAMP) + WHERE last_saved_at IS NULL OR last_saved_at = '' + `).run() +} + +function sqliteDateFromMs(value) { + const timestamp = Number(value) + if (!Number.isFinite(timestamp) || timestamp <= 0) { + return new Date().toISOString() + } + return new Date(timestamp).toISOString() +} + +function epochMsFromSqliteDate(value) { + const timestamp = Date.parse(String(value ?? '')) + return Number.isFinite(timestamp) ? timestamp : 0 +} + function sendJson(response, status, body, headers = {}) { response.statusCode = status response.setHeader('Content-Type', 'application/json') @@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) { response.end(JSON.stringify(body)) } +function touchAccountSave(database, accountId) { + database.prepare(` + UPDATE accounts + SET last_saved_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(accountId) +} + +function sendMutatingResult(response, database, accountId, body) { + touchAccountSave(database, accountId) + sendJson(response, 200, body) +} + function configuredCorsOrigins() { return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '') .split(',') @@ -1027,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) { const account = database.prepare(` SELECT completed_dungeon_parts AS completedDungeonParts, - completed_raid_phases AS completedRaidPhases + completed_raid_phases AS completedRaidPhases, + last_saved_at AS lastSavedAt FROM accounts WHERE id = ? `).get(accountId) @@ -1058,6 +1101,7 @@ function buildSyncSave(database, accountId, activeCharacterId) { `).get(activeCharacterId) return { version: 4, + updatedAt: epochMsFromSqliteDate(account?.lastSavedAt), characterName, activeClassId, completedDungeonParts: account?.completedDungeonParts ?? 0, @@ -1164,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) { database.prepare(` UPDATE accounts - SET completed_dungeon_parts = ?, completed_raid_phases = ? + SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ? WHERE id = ? `).run( clampInteger(save.completedDungeonParts, 0, 0, 3), clampInteger(save.completedRaidPhases, 0, 0, 3), + sqliteDateFromMs(save.updatedAt), accountId, ) @@ -3004,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) { const database = new DatabaseSync(databasePath) database.exec('PRAGMA foreign_keys = ON') + ensureRuntimeMigrations(database) try { const ip = requestIp(request) @@ -3060,6 +3106,7 @@ export async function handleApiRequest(request, response, next) { const database = new DatabaseSync(databasePath) database.exec('PRAGMA foreign_keys = ON') + ensureRuntimeMigrations(database) try { const ip = requestIp(request) @@ -3098,7 +3145,7 @@ export async function handleApiRequest(request, response, next) { if (request.url === '/api/profile' && request.method === 'PUT') { const payload = await readJson(request) const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload) - sendJson(response, 200, getProfile(database, newCharacterId, session.accountId)) + sendMutatingResult(response, database, session.accountId, getProfile(database, newCharacterId, session.accountId)) return } @@ -3148,9 +3195,10 @@ export async function handleApiRequest(request, response, next) { const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/) if (dungeonCompletion && request.method === 'POST') { const payload = await readJson(request) - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, completeDungeon( database, session.characterId, @@ -3165,9 +3213,10 @@ export async function handleApiRequest(request, response, next) { if (request.url === '/api/roguelike/complete' && request.method === 'POST') { const payload = await readJson(request) - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, completeRoguelike(database, session.characterId, session.accountId, payload), ) return @@ -3175,24 +3224,26 @@ export async function handleApiRequest(request, response, next) { const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/) if (talentAllocation && request.method === 'POST') { - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, allocateTalent(database, session.characterId, Number(talentAllocation[1])), ) return } if (request.url === '/api/talents/reset' && request.method === 'POST') { - sendJson(response, 200, resetTalents(database, session.characterId)) + sendMutatingResult(response, database, session.accountId, resetTalents(database, session.characterId)) return } const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/) if (itemEquip && request.method === 'POST') { - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, equipItem(database, session.characterId, Number(itemEquip[1])), ) return @@ -3200,9 +3251,10 @@ export async function handleApiRequest(request, response, next) { const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/) if (itemDiscard && request.method === 'POST') { - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, discardExtraItem(database, session.characterId, Number(itemDiscard[1])), ) return @@ -3210,9 +3262,10 @@ export async function handleApiRequest(request, response, next) { const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/) if (itemBreakdown && request.method === 'POST') { - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, breakdownItem(database, session.characterId, Number(itemBreakdown[1])), ) return @@ -3220,9 +3273,10 @@ export async function handleApiRequest(request, response, next) { const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/) if (recipeCraft && request.method === 'POST') { - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, craftItem(database, session.characterId, Number(recipeCraft[1])), ) return @@ -3230,9 +3284,10 @@ export async function handleApiRequest(request, response, next) { const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/) if (itemUpgrade && request.method === 'POST') { - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, upgradeItem(database, session.characterId, Number(itemUpgrade[1])), ) return @@ -3241,9 +3296,10 @@ export async function handleApiRequest(request, response, next) { const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/) if (encounterLootRoll && request.method === 'POST') { const payload = await readJson(request) - sendJson( + sendMutatingResult( response, - 200, + database, + session.accountId, rollEncounterLoot( database, session.characterId, diff --git a/src/App.css b/src/App.css index c836e65..646e789 100644 --- a/src/App.css +++ b/src/App.css @@ -1990,6 +1990,12 @@ h2 { width: 100%; } +.main-menu-grid.has-cloud-sync { + grid-auto-rows: minmax(72px, auto); + grid-template-rows: none; + height: auto; +} + .roguelike-mode-grid { display: grid; gap: 15px; @@ -2087,10 +2093,12 @@ h2 { } .cloud-sync-card { + align-items: start; cursor: default; display: grid; gap: 10px; - grid-template-columns: auto minmax(0, 1fr); + grid-column: 1 / -1; + grid-template-columns: auto minmax(0, 1fr) minmax(180px, 220px); justify-content: space-between; } @@ -2106,8 +2114,14 @@ h2 { min-width: 0; } -.cloud-sync-card .text-button { - grid-column: 1 / -1; +.cloud-sync-control-stack { + display: grid; + gap: 8px; + grid-column: 3; +} + +.cloud-sync-control-stack .text-button { + grid-column: auto; min-height: 28px; padding: 5px 8px; } @@ -2116,6 +2130,34 @@ h2 { opacity: 0.7; } +.cloud-sync-times { + display: grid; + gap: 3px; +} + +.cloud-sync-actions { + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr); +} + +.cloud-sync-actions .text-button { + grid-column: auto; + min-width: 0; + white-space: normal; +} + +@media (max-width: 720px) { + .cloud-sync-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .cloud-sync-card .text-button, + .cloud-sync-control-stack { + grid-column: 1 / -1; + } +} + .cloud-sync-message { color: var(--gold); } diff --git a/src/App.tsx b/src/App.tsx index 36e299d..cff4def 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,9 +14,12 @@ import { type CharacterProfile, } from './profile' import { + applyCloudSaveSync, getCloudSyncStatus, getGameMode, - syncCloudSave, + previewCloudSaveSync, + type CloudSyncChoice, + type CloudSyncComparison, type GameMode, } from './gameRepository' import { focusFirstControl, useGameAction } from './input.tsx' @@ -99,6 +102,21 @@ type RoguelikeNavPosition = { column: number } +function formatSaveTimestamp(updatedAt: number | null) { + if (!updatedAt) return 'Unknown legacy timestamp' + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(updatedAt)) +} + +function cloudSyncRelationText(comparison: CloudSyncComparison) { + if (comparison.relation === 'server-newer') return 'Server save is newer than this device.' + if (comparison.relation === 'local-newer') return 'This device save is newer than the server.' + if (comparison.relation === 'same') return 'Server and this device have the same save time.' + return 'One save has no timestamp yet. Choose the copy you want to keep.' +} + function activityInitials(name: string) { return name .split(/\s+/) @@ -148,6 +166,7 @@ function App() { const [error, setError] = useState('') const [syncingCloud, setSyncingCloud] = useState(false) const [syncMessage, setSyncMessage] = useState('') + const [syncComparison, setSyncComparison] = useState(null) const [homeSelectedIndex, setHomeSelectedIndex] = useState(0) const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0) const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1) @@ -352,6 +371,7 @@ function App() { setGameMode(getGameMode()) setScreen('menu') setSyncMessage('') + setSyncComparison(null) } catch (reason) { setError(reason instanceof Error ? reason.message : 'Unable to sign out.') } @@ -360,11 +380,29 @@ function App() { async function syncSaveNow() { setSyncingCloud(true) setSyncMessage('') + setSyncComparison(null) try { - const updated = await syncCloudSave() + const comparison = await previewCloudSaveSync() + setSyncComparison(comparison) + setSyncMessage('') + } catch (reason) { + setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') + } finally { + setSyncingCloud(false) + } + } + + async function keepCloudSave(choice: CloudSyncChoice) { + setSyncingCloud(true) + setSyncMessage(choice === 'local' ? 'Uploading this device save...' : 'Downloading server save...') + try { + const updated = await applyCloudSaveSync(choice) setProfile(updated) setGameMode(getGameMode()) - setSyncMessage('Cloud save updated.') + setSyncComparison(null) + setSyncMessage(choice === 'local' + ? 'Server now uses this device save.' + : 'This device now uses the server save.') } catch (reason) { setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.') } finally { @@ -374,13 +412,22 @@ function App() { const cloudSync = getCloudSyncStatus() const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available) - const homeMenuOffset = canShowCloudSync ? 1 : 0 + const cloudSyncEntryCount = canShowCloudSync ? (syncComparison ? 3 : 1) : 0 + const homeMenuOffset = cloudSyncEntryCount const homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1) function openHomeMenuIndex(index: number) { if (canShowCloudSync && index === 0) { - if (!syncingCloud && cloudSync.dirty) void syncSaveNow() + if (!syncingCloud) void syncSaveNow() + return + } + if (canShowCloudSync && syncComparison && index === 1) { + if (!syncingCloud) void keepCloudSave('local') + return + } + if (canShowCloudSync && syncComparison && index === 2) { + if (!syncingCloud) void keepCloudSave('server') return } const item = MENU_ITEMS[index - homeMenuOffset] @@ -404,6 +451,52 @@ function App() { setScreen(item.screen) } + function moveHomeSelection(action: string) { + setHomeSelectedIndex((current) => { + const bounded = Math.min(current, homeMenuEntryCount - 1) + if (canShowCloudSync && syncComparison && bounded <= 2) { + if (bounded === 0) { + return action === 'navigateDown' || action === 'navigateRight' ? 1 : 0 + } + if (bounded === 1) { + if (action === 'navigateRight') return 2 + if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1) + return 0 + } + if (action === 'navigateLeft') return 1 + if (action === 'navigateDown') return Math.min(homeMenuOffset, homeMenuEntryCount - 1) + return 0 + } + if (canShowCloudSync && !syncComparison && bounded === 0) { + return action === 'navigateDown' || action === 'navigateRight' + ? Math.min(homeMenuOffset, homeMenuEntryCount - 1) + : 0 + } + + const menuIndex = bounded - homeMenuOffset + if (menuIndex < 0) return bounded + const column = menuIndex % HOME_MENU_COLUMNS + if (action === 'navigateLeft') { + if (column > 0) return bounded - 1 + if (canShowCloudSync && syncComparison && menuIndex < HOME_MENU_COLUMNS) return 2 + return bounded + } + if (action === 'navigateRight') { + const nextMenuIndex = menuIndex + 1 + return column < HOME_MENU_COLUMNS - 1 && nextMenuIndex < MENU_ITEMS.length ? bounded + 1 : bounded + } + if (action === 'navigateUp') { + const previousMenuIndex = menuIndex - HOME_MENU_COLUMNS + if (previousMenuIndex >= 0) return homeMenuOffset + previousMenuIndex + if (canShowCloudSync && syncComparison) return column === 0 ? 1 : 2 + if (canShowCloudSync) return 0 + return bounded + } + const nextMenuIndex = menuIndex + HOME_MENU_COLUMNS + return nextMenuIndex < MENU_ITEMS.length ? homeMenuOffset + nextMenuIndex : bounded + }) + } + function dungeonEntries() { const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0] const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true @@ -659,19 +752,7 @@ function App() { return } if (!action.startsWith('navigate')) return - setHomeSelectedIndex((current) => { - const bounded = Math.min(current, homeMenuEntryCount - 1) - const column = bounded % HOME_MENU_COLUMNS - if (action === 'navigateLeft') return column > 0 ? bounded - 1 : bounded - if (action === 'navigateRight') { - const next = bounded + 1 - return column < HOME_MENU_COLUMNS - 1 && next < homeMenuEntryCount ? next : bounded - } - if (action === 'navigateUp') return bounded >= HOME_MENU_COLUMNS ? bounded - HOME_MENU_COLUMNS : bounded - const next = bounded + HOME_MENU_COLUMNS - if (next < homeMenuEntryCount) return next - return column > 0 ? homeMenuEntryCount - 1 : bounded - }) + moveHomeSelection(action) return } if (screen === 'dungeons' || screen === 'raids') { @@ -860,32 +941,68 @@ function App() { {screen === 'menu' && (
-
+
{canShowCloudSync && (
setHomeSelectedIndex(0)} > - {cloudSync.dirty ? 'S' : 'C'} + {syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'}
- Cloud Save + Sync With Server - {cloudSync.dirty + {syncComparison + ? cloudSyncRelationText(syncComparison) + : cloudSync.dirty ? 'Local progress waiting. Upload when you want to refresh the server copy.' : 'Server copy matches this device.'} + {syncComparison && ( +
+ Device: {formatSaveTimestamp(syncComparison.local.updatedAt)} + Server: {formatSaveTimestamp(syncComparison.server.updatedAt)} +
+ )} {syncMessage && {syncMessage}}
- +
+ + {syncComparison && ( +
+ + +
+ )} +
)} {MENU_ITEMS.map((item, index) => { diff --git a/src/gameRepository.ts b/src/gameRepository.ts index 9f4a864..19713a1 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -86,6 +86,7 @@ type CharacterData = { type OfflineSave = { version: 4 + updatedAt: number characterName: string activeClassId: number completedDungeonParts: number @@ -117,6 +118,22 @@ export type CloudSyncStatus = { dirty: boolean } +export type CloudSaveAge = 'server-newer' | 'local-newer' | 'same' | 'unknown' + +export type CloudSaveSummary = { + updatedAt: number | null + characterName: string + activeClassId: number +} + +export type CloudSyncComparison = { + relation: CloudSaveAge + local: CloudSaveSummary + server: CloudSaveSummary +} + +export type CloudSyncChoice = 'local' | 'server' + type RepositoryMode = 'online' | 'offline-local' | 'offline-cached' type NetworkError = Error & { @@ -144,6 +161,30 @@ function clone(value: T): T { return structuredClone(value) } +function normalizedSaveTimestamp(value: unknown): number { + const timestamp = Number(value) + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0 +} + +function stampSave(save: OfflineSave, updatedAt = Date.now()): OfflineSave { + save.updatedAt = updatedAt + return save +} + +function saveSummary(save: OfflineSave): CloudSaveSummary { + return { + updatedAt: save.updatedAt > 0 ? save.updatedAt : null, + characterName: save.characterName, + activeClassId: save.activeClassId, + } +} + +function compareSaveAge(local: OfflineSave, server: OfflineSave): CloudSaveAge { + if (local.updatedAt <= 0 || server.updatedAt <= 0) return 'unknown' + if (Math.abs(local.updatedAt - server.updatedAt) < 1000) return 'same' + return server.updatedAt > local.updatedAt ? 'server-newer' : 'local-newer' +} + function toGameMode(mode: RepositoryMode): GameMode { return mode === 'online' ? 'online' : 'offline' } @@ -190,6 +231,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record { } function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave { + save.updatedAt = normalizedSaveTimestamp(save.updatedAt) for (const character of Object.values(save.characters)) { character.abilitySlots = normalizeAbilitySlots(character.abilitySlots) } @@ -590,6 +635,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS } return { version: 4, + updatedAt: existingSave?.updatedAt ?? Date.now(), characterName: profile.character.name, activeClassId: profile.character.classId, completedDungeonParts: profile.completedDungeonParts, @@ -1008,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave { return save } +function writeStoreSave(store: LocalSaveStore, save: OfflineSave) { + store.writeSave(stampSave(save)) +} + function createLocalRepository(store: LocalSaveStore): GameRepository { return { async loadSession() { @@ -1055,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { } save.characters[classId].abilitySlots = slots save.activeClassId = classId - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) { @@ -1161,7 +1211,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { } } - store.writeSave(save) + writeStoreSave(store, save) const updatedProfile = buildProfile(save) return { @@ -1284,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { ) cd.inventory = profile.inventory - store.writeSave(save) + writeStoreSave(store, save) const updatedProfile = buildProfile(save) return { @@ -1339,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { } cd.talentRanks[String(talentId)] = 1 } - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) } if (cd.talentPoints <= 0) { @@ -1367,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { } cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1 cd.talentPoints -= 1 - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async resetTalents() { @@ -1390,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { cd.talentPoints + refunded, ) } - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async equipItem(itemId) { @@ -1402,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id } save.characters[save.activeClassId].inventory = profile.inventory - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async discardExtraItem(itemId) { @@ -1413,7 +1463,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { 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) + writeStoreSave(store, save) return buildProfile(save) }, async breakdownItem(itemId) { @@ -1456,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { } save.characters[save.activeClassId].inventory = profile.inventory - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async craftItem(recipeId) { @@ -1484,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { addInventoryItem(profile.inventory, recipe.item, 1) save.characters[save.activeClassId].inventory = profile.inventory - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async upgradeItem(itemId) { @@ -1527,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { if (upgraded) upgraded.equipped = true } save.characters[save.activeClassId].inventory = profile.inventory - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, async rollEncounterLoot(encounterId, difficultyId, runToken) { @@ -1610,7 +1660,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { } save.lootRolls[rollKey] = result save.characters[save.activeClassId].inventory = profile.inventory - store.writeSave(save) + writeStoreSave(store, save) return clone(result) }, async recordBossKill(encounterId, options) { @@ -1623,7 +1673,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { const petAwarded = recordBossKillInSave(save, encounter, { petVariant: options?.petVariant ?? 'normal', }) - store.writeSave(save) + writeStoreSave(store, save) return { profile: buildProfile(save), petAwarded, @@ -1633,7 +1683,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { const save = requireStoredSave(store) save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1 if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1 - store.writeSave(save) + writeStoreSave(store, save) return buildProfile(save) }, } @@ -1750,6 +1800,49 @@ export async function syncCloudSave(): Promise { return buildProfile(synced.save) } +export async function previewCloudSaveSync(): Promise { + const cache = readOnlineCache() + if (!cache) { + throw new Error('No signed-in save is available for cloud sync.') + } + await refreshCatalogFromServer() + const serverSave = await loadServerSyncSave() + return { + relation: compareSaveAge(cache.save, serverSave), + local: saveSummary(cache.save), + server: saveSummary(serverSave), + } +} + +export async function applyCloudSaveSync(choice: CloudSyncChoice): Promise { + const cache = readOnlineCache() + if (!cache) { + throw new Error('No signed-in save is available for cloud sync.') + } + await refreshCatalogFromServer() + if (choice === 'server') { + const serverSave = await loadServerSyncSave() + writeOnlineCache({ + version: 1, + account: cache.account, + save: serverSave, + dirty: false, + }) + writeMode('online') + return buildProfile(serverSave) + } + const synced = await pushServerSyncSave(cache.save) + await refreshCatalogFromServer() + writeOnlineCache({ + version: 1, + account: cache.account, + save: synced.save, + dirty: false, + }) + writeMode('online') + return buildProfile(synced.save) +} + export function selectOnlineMode() { writeMode('online') } @@ -1765,6 +1858,7 @@ export function createOfflineCharacter(characterName: string): AuthSession { } const save: OfflineSave = { version: 4, + updatedAt: Date.now(), characterName: name, activeClassId: 1, completedDungeonParts: 0,