import assert from "node:assert/strict"; import test from "node:test"; import { DatabaseSync } from "node:sqlite"; import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs"; import { BITE_SIZED_DEFAULT_CONFIG, selectBiteSizedRun } from "../shared/bite-sized-layouts.mjs"; import { mkdtempSync, rmSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import { createGameServer } from "./server.mjs"; import { readFileSync } from "node:fs"; import { gunzipSync } from "node:zlib"; class ApiError extends Error { constructor(message, status) { super(message); this.status = status; } } function fixture(t) { const db = new DatabaseSync(":memory:"); t.after(() => db.close()); return createBiteSizedLayoutRepository(db, { ErrorType: ApiError, isGameMaster: (a) => a?.gm === true }); } const gm = { gm: true }; test("publishes, duplicates, removes, archives gameplay changes and preserves rename timing", (t) => { const repository = fixture(t); const original = repository.getBiteSizedLayouts(); const spot = original.config.spots.find((s) => s.sourceDungeonId === "stormwind-stockade"); const renamed = structuredClone(original.config); renamed.spots.find((s) => s.id === spot.id).name = "A new name"; const first = repository.putBiteSizedLayouts(gm, 0, renamed); assert.equal(first.config.spots.find((s) => s.id === spot.id).revision, 0); const edited = structuredClone(first.config); edited.spots.find((s) => s.id === spot.id).bossActivation = "after-mobs"; edited.spots.push({ ...spot, id: "second-spot" }); const second = repository.putBiteSizedLayouts(gm, 1, edited); assert.equal(second.config.spots.find((s) => s.id === spot.id).revision, 1); const pinned = selectBiteSizedRun(original.config, spot.dungeonId, "active-run"); assert.deepEqual(repository.validateBiteSizedRunSelection(pinned, spot.dungeonId), pinned); repository.putBiteSizedLayouts(gm, 2, { ...second.config, spots: second.config.spots.filter((s) => s.id !== spot.id) }); assert.equal(repository.getBiteSizedSpotRevision(spot.id, 0).name, spot.name); assert.equal(repository.getBiteSizedSpotRevision(spot.id, 1).name, "A new name"); assert.throws(() => repository.putBiteSizedLayouts(gm, 1, edited), { status: 409 }); assert.equal(repository.getBiteSizedLayouts().revision, 3); }); test("rejects unauthorized publication, missing spots, malformed references, and off-floor transforms", (t) => { const repository = fixture(t); assert.throws(() => repository.putBiteSizedLayouts(null, 0, BITE_SIZED_DEFAULT_CONFIG), { status: 403 }); assert.throws(() => repository.putBiteSizedLayouts(gm, 0, { schemaVersion: 1, spots: [] }), { status: 400 }); const edited = structuredClone(BITE_SIZED_DEFAULT_CONFIG); const spot = edited.spots.find((s) => s.sourceDungeonId === "stormwind-stockade"); spot.partySpawn.footPosition[1] += 10; assert.throws(() => repository.putBiteSizedLayouts(gm, 0, edited), /walkable floor/); spot.partySpawn.footPosition[1] -= 10; spot.mobs[0].entityId = "missing-creature"; assert.throws(() => repository.putBiteSizedLayouts(gm, 0, edited), /known non-boss/); assert.equal(repository.getBiteSizedLayouts().revision, 0); assert.throws(() => repository.getBiteSizedSpotRevision("missing", 0), { status: 404 }); }); test("imported layouts retain branch closures while new spots can use the full parent floor", (t) => { const repository = fixture(t); const edited = structuredClone(BITE_SIZED_DEFAULT_CONFIG); const spot = edited.spots.find((s) => s.sourceDungeonId === "stormwind-stockade"); const graph = JSON.parse(gunzipSync(readFileSync(new URL("./generated/bite-sized-navigation.json.gz", import.meta.url))).toString("utf8"))[spot.sourceDungeonId]; const triangle = graph.triangles.find((indices) => { const points = indices.map((i) => graph.nodes[i].position); return points.every((p) => Math.abs(p[0] + 129.17) < 4 && p[2] > 27 && p[2] < 48); }); assert.ok(triangle, "a floor triangle exists beyond the original sealed branch"); const position = [0, 1, 2].map((axis) => triangle.reduce((total, i) => total + graph.nodes[i].position[axis], 0) / 3); spot.mobs = [{ ...spot.mobs[0], position }]; assert.throws(() => repository.putBiteSizedLayouts(gm, 0, edited), /cannot be reached/); const newSpot = { ...spot, id: "open-parent-branch", legacyLayout: false }; const published = repository.putBiteSizedLayouts(gm, 0, { ...BITE_SIZED_DEFAULT_CONFIG, spots: [...BITE_SIZED_DEFAULT_CONFIG.spots, newSpot] }); assert.deepEqual(published.config.spots.find((s) => s.id === newSpot.id).mobs[0].position, position); }); test("public reads, GM-only writes, conflict recovery, and archived HTTP revision reads", async () => { const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-layout-api-")); const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir }); await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve)); const origin = `http://127.0.0.1:${runtime.server.address().port}`; try { const read = async (query = "") => (await (await fetch(`${origin}/api/bite-sized-layouts${query}`)).json()); const initial = await read(); const register = async (username) => (await (await fetch(`${origin}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: "test-password" }), })).json()).token; const playerToken = await register("layout_reader"), gmToken = await register("codex_dev_gm"); const draft = structuredClone(initial.config); const spot = draft.spots.find((s) => s.sourceDungeonId === "stormwind-stockade"); spot.bossActivation = "after-mobs"; const put = (token, revision) => fetch(`${origin}/api/gm/bite-sized-layouts`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ expectedRevision: revision, config: draft }), }); assert.equal((await fetch(`${origin}/api/gm/bite-sized-layouts`)).status, 401); assert.equal((await put(playerToken, 0)).status, 403); assert.equal((await put(gmToken, 0)).status, 200); assert.equal((await put(gmToken, 0)).status, 409); assert.equal((await read()).revision, 1); assert.equal((await read(`?spotId=${encodeURIComponent(spot.id)}&revision=0`)).bossActivation, "immediate"); assert.equal((await read(`?spotId=${encodeURIComponent(spot.id)}&revision=1`)).bossActivation, "after-mobs"); } finally { await new Promise((resolve) => runtime.server.close(resolve)); rmSync(dataDir, { recursive: true, force: true }); } }); test("timing rankings isolate spot and gameplay revision and keep active race snapshots", async () => { const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-layout-rank-")); const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir }); await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve)); const database = runtime.database; try { const account = (await database.register("codex_dev_gm", "test-password")).account; const initial = database.getBiteSizedLayouts(); const spot = initial.config.spots.find((s) => s.sourceDungeonId === "stormwind-stockade"); const selection = database.resolveBiteSizedRun(spot.dungeonId, "same-boss"); const run = (id, pinned, duration) => ({ id, dungeonId: pinned.dungeonId, difficultyId: "normal", bossDifficulty: 1, durationMs: duration, finishedAt: Date.now(), killedBossIds: [pinned.boss.objectiveId], biteSizedSelection: pinned, party: [{ name: "Tester", classId: "priest", role: "healer", level: 20, npc: false }] }); const record = run("old-timing-run", selection, 1000); assert.deepEqual(database.submitDungeonRun(account, record), database.submitDungeonRun(account, record)); const match = database.createDungeonRaceMatch({ id: "pinned-race", seed: "same-boss", dungeonId: spot.dungeonId, difficultyId: "normal", createdAt: Date.now(), participants: [] }); const member = (await database.register("layout_party_member", "test-password")).account; database.inviteOnlineGroupMember(account, member.username); const invite = database.onlineGroupState(member.id).invitations[0]; database.respondToOnlineGroupInvite(member.id, invite.id, true); database.updateOnlineGroupMember(account.id, { id: "leader-character", name: "Leader", classId: "warrior", specializationId: "warrior:protection", level: 20 }, "tank"); database.updateOnlineGroupMember(member.id, { id: "member-character", name: "Member", classId: "priest", specializationId: "priest:discipline", level: 20 }, "healer"); const activity = database.startOnlineGroupActivity(account.id, { type: "dungeon", contentId: spot.dungeonId, fillWithAi: true, partySize: 5 }).group.activity; assert.deepEqual(database.onlineGroupState(member.id).group.activity.biteSizedSelection, activity.biteSizedSelection); const draft = structuredClone(initial.config); draft.spots.find((s) => s.id === spot.id).bossActivation = "after-mobs"; database.putBiteSizedLayouts(account, 0, draft); const changed = database.resolveBiteSizedRun(spot.dungeonId, "same-boss"); database.submitDungeonRun(account, run("new-timing-run", changed, 900)); const board = (revision) => database.listDungeonLeaderboard({ dungeonId: spot.dungeonId, difficultyId: "normal", bossDifficulty: 1, bossId: selection.boss.objectiveId, spotId: spot.id, spotRevision: revision }); assert.equal(board(0).total, 1); assert.equal(board(1).total, 1); assert.equal(board(0).entries[0].id, "old-timing-run"); assert.deepEqual(database.getDungeonRaceMatch(match.id).biteSizedSelection, selection); assert.deepEqual(database.onlineGroupState(account.id).group.activity.biteSizedSelection, activity.biteSizedSelection); assert.deepEqual(database.onlineGroupState(member.id).group.activity.biteSizedSelection, activity.biteSizedSelection); database.submitDungeonRun(account, run("old-after-publication", selection, 1100)); assert.equal(board(0).total, 2); } finally { await new Promise((resolve) => runtime.server.close(resolve)); rmSync(dataDir, { recursive: true, force: true }); } });