143 lines
10 KiB
JavaScript
143 lines
10 KiB
JavaScript
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 }); }
|
|
});
|