forked from phenom/healer-man
78 lines
5.3 KiB
JavaScript
78 lines
5.3 KiB
JavaScript
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, normalizeBiteSizedConfig, normalizeBiteSizedSelection, biteSizedGameplayKey, selectBiteSizedRun } from "../shared/bite-sized-layouts.mjs";
|
|
import { readFileSync } from "node:fs";
|
|
import { gunzipSync } from "node:zlib";
|
|
import { validateBiteSizedSpotNavigation } from "./generated/bite-sized-navigation.mjs";
|
|
|
|
const navigationGraphs = JSON.parse(gunzipSync(readFileSync(new URL("./generated/bite-sized-navigation.json.gz", import.meta.url))).toString("utf8"));
|
|
|
|
export function createBiteSizedLayoutRepository(database, { ErrorType, isGameMaster }) {
|
|
database.exec(`CREATE TABLE IF NOT EXISTS bite_sized_layout_config (
|
|
id INTEGER PRIMARY KEY CHECK(id = 1), revision INTEGER NOT NULL, updated_at INTEGER, config_json TEXT NOT NULL
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS bite_sized_spot_revisions (
|
|
spot_id TEXT NOT NULL, revision INTEGER NOT NULL, spot_json TEXT NOT NULL, PRIMARY KEY(spot_id, revision)
|
|
) STRICT;`);
|
|
database.prepare("INSERT OR IGNORE INTO bite_sized_layout_config VALUES (1, 0, NULL, ?)").run(JSON.stringify(normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG)));
|
|
const archive = database.prepare("INSERT OR IGNORE INTO bite_sized_spot_revisions VALUES (?, ?, ?)");
|
|
for (const spot of normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG).spots) archive.run(spot.id, spot.revision, JSON.stringify(spot));
|
|
function getBiteSizedLayouts() {
|
|
const row = database.prepare("SELECT * FROM bite_sized_layout_config WHERE id = 1").get();
|
|
return { revision: row.revision, updatedAt: row.updated_at, config: normalizeBiteSizedConfig(JSON.parse(row.config_json)) };
|
|
}
|
|
function getBiteSizedSpotRevision(id, revision) {
|
|
const row = database.prepare("SELECT spot_json FROM bite_sized_spot_revisions WHERE spot_id = ? AND revision = ?").get(id, revision);
|
|
if (!row) throw new ErrorType("That spot revision is unavailable.", 404, "spot_not_found");
|
|
return JSON.parse(row.spot_json);
|
|
}
|
|
function listBiteSizedSpotRevisions() {
|
|
return database.prepare("SELECT spot_json FROM bite_sized_spot_revisions ORDER BY spot_id, revision DESC").all().map((row) => JSON.parse(row.spot_json));
|
|
}
|
|
function putBiteSizedLayouts(account, expectedRevision, input) {
|
|
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
|
|
let config;
|
|
try { config = normalizeBiteSizedConfig(input); }
|
|
catch (error) { throw new ErrorType(error.message, 400, "invalid_bite_sized_layout"); }
|
|
database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
const current = getBiteSizedLayouts();
|
|
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== current.revision) throw new ErrorType("Layouts changed on the server. Refresh before publishing; your draft is still available.", 409, "revision_conflict");
|
|
for (const spot of config.spots) {
|
|
const previous = current.config.spots.find((s) => s.id === spot.id);
|
|
if (previous && biteSizedGameplayKey(previous) === biteSizedGameplayKey(spot)) continue;
|
|
try {
|
|
const graph = navigationGraphs[spot.sourceDungeonId];
|
|
const blockers = spot.legacyLayout ? BITE_SIZED_LAYOUT_CATALOG.dungeons.find((d) => d.id === spot.dungeonId).legacyBlockers : [];
|
|
validateBiteSizedSpotNavigation(spot, graph, spot.legacyLayout ? graph.legacyRegions ?? graph.regions : graph.regions, blockers);
|
|
}
|
|
catch (error) { throw new ErrorType(error.message, 400, "invalid_bite_sized_navigation"); }
|
|
}
|
|
const spots = config.spots.map((spot) => {
|
|
const previous = current.config.spots.find((s) => s.id === spot.id);
|
|
const latest = database.prepare("SELECT MAX(revision) AS revision FROM bite_sized_spot_revisions WHERE spot_id = ?").get(spot.id).revision;
|
|
const revision = previous && biteSizedGameplayKey(previous) === biteSizedGameplayKey(spot) ? previous.revision : (latest ?? -1) + 1;
|
|
const next = { ...spot, revision };
|
|
archive.run(next.id, revision, JSON.stringify(next));
|
|
return next;
|
|
});
|
|
const next = { schemaVersion: 1, spots };
|
|
database.prepare("UPDATE bite_sized_layout_config SET revision = revision + 1, updated_at = ?, config_json = ? WHERE id = 1").run(Date.now(), JSON.stringify(next));
|
|
database.exec("COMMIT");
|
|
return getBiteSizedLayouts();
|
|
} catch (error) { database.exec("ROLLBACK"); throw error; }
|
|
}
|
|
function resolveBiteSizedRun(dungeonId, seed, eligibleDungeonIds) {
|
|
return selectBiteSizedRun(getBiteSizedLayouts().config, dungeonId, seed, { eligibleDungeonIds });
|
|
}
|
|
function validateBiteSizedRunSelection(input, dungeonId) {
|
|
let selection;
|
|
try { selection = normalizeBiteSizedSelection(input); }
|
|
catch (error) { throw new ErrorType(error.message, 400, "invalid_bite_sized_selection"); }
|
|
const archived = getBiteSizedSpotRevision(selection.spot.id, selection.spot.revision);
|
|
if (selection.dungeonId !== dungeonId || biteSizedGameplayKey(archived) !== biteSizedGameplayKey(selection.spot)) {
|
|
throw new ErrorType("The run does not match its published spot revision.", 400, "invalid_bite_sized_selection");
|
|
}
|
|
return selection;
|
|
}
|
|
return { getBiteSizedLayouts, getBiteSizedSpotRevision, listBiteSizedSpotRevisions, putBiteSizedLayouts, resolveBiteSizedRun, validateBiteSizedRunSelection };
|
|
}
|