Android build v1.1.14
This commit is contained in:
@@ -23,6 +23,20 @@
|
|||||||
- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical.
|
- 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.
|
- 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
|
## Performance Requirements
|
||||||
|
|
||||||
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints.
|
||||||
|
|||||||
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 92
|
versionCode 93
|
||||||
versionName "1.1.13"
|
versionName "1.1.14"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+2
-1
@@ -169,7 +169,8 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
completed_dungeon_parts INTEGER NOT NULL DEFAULT 0,
|
||||||
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
completed_raid_phases INTEGER NOT NULL DEFAULT 0,
|
||||||
created_ip TEXT NOT NULL,
|
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 (
|
CREATE TABLE IF NOT EXISTS account_ip_allowances (
|
||||||
|
|||||||
@@ -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_dungeon_parts', 'INTEGER NOT NULL DEFAULT 0')
|
||||||
addColumnIfMissing('accounts', 'completed_raid_phases', '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)')
|
addColumnIfMissing('sessions', 'active_character_id', 'INTEGER REFERENCES characters(id)')
|
||||||
|
|
||||||
migrateCharacterAccountConstraint()
|
migrateCharacterAccountConstraint()
|
||||||
|
|||||||
+78
-22
@@ -37,6 +37,35 @@ const pvpMatches = new Map()
|
|||||||
const pvpQueueTtlMs = 15 * 1000
|
const pvpQueueTtlMs = 15 * 1000
|
||||||
const pvpMatchTtlMs = 60 * 60 * 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 = {}) {
|
function sendJson(response, status, body, headers = {}) {
|
||||||
response.statusCode = status
|
response.statusCode = status
|
||||||
response.setHeader('Content-Type', 'application/json')
|
response.setHeader('Content-Type', 'application/json')
|
||||||
@@ -44,6 +73,19 @@ function sendJson(response, status, body, headers = {}) {
|
|||||||
response.end(JSON.stringify(body))
|
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() {
|
function configuredCorsOrigins() {
|
||||||
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
return String(process.env.CORS_ORIGINS ?? process.env.AUTH_CORS_ORIGINS ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -1027,7 +1069,8 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
const account = database.prepare(`
|
const account = database.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
completed_dungeon_parts AS completedDungeonParts,
|
completed_dungeon_parts AS completedDungeonParts,
|
||||||
completed_raid_phases AS completedRaidPhases
|
completed_raid_phases AS completedRaidPhases,
|
||||||
|
last_saved_at AS lastSavedAt
|
||||||
FROM accounts
|
FROM accounts
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(accountId)
|
`).get(accountId)
|
||||||
@@ -1058,6 +1101,7 @@ function buildSyncSave(database, accountId, activeCharacterId) {
|
|||||||
`).get(activeCharacterId)
|
`).get(activeCharacterId)
|
||||||
return {
|
return {
|
||||||
version: 4,
|
version: 4,
|
||||||
|
updatedAt: epochMsFromSqliteDate(account?.lastSavedAt),
|
||||||
characterName,
|
characterName,
|
||||||
activeClassId,
|
activeClassId,
|
||||||
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
completedDungeonParts: account?.completedDungeonParts ?? 0,
|
||||||
@@ -1164,11 +1208,12 @@ function importSyncSave(database, accountId, activeCharacterId, payload) {
|
|||||||
|
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET completed_dungeon_parts = ?, completed_raid_phases = ?
|
SET completed_dungeon_parts = ?, completed_raid_phases = ?, last_saved_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
clampInteger(save.completedDungeonParts, 0, 0, 3),
|
||||||
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
clampInteger(save.completedRaidPhases, 0, 0, 3),
|
||||||
|
sqliteDateFromMs(save.updatedAt),
|
||||||
accountId,
|
accountId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3004,6 +3049,7 @@ export async function handleAuthApiRequest(request, response, next = null) {
|
|||||||
|
|
||||||
const database = new DatabaseSync(databasePath)
|
const database = new DatabaseSync(databasePath)
|
||||||
database.exec('PRAGMA foreign_keys = ON')
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
ensureRuntimeMigrations(database)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
@@ -3060,6 +3106,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const database = new DatabaseSync(databasePath)
|
const database = new DatabaseSync(databasePath)
|
||||||
database.exec('PRAGMA foreign_keys = ON')
|
database.exec('PRAGMA foreign_keys = ON')
|
||||||
|
ensureRuntimeMigrations(database)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ip = requestIp(request)
|
const ip = requestIp(request)
|
||||||
@@ -3098,7 +3145,7 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
if (request.url === '/api/profile' && request.method === 'PUT') {
|
if (request.url === '/api/profile' && request.method === 'PUT') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
const newCharacterId = saveProfile(database, session.characterId, session.accountId, payload)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3148,9 +3195,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
const dungeonCompletion = request.url.match(/^\/api\/dungeons\/(\d+)\/complete$/)
|
||||||
if (dungeonCompletion && request.method === 'POST') {
|
if (dungeonCompletion && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
completeDungeon(
|
completeDungeon(
|
||||||
database,
|
database,
|
||||||
session.characterId,
|
session.characterId,
|
||||||
@@ -3165,9 +3213,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
if (request.url === '/api/roguelike/complete' && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
completeRoguelike(database, session.characterId, session.accountId, payload),
|
completeRoguelike(database, session.characterId, session.accountId, payload),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3175,24 +3224,26 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
const talentAllocation = request.url.match(/^\/api\/talents\/(\d+)\/allocate$/)
|
||||||
if (talentAllocation && request.method === 'POST') {
|
if (talentAllocation && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
allocateTalent(database, session.characterId, Number(talentAllocation[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.url === '/api/talents/reset' && request.method === 'POST') {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
const itemEquip = request.url.match(/^\/api\/equipment\/(\d+)\/equip$/)
|
||||||
if (itemEquip && request.method === 'POST') {
|
if (itemEquip && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
equipItem(database, session.characterId, Number(itemEquip[1])),
|
equipItem(database, session.characterId, Number(itemEquip[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3200,9 +3251,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
const itemDiscard = request.url.match(/^\/api\/equipment\/(\d+)\/discard-extra$/)
|
||||||
if (itemDiscard && request.method === 'POST') {
|
if (itemDiscard && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
discardExtraItem(database, session.characterId, Number(itemDiscard[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3210,9 +3262,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
const itemBreakdown = request.url.match(/^\/api\/equipment\/(\d+)\/breakdown$/)
|
||||||
if (itemBreakdown && request.method === 'POST') {
|
if (itemBreakdown && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
breakdownItem(database, session.characterId, Number(itemBreakdown[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3220,9 +3273,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
const recipeCraft = request.url.match(/^\/api\/crafting\/recipes\/(\d+)\/craft$/)
|
||||||
if (recipeCraft && request.method === 'POST') {
|
if (recipeCraft && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
craftItem(database, session.characterId, Number(recipeCraft[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3230,9 +3284,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
|
|
||||||
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
const itemUpgrade = request.url.match(/^\/api\/items\/(\d+)\/upgrade$/)
|
||||||
if (itemUpgrade && request.method === 'POST') {
|
if (itemUpgrade && request.method === 'POST') {
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
upgradeItem(database, session.characterId, Number(itemUpgrade[1])),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -3241,9 +3296,10 @@ export async function handleApiRequest(request, response, next) {
|
|||||||
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
const encounterLootRoll = request.url.match(/^\/api\/encounters\/(\d+)\/loot-roll$/)
|
||||||
if (encounterLootRoll && request.method === 'POST') {
|
if (encounterLootRoll && request.method === 'POST') {
|
||||||
const payload = await readJson(request)
|
const payload = await readJson(request)
|
||||||
sendJson(
|
sendMutatingResult(
|
||||||
response,
|
response,
|
||||||
200,
|
database,
|
||||||
|
session.accountId,
|
||||||
rollEncounterLoot(
|
rollEncounterLoot(
|
||||||
database,
|
database,
|
||||||
session.characterId,
|
session.characterId,
|
||||||
|
|||||||
+45
-3
@@ -1990,6 +1990,12 @@ h2 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-menu-grid.has-cloud-sync {
|
||||||
|
grid-auto-rows: minmax(72px, auto);
|
||||||
|
grid-template-rows: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.roguelike-mode-grid {
|
.roguelike-mode-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
@@ -2087,10 +2093,12 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cloud-sync-card {
|
.cloud-sync-card {
|
||||||
|
align-items: start;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
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;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2106,8 +2114,14 @@ h2 {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cloud-sync-card .text-button {
|
.cloud-sync-control-stack {
|
||||||
grid-column: 1 / -1;
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cloud-sync-control-stack .text-button {
|
||||||
|
grid-column: auto;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
}
|
}
|
||||||
@@ -2116,6 +2130,34 @@ h2 {
|
|||||||
opacity: 0.7;
|
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 {
|
.cloud-sync-message {
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-24
@@ -14,9 +14,12 @@ import {
|
|||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
} from './profile'
|
} from './profile'
|
||||||
import {
|
import {
|
||||||
|
applyCloudSaveSync,
|
||||||
getCloudSyncStatus,
|
getCloudSyncStatus,
|
||||||
getGameMode,
|
getGameMode,
|
||||||
syncCloudSave,
|
previewCloudSaveSync,
|
||||||
|
type CloudSyncChoice,
|
||||||
|
type CloudSyncComparison,
|
||||||
type GameMode,
|
type GameMode,
|
||||||
} from './gameRepository'
|
} from './gameRepository'
|
||||||
import { focusFirstControl, useGameAction } from './input.tsx'
|
import { focusFirstControl, useGameAction } from './input.tsx'
|
||||||
@@ -99,6 +102,21 @@ type RoguelikeNavPosition = {
|
|||||||
column: number
|
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) {
|
function activityInitials(name: string) {
|
||||||
return name
|
return name
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
@@ -148,6 +166,7 @@ function App() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [syncingCloud, setSyncingCloud] = useState(false)
|
const [syncingCloud, setSyncingCloud] = useState(false)
|
||||||
const [syncMessage, setSyncMessage] = useState('')
|
const [syncMessage, setSyncMessage] = useState('')
|
||||||
|
const [syncComparison, setSyncComparison] = useState<CloudSyncComparison | null>(null)
|
||||||
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
const [homeSelectedIndex, setHomeSelectedIndex] = useState(0)
|
||||||
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
|
const [dungeonSelectedIndex, setDungeonSelectedIndex] = useState(0)
|
||||||
const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1)
|
const [roguelikeSelectedIndex, setRoguelikeSelectedIndex] = useState(1)
|
||||||
@@ -352,6 +371,7 @@ function App() {
|
|||||||
setGameMode(getGameMode())
|
setGameMode(getGameMode())
|
||||||
setScreen('menu')
|
setScreen('menu')
|
||||||
setSyncMessage('')
|
setSyncMessage('')
|
||||||
|
setSyncComparison(null)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
setError(reason instanceof Error ? reason.message : 'Unable to sign out.')
|
||||||
}
|
}
|
||||||
@@ -360,11 +380,29 @@ function App() {
|
|||||||
async function syncSaveNow() {
|
async function syncSaveNow() {
|
||||||
setSyncingCloud(true)
|
setSyncingCloud(true)
|
||||||
setSyncMessage('')
|
setSyncMessage('')
|
||||||
|
setSyncComparison(null)
|
||||||
try {
|
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)
|
setProfile(updated)
|
||||||
setGameMode(getGameMode())
|
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) {
|
} catch (reason) {
|
||||||
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
|
setSyncMessage(reason instanceof Error ? reason.message : 'Unable to sync cloud save.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -374,13 +412,22 @@ function App() {
|
|||||||
|
|
||||||
const cloudSync = getCloudSyncStatus()
|
const cloudSync = getCloudSyncStatus()
|
||||||
const canShowCloudSync = Boolean(account && account.id !== -1 && cloudSync.available)
|
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 homeMenuEntryCount = MENU_ITEMS.length + homeMenuOffset
|
||||||
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
const homeActiveIndex = Math.min(homeSelectedIndex, homeMenuEntryCount - 1)
|
||||||
|
|
||||||
function openHomeMenuIndex(index: number) {
|
function openHomeMenuIndex(index: number) {
|
||||||
if (canShowCloudSync && index === 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const item = MENU_ITEMS[index - homeMenuOffset]
|
const item = MENU_ITEMS[index - homeMenuOffset]
|
||||||
@@ -404,6 +451,52 @@ function App() {
|
|||||||
setScreen(item.screen)
|
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() {
|
function dungeonEntries() {
|
||||||
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
const difficulty = selectedDifficultyOption ?? selectedActivityOption?.difficulties[0]
|
||||||
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
const locked = profile && difficulty ? profile.character.level < difficulty.unlockLevel : true
|
||||||
@@ -659,19 +752,7 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!action.startsWith('navigate')) return
|
if (!action.startsWith('navigate')) return
|
||||||
setHomeSelectedIndex((current) => {
|
moveHomeSelection(action)
|
||||||
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
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (screen === 'dungeons' || screen === 'raids') {
|
if (screen === 'dungeons' || screen === 'raids') {
|
||||||
@@ -860,32 +941,68 @@ function App() {
|
|||||||
|
|
||||||
{screen === 'menu' && (
|
{screen === 'menu' && (
|
||||||
<section className="menu-screen" data-game-nav-active="true">
|
<section className="menu-screen" data-game-nav-active="true">
|
||||||
<div className="main-menu-grid">
|
<div className={`main-menu-grid ${canShowCloudSync ? 'has-cloud-sync' : ''}`}>
|
||||||
{canShowCloudSync && (
|
{canShowCloudSync && (
|
||||||
<div
|
<div
|
||||||
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
|
className={`menu-card cloud-sync-card ${homeActiveIndex === 0 ? 'game-selected' : ''}`}
|
||||||
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
|
data-game-selected={homeActiveIndex === 0 ? 'true' : undefined}
|
||||||
onPointerDown={() => setHomeSelectedIndex(0)}
|
onPointerDown={() => setHomeSelectedIndex(0)}
|
||||||
>
|
>
|
||||||
<span>{cloudSync.dirty ? 'S' : 'C'}</span>
|
<span>{syncComparison ? '!' : cloudSync.dirty ? 'S' : 'C'}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Cloud Save</strong>
|
<strong>Sync With Server</strong>
|
||||||
<small>
|
<small>
|
||||||
{cloudSync.dirty
|
{syncComparison
|
||||||
|
? cloudSyncRelationText(syncComparison)
|
||||||
|
: cloudSync.dirty
|
||||||
? 'Local progress waiting. Upload when you want to refresh the server copy.'
|
? 'Local progress waiting. Upload when you want to refresh the server copy.'
|
||||||
: 'Server copy matches this device.'}
|
: 'Server copy matches this device.'}
|
||||||
</small>
|
</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>}
|
{syncMessage && <small className="cloud-sync-message">{syncMessage}</small>}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="cloud-sync-control-stack">
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="text-button"
|
||||||
data-controller-nav="skip"
|
data-controller-nav="skip"
|
||||||
disabled={syncingCloud || !cloudSync.dirty}
|
disabled={syncingCloud}
|
||||||
onClick={syncSaveNow}
|
onClick={syncSaveNow}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{syncingCloud ? 'Syncing...' : cloudSync.dirty ? 'Sync Save To Server' : 'Already Synced'}
|
{syncingCloud ? 'Checking...' : syncComparison ? 'Check Again' : 'Sync With Server'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{MENU_ITEMS.map((item, index) => {
|
{MENU_ITEMS.map((item, index) => {
|
||||||
|
|||||||
+108
-14
@@ -86,6 +86,7 @@ type CharacterData = {
|
|||||||
|
|
||||||
type OfflineSave = {
|
type OfflineSave = {
|
||||||
version: 4
|
version: 4
|
||||||
|
updatedAt: number
|
||||||
characterName: string
|
characterName: string
|
||||||
activeClassId: number
|
activeClassId: number
|
||||||
completedDungeonParts: number
|
completedDungeonParts: number
|
||||||
@@ -117,6 +118,22 @@ export type CloudSyncStatus = {
|
|||||||
dirty: boolean
|
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 RepositoryMode = 'online' | 'offline-local' | 'offline-cached'
|
||||||
|
|
||||||
type NetworkError = Error & {
|
type NetworkError = Error & {
|
||||||
@@ -144,6 +161,30 @@ function clone<T>(value: T): T {
|
|||||||
return structuredClone(value)
|
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 {
|
function toGameMode(mode: RepositoryMode): GameMode {
|
||||||
return mode === 'online' ? 'online' : 'offline'
|
return mode === 'online' ? 'online' : 'offline'
|
||||||
}
|
}
|
||||||
@@ -190,6 +231,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 4,
|
version: 4,
|
||||||
|
updatedAt: 0,
|
||||||
characterName: p.character.name,
|
characterName: p.character.name,
|
||||||
activeClassId: p.character.classId,
|
activeClassId: p.character.classId,
|
||||||
completedDungeonParts: p.completedDungeonParts,
|
completedDungeonParts: p.completedDungeonParts,
|
||||||
@@ -210,6 +252,7 @@ function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases' |
|
|||||||
return normalizeSaveAbilitySlots({
|
return normalizeSaveAbilitySlots({
|
||||||
...v2,
|
...v2,
|
||||||
version: 4,
|
version: 4,
|
||||||
|
updatedAt: normalizedSaveTimestamp((v2 as { updatedAt?: unknown }).updatedAt),
|
||||||
completedRaidPhases: 0,
|
completedRaidPhases: 0,
|
||||||
bossKills: {},
|
bossKills: {},
|
||||||
bossPets: {},
|
bossPets: {},
|
||||||
@@ -222,6 +265,7 @@ function upgradeV3Save(v3: Omit<OfflineSave, 'version' | 'bossKills' | 'bossPets
|
|||||||
return normalizeSaveAbilitySlots({
|
return normalizeSaveAbilitySlots({
|
||||||
...v3,
|
...v3,
|
||||||
version: 4,
|
version: 4,
|
||||||
|
updatedAt: normalizedSaveTimestamp((v3 as { updatedAt?: unknown }).updatedAt),
|
||||||
bossKills: {},
|
bossKills: {},
|
||||||
bossPets: {},
|
bossPets: {},
|
||||||
pvpMatchesPlayed: 0,
|
pvpMatchesPlayed: 0,
|
||||||
@@ -244,6 +288,7 @@ function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||||
|
save.updatedAt = normalizedSaveTimestamp(save.updatedAt)
|
||||||
for (const character of Object.values(save.characters)) {
|
for (const character of Object.values(save.characters)) {
|
||||||
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||||
}
|
}
|
||||||
@@ -590,6 +635,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
version: 4,
|
version: 4,
|
||||||
|
updatedAt: existingSave?.updatedAt ?? Date.now(),
|
||||||
characterName: profile.character.name,
|
characterName: profile.character.name,
|
||||||
activeClassId: profile.character.classId,
|
activeClassId: profile.character.classId,
|
||||||
completedDungeonParts: profile.completedDungeonParts,
|
completedDungeonParts: profile.completedDungeonParts,
|
||||||
@@ -1008,6 +1054,10 @@ function requireStoredSave(store: LocalSaveStore): OfflineSave {
|
|||||||
return save
|
return save
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeStoreSave(store: LocalSaveStore, save: OfflineSave) {
|
||||||
|
store.writeSave(stampSave(save))
|
||||||
|
}
|
||||||
|
|
||||||
function createLocalRepository(store: LocalSaveStore): GameRepository {
|
function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||||
return {
|
return {
|
||||||
async loadSession() {
|
async loadSession() {
|
||||||
@@ -1055,7 +1105,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
save.characters[classId].abilitySlots = slots
|
save.characters[classId].abilitySlots = slots
|
||||||
save.activeClassId = classId
|
save.activeClassId = classId
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
|
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)
|
const updatedProfile = buildProfile(save)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1284,7 +1334,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
)
|
)
|
||||||
cd.inventory = profile.inventory
|
cd.inventory = profile.inventory
|
||||||
|
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
const updatedProfile = buildProfile(save)
|
const updatedProfile = buildProfile(save)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1339,7 +1389,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
cd.talentRanks[String(talentId)] = 1
|
cd.talentRanks[String(talentId)] = 1
|
||||||
}
|
}
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
}
|
}
|
||||||
if (cd.talentPoints <= 0) {
|
if (cd.talentPoints <= 0) {
|
||||||
@@ -1367,7 +1417,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
|
cd.talentRanks[String(talentId)] = (cd.talentRanks[String(talentId)] ?? 0) + 1
|
||||||
cd.talentPoints -= 1
|
cd.talentPoints -= 1
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async resetTalents() {
|
async resetTalents() {
|
||||||
@@ -1390,7 +1440,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
cd.talentPoints + refunded,
|
cd.talentPoints + refunded,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async equipItem(itemId) {
|
async equipItem(itemId) {
|
||||||
@@ -1402,7 +1452,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
|
if (candidate.slot === item.slot) candidate.equipped = candidate.id === item.id
|
||||||
}
|
}
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async discardExtraItem(itemId) {
|
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.')
|
if (item.quantity <= 1) throw new Error('Only extra copies can be discarded.')
|
||||||
item.quantity -= 1
|
item.quantity -= 1
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async breakdownItem(itemId) {
|
async breakdownItem(itemId) {
|
||||||
@@ -1456,7 +1506,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async craftItem(recipeId) {
|
async craftItem(recipeId) {
|
||||||
@@ -1484,7 +1534,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
|
|
||||||
addInventoryItem(profile.inventory, recipe.item, 1)
|
addInventoryItem(profile.inventory, recipe.item, 1)
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async upgradeItem(itemId) {
|
async upgradeItem(itemId) {
|
||||||
@@ -1527,7 +1577,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
if (upgraded) upgraded.equipped = true
|
if (upgraded) upgraded.equipped = true
|
||||||
}
|
}
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
async rollEncounterLoot(encounterId, difficultyId, runToken) {
|
async rollEncounterLoot(encounterId, difficultyId, runToken) {
|
||||||
@@ -1610,7 +1660,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
}
|
}
|
||||||
save.lootRolls[rollKey] = result
|
save.lootRolls[rollKey] = result
|
||||||
save.characters[save.activeClassId].inventory = profile.inventory
|
save.characters[save.activeClassId].inventory = profile.inventory
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return clone(result)
|
return clone(result)
|
||||||
},
|
},
|
||||||
async recordBossKill(encounterId, options) {
|
async recordBossKill(encounterId, options) {
|
||||||
@@ -1623,7 +1673,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const petAwarded = recordBossKillInSave(save, encounter, {
|
const petAwarded = recordBossKillInSave(save, encounter, {
|
||||||
petVariant: options?.petVariant ?? 'normal',
|
petVariant: options?.petVariant ?? 'normal',
|
||||||
})
|
})
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return {
|
return {
|
||||||
profile: buildProfile(save),
|
profile: buildProfile(save),
|
||||||
petAwarded,
|
petAwarded,
|
||||||
@@ -1633,7 +1683,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
const save = requireStoredSave(store)
|
const save = requireStoredSave(store)
|
||||||
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
|
save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1
|
||||||
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
|
if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1
|
||||||
store.writeSave(save)
|
writeStoreSave(store, save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1750,6 +1800,49 @@ export async function syncCloudSave(): Promise<CharacterProfile> {
|
|||||||
return buildProfile(synced.save)
|
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() {
|
export function selectOnlineMode() {
|
||||||
writeMode('online')
|
writeMode('online')
|
||||||
}
|
}
|
||||||
@@ -1765,6 +1858,7 @@ export function createOfflineCharacter(characterName: string): AuthSession {
|
|||||||
}
|
}
|
||||||
const save: OfflineSave = {
|
const save: OfflineSave = {
|
||||||
version: 4,
|
version: 4,
|
||||||
|
updatedAt: Date.now(),
|
||||||
characterName: name,
|
characterName: name,
|
||||||
activeClassId: 1,
|
activeClassId: 1,
|
||||||
completedDungeonParts: 0,
|
completedDungeonParts: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user