Add atomic account Boss Card checkpoints and preserve cards in legacy saves (#7)
This commit is contained in:
1 parent
ff20ab8779
commit
5385b14e3a
5 files changed
+392
-4
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user