Add atomic account Boss Card checkpoints and preserve cards in legacy saves (#7)

This commit is contained in:
warren committed 2026-10-10 01:59:50 -04:00
1 parent ff20ab8779
commit 5385b14e3a
5 files changed
+392 -4

No files matched your search

+70 -3
View File
@@ -26,6 +26,7 @@ import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon } from "../shared/bite-sized-layouts.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import { mergeBossCardCollections } from "./boss-card-collections.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
const scrypt = promisify(scryptCallback);
@@ -402,13 +403,78 @@ export function openGameDatabase(options = {}) {
}
}
function cloudSaveDataForWrite(row) {
if (!row) return null;
try {
const data = JSON.parse(row.data_json);
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Cloud save data is not an object.");
return data;
} catch {
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
}
}
function previousCloudSaveForPut(row) {
try {
return cloudSaveDataForWrite(row);
} catch (error) {
// Keep legacy PUT's ability to replace an unreadable save document.
if (error instanceof GameDatabaseError && error.code === "cloud_save_corrupt") return null;
throw error;
}
}
function saveCloudData(accountId, data) {
const now = Date.now();
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
}
function transactCloudSave(operation) {
database.exec("BEGIN IMMEDIATE");
try {
const result = operation();
database.exec("COMMIT");
return result;
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
function putCloudSave(accountId, data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new GameDatabaseError("Cloud save data must be an object.");
}
const now = Date.now();
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
return transactCloudSave(() => {
const previous = previousCloudSaveForPut(statements.cloudSave.get(accountId));
const nextData = { ...data };
const hasPreviousCards = previous !== null && Object.hasOwn(previous, "bossCards");
const hasIncomingCards = Object.hasOwn(data, "bossCards");
if (hasPreviousCards || hasIncomingCards) {
nextData.bossCards = mergeBossCardCollections([previous?.bossCards, data.bossCards]);
}
return saveCloudData(accountId, nextData);
});
}
function patchCloudSaveBossCards(accountId, bossCards, expectedRevision) {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("Expected cloud save revision must be a nonnegative integer.", 400, "invalid_revision");
}
return transactCloudSave(() => {
const row = statements.cloudSave.get(accountId);
const currentRevision = row ? Number(row.revision) : 0;
if (currentRevision !== expectedRevision) {
throw new GameDatabaseError("Cloud save revision changed. Reload before retrying.", 409, "revision_conflict");
}
const previous = cloudSaveDataForWrite(row) ?? { version: 1, characters: [] };
const nextData = {
...previous,
bossCards: mergeBossCardCollections([previous.bossCards, bossCards]),
};
return saveCloudData(accountId, nextData);
});
}
function isGameMaster(account) {
@@ -1213,6 +1279,7 @@ export function openGameDatabase(options = {}) {
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
getCloudSave,
putCloudSave,
patchCloudSaveBossCards,
isGameMaster,
getManastormConfig,
putManastormConfig,