Android build v1.1.14

This commit is contained in:
Warren H
2026-07-03 12:58:53 -04:00
parent a121b2ef74
commit a849cd2c69
9 changed files with 403 additions and 73 deletions
+45 -3
View File
@@ -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);
}
+148 -31
View File
@@ -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<CloudSyncComparison | null>(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' && (
<section className="menu-screen" data-game-nav-active="true">
<div className="main-menu-grid">
<div className={`main-menu-grid ${canShowCloudSync ? 'has-cloud-sync' : ''}`}>
{canShowCloudSync && (
<div
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
onPointerDown={() => setHomeSelectedIndex(0)}
>
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
<span>{syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'}</span>
<div>
<strong>Cloud Save</strong>
<strong>Sync With Server</strong>
<small>
{cloudSync.dirty
{syncComparison
? cloudSyncRelationText(syncComparison)
: cloudSync.dirty
? 'Local progress waiting. Upload when you want to refresh the server copy.'
: 'Server copy matches this device.'}
</small>
{syncComparison && (
<div className="cloud-sync-times">
<small>Device: {formatSaveTimestamp(syncComparison.local.updatedAt)}</small>
<small>Server: {formatSaveTimestamp(syncComparison.server.updatedAt)}</small>
</div>
)}
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
</div>
<button
className="text-button"
data-controller-nav="skip"
disabled={syncingCloud || !cloudSync.dirty}
onClick={syncSaveNow}
type="button"
>
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
</button>
<div className="cloud-sync-control-stack">
<button
className="text-button"
data-controller-nav="skip"
disabled={syncingCloud}
onClick={syncSaveNow}
type="button"
>
{syncingCloud ? 'Checking...' : syncComparison ? 'Check Again' : 'Sync With Server'}
</button>
{syncComparison && (
<div className="cloud-sync-actions">
<button
className={`text-button ${homeActiveIndex === 1 ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === 1 ? 'true' : undefined}
disabled={syncingCloud}
onClick={() => keepCloudSave('local')}
onPointerDown={() => setHomeSelectedIndex(1)}
type="button"
>
Keep Device Save
</button>
<button
className={`text-button ${homeActiveIndex === 2 ? 'game-selected' : ''}`}
data-controller-nav="skip"
data-game-selected={homeActiveIndex === 2 ? 'true' : undefined}
disabled={syncingCloud}
onClick={() => keepCloudSave('server')}
onPointerDown={() => setHomeSelectedIndex(2)}
type="button"
>
Keep Server Save
</button>
</div>
)}
</div>
</div>
)}
{MENU_ITEMS.map((item, index) => {
+108 -14
View File
@@ -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<T>(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<string
}
return {
version: 4,
updatedAt: 0,
characterName: p.character.name,
activeClassId: p.character.classId,
completedDungeonParts: p.completedDungeonParts,
@@ -210,6 +252,7 @@ function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' |
return normalizeSaveAbilitySlots({
...v2,
version: 4,
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
completedRaidPhases: 0,
bossKills: {},
bossPets: {},
@@ -222,6 +265,7 @@ function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets
return normalizeSaveAbilitySlots({
...v3,
version: 4,
updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
bossKills: {},
bossPets: {},
pvpMatchesPlayed: 0,
@@ -244,6 +288,7 @@ function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
}
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<CharacterProfile> {
return buildProfile(synced.save)
}
export async function previewCloudSaveSync(): Promise<CloudSyncComparison> {
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<CharacterProfile> {
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,