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

+245
View File
@@ -0,0 +1,245 @@
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { randomBytes } from "node:crypto";
import test from "node:test";
import { createGameServer } from "./server.mjs";
function fixtureUsername() {
return `card${process.pid.toString(36)}${randomBytes(4).toString("hex")}`;
}
async function startServer(dataDir) {
const root = path.join(dataDir, "http");
const staticDir = path.join(root, "static");
const contentDir = path.join(root, "content");
mkdirSync(staticDir, { recursive: true });
mkdirSync(contentDir, { recursive: true });
const runtime = createGameServer({
staticDir, contentDir, dataDir, enableShelvedModes: true, corsOrigins: "https://native-test.invalid",
});
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
return { runtime, origin: `http://127.0.0.1:${runtime.server.address().port}` };
}
async function stopServer(runtime) {
if (!runtime?.server.listening) return;
await new Promise((resolve, reject) => {
runtime.server.close((error) => error ? reject(error) : resolve());
});
}
async function request(origin, route, { method = "GET", token, body } = {}) {
const response = await fetch(`${origin}${route}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return { status: response.status, body: await response.json() };
}
async function register(origin, username, password) {
const result = await request(origin, "/api/auth/register", {
method: "POST", body: { username, password },
});
assert.equal(result.status, 201);
return result.body;
}
test("cloud card PATCH checkpoints atomically per authenticated account and legacy PUT cannot erase cards", async () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "healer-boss-card-checkpoint-"));
const firstPassword = `A${randomBytes(18).toString("base64url")}`;
const secondPassword = `B${randomBytes(18).toString("base64url")}`;
const firstUsername = fixtureUsername();
const secondUsername = fixtureUsername();
let runtime;
try {
let service = await startServer(tempDir);
runtime = service.runtime;
const unauthenticated = await request(service.origin, "/api/cloud-save", {
method: "PATCH", body: { expectedRevision: 0, bossCards: {} },
});
assert.equal(unauthenticated.status, 401);
const preflight = await fetch(`${service.origin}/api/cloud-save`, {
method: "OPTIONS",
headers: {
Origin: "https://native-test.invalid",
"Access-Control-Request-Method": "PATCH",
"Access-Control-Request-Headers": "authorization,content-type",
},
});
assert.equal(preflight.status, 204);
assert.equal(preflight.headers.get("access-control-allow-origin"), "https://native-test.invalid");
assert.match(preflight.headers.get("access-control-allow-methods"), /\bPATCH\b/);
const unauthorizedMalformed = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: "not-a-valid-session-token",
body: { expectedRevision: 0 },
});
assert.equal(unauthorizedMalformed.status, 401);
const firstAccount = await register(service.origin, firstUsername, firstPassword);
const secondAccount = await register(service.origin, secondUsername, secondPassword);
assert.notEqual(firstAccount.account.id, secondAccount.account.id);
assert.ok(firstAccount.token);
assert.ok(secondAccount.token);
const missingCards = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token, body: { expectedRevision: 0 },
});
assert.equal(missingCards.status, 400);
assert.equal(missingCards.body.code, "invalid_boss_cards");
const initialFirst = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
const initialSecond = await request(service.origin, "/api/cloud-save", { token: secondAccount.token });
assert.equal(initialFirst.body.revision, 0);
assert.deepEqual(initialFirst.body.data, { version: 1, characters: [] });
assert.equal(initialSecond.body.revision, 0);
const rawCards = JSON.parse(
'{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":2.9,"rare":0,"epic":1.7,"legendary":9007199254740992,"unknown":50},"":{"normal":7}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","ignored":42}}',
);
const firstCheckpoint = await request(service.origin, "/api/cloud-save", {
method: "PATCH",
token: firstAccount.token,
body: { accountId: secondAccount.account.id, expectedRevision: 0, bossCards: rawCards },
});
assert.equal(firstCheckpoint.status, 200);
assert.equal(firstCheckpoint.body.revision, 1);
assert.deepEqual(firstCheckpoint.body.data.version, 1);
assert.deepEqual(firstCheckpoint.body.data.characters, []);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].normal, 2);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].epic, 1);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].legendary, Number.MAX_SAFE_INTEGER);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].rare, undefined);
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].unknown, undefined);
assert.equal(firstCheckpoint.body.data.bossCards.counts[""], undefined);
assert.equal(firstCheckpoint.body.data.bossCards.counts["__proto__"].normal, 9);
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["__proto__"], "200:1");
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["spawn:a"], "100:2");
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims.ignored, undefined);
const conflict = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token,
body: { expectedRevision: 0, bossCards: { counts: { "wow:55": { normal: 50 } } } },
});
assert.equal(conflict.status, 409);
assert.equal(conflict.body.code, "revision_conflict");
const unchangedAfterConflict = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
assert.deepEqual(unchangedAfterConflict.body, firstCheckpoint.body);
const invalidRevision = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token,
body: { expectedRevision: 1.5, bossCards: {} },
});
assert.equal(invalidRevision.status, 400);
assert.equal(invalidRevision.body.code, "invalid_revision");
const retried = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstAccount.token,
body: {
expectedRevision: 1,
bossCards: {
counts: { "wow:55": { normal: 5, rare: 1 }, "wow:new": { epic: 1 } },
lastClaims: { "spawn:a": "100:1", "spawn:b": "alpha" },
},
},
});
assert.equal(retried.status, 200);
assert.equal(retried.body.revision, 2);
assert.equal(retried.body.data.bossCards.counts["wow:55"].normal, 5);
assert.equal(retried.body.data.bossCards.counts["wow:55"].rare, 1);
assert.equal(retried.body.data.bossCards.counts["wow:new"].epic, 1);
assert.equal(retried.body.data.bossCards.lastClaims["spawn:a"], "100:2");
const otherCheckpoint = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: secondAccount.token,
body: { expectedRevision: 0, bossCards: { counts: { "rom:9": { rare: 3 } } } },
});
assert.equal(otherCheckpoint.status, 200);
assert.equal(otherCheckpoint.body.revision, 1);
assert.deepEqual(Object.keys(otherCheckpoint.body.data.bossCards.counts), ["rom:9"]);
const firstStillIsolated = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
assert.equal(firstStillIsolated.body.revision, 2);
assert.equal(firstStillIsolated.body.data.bossCards.counts["rom:9"], undefined);
const stalePut = await request(service.origin, "/api/cloud-save", {
method: "PUT", token: firstAccount.token,
body: {
data: {
version: 2,
characters: [{ id: "character-a" }],
uiPreferences: { theme: "night" },
futureField: { preservedDuringThisWrite: true },
bossCards: {
counts: { "wow:55": { normal: 1 }, "wow:legacy": { legendary: 1 } },
lastClaims: { "spawn:a": "99:9", "spawn:c": "claim-c" },
},
},
},
});
assert.equal(stalePut.status, 200);
assert.equal(stalePut.body.revision, 3);
const stalePutExpectedData = JSON.parse(
'{"version":2,"characters":[{"id":"character-a"}],"uiPreferences":{"theme":"night"},"futureField":{"preservedDuringThisWrite":true},"bossCards":{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":5,"rare":1,"epic":1,"legendary":9007199254740991},"wow:new":{"epic":1},"wow:legacy":{"legendary":1}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","spawn:b":"alpha","spawn:c":"claim-c"}}}',
);
assert.deepEqual(stalePut.body.data, stalePutExpectedData);
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].normal, 5);
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].rare, 1);
assert.equal(stalePut.body.data.bossCards.counts["wow:legacy"].legendary, 1);
assert.equal(stalePut.body.data.bossCards.lastClaims["spawn:a"], "100:2");
const omittedCardPut = await request(service.origin, "/api/cloud-save", {
method: "PUT", token: firstAccount.token,
body: { data: { version: 3, characters: [], custom: "legacy replacement" } },
});
assert.equal(omittedCardPut.status, 200);
assert.equal(omittedCardPut.body.revision, 4);
assert.deepEqual(omittedCardPut.body.data, {
version: 3,
characters: [],
custom: "legacy replacement",
bossCards: stalePut.body.data.bossCards,
});
await stopServer(runtime);
runtime = null;
service = await startServer(tempDir);
runtime = service.runtime;
const firstLogin = await request(service.origin, "/api/auth/login", {
method: "POST", body: { username: firstUsername, password: firstPassword },
});
const secondLogin = await request(service.origin, "/api/auth/login", {
method: "POST", body: { username: secondUsername, password: secondPassword },
});
assert.equal(firstLogin.status, 200);
assert.equal(secondLogin.status, 200);
const reopenedFirst = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
const reopenedSecond = await request(service.origin, "/api/cloud-save", { token: secondLogin.body.token });
assert.deepEqual(reopenedFirst.body.data, omittedCardPut.body.data);
assert.deepEqual(reopenedSecond.body.data.bossCards.counts, { "rom:9": { rare: 3 } });
const concurrent = await Promise.all([0, 1].map(() => request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstLogin.body.token,
body: { expectedRevision: 4, bossCards: stalePut.body.data.bossCards },
})));
assert.deepEqual(concurrent.map((result) => result.status).sort(), [200, 409]);
assert.equal(concurrent.find((result) => result.status === 409).body.code, "revision_conflict");
const afterConcurrentCheckpoint = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
assert.equal(afterConcurrentCheckpoint.body.revision, 5);
assert.deepEqual(afterConcurrentCheckpoint.body.data, omittedCardPut.body.data);
const duplicateCheckpoint = await request(service.origin, "/api/cloud-save", {
method: "PATCH", token: firstLogin.body.token,
body: { expectedRevision: 5, bossCards: stalePut.body.data.bossCards },
});
assert.equal(duplicateCheckpoint.status, 200);
assert.equal(duplicateCheckpoint.body.revision, 6);
assert.deepEqual(duplicateCheckpoint.body.data.bossCards, omittedCardPut.body.data.bossCards);
} finally {
await stopServer(runtime);
rmSync(tempDir, { recursive: true, force: true });
}
});
+56
View File
@@ -0,0 +1,56 @@
const BOSS_CARD_RARITIES = ["normal", "rare", "epic", "legendary"];
export function emptyBossCardCollection() {
return { counts: {}, lastClaims: {} };
}
/** Keep this normalization in step with src/game/bossCards.ts. */
export function normalizeBossCardCollection(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return emptyBossCardCollection();
const raw = value;
const counts = Object.create(null);
if (raw.counts && typeof raw.counts === "object" && !Array.isArray(raw.counts)) {
for (const [key, candidate] of Object.entries(raw.counts)) {
if (!key || !candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
const entry = {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = candidate[rarity];
if (typeof count === "number" && Number.isFinite(count) && count >= 1) {
entry[rarity] = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(count));
}
}
if (Object.keys(entry).length) counts[key] = entry;
}
}
const lastClaims = raw.lastClaims && typeof raw.lastClaims === "object" && !Array.isArray(raw.lastClaims)
? Object.fromEntries(Object.entries(raw.lastClaims).filter(([key, claim]) => key && typeof claim === "string"))
: {};
return { counts, lastClaims };
}
/** Max-reconcile copies of one account collection, matching source mergeBossCardCollections(..., "max"). */
export function mergeBossCardCollections(collections) {
const counts = Object.create(null);
const lastClaims = Object.create(null);
for (const value of collections) {
const collection = normalizeBossCardCollection(value);
for (const [bossKey, entry] of Object.entries(collection.counts)) {
const merged = counts[bossKey] ??= {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = entry[rarity];
if (count === undefined) continue;
merged[rarity] = Math.max(merged[rarity] ?? 0, count);
}
}
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
const previous = lastClaims[spawnId];
if (!previous) { lastClaims[spawnId] = claim; continue; }
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
const [previousTime, previousRevision] = previous.split(":").map(Number);
const [time, revision] = claim.split(":").map(Number);
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
} else if (claim > previous) lastClaims[spawnId] = claim;
}
}
return { counts, lastClaims };
}
+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,
+10 -1
View File
@@ -209,7 +209,7 @@ export function createGameServer(options = {}) {
response.writeHead(204, {
...cors,
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Max-Age": "86400",
});
response.end();
@@ -445,6 +445,15 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
return;
}
if (url.pathname === "/api/cloud-save" && request.method === "PATCH") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, bodyLimit);
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "bossCards")) {
throw new GameDatabaseError("Boss Card collection is required.", 400, "invalid_boss_cards");
}
json(response, 200, gameDatabase.patchCloudSaveBossCards(account.id, body.bossCards, body.expectedRevision));
return;
}
if (url.pathname === "/api/online-group" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, gameDatabase.onlineGroupState(account.id));