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 }; }