forked from phenom/healer-man
408 lines
20 KiB
JavaScript
408 lines
20 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
|
|
|
const routeCatalog = JSON.parse(readFileSync(new URL("../src/game/generated/dungeonRouteCatalog.generated.json", import.meta.url), "utf8"));
|
|
|
|
function withDatabase(run) {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-test-"));
|
|
const database = openGameDatabase({ dataDir: directory, sessionTtlDays: 1 });
|
|
return Promise.resolve(run(database)).finally(() => {
|
|
database.close();
|
|
rmSync(directory, { recursive: true, force: true });
|
|
});
|
|
}
|
|
|
|
test("registers, authenticates, and revokes an account session", () => withDatabase(async (database) => {
|
|
const registered = await database.register("CaveHealer", "serpent-pass");
|
|
assert.equal(registered.account.username, "CaveHealer");
|
|
assert.equal(database.authenticate(registered.token).id, registered.account.id);
|
|
|
|
const loggedIn = await database.login("cavehealer", "serpent-pass");
|
|
assert.equal(loggedIn.account.id, registered.account.id);
|
|
database.logout(loggedIn.token);
|
|
assert.throws(() => database.authenticate(loggedIn.token), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 401
|
|
));
|
|
}));
|
|
|
|
test("rejects duplicate usernames and incorrect passwords", () => withDatabase(async (database) => {
|
|
await database.register("PartyLead", "eight-plus");
|
|
await assert.rejects(database.register("partylead", "another-pass"), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 409
|
|
));
|
|
await assert.rejects(database.login("PartyLead", "wrong-pass"), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 401
|
|
));
|
|
}));
|
|
|
|
test("persists opaque cloud roster data with increasing revisions", () => withDatabase(async (database) => {
|
|
const registered = await database.register("CloudPriest", "cloud-pass");
|
|
assert.equal(database.getCloudSave(registered.account.id).revision, 0);
|
|
const first = database.putCloudSave(registered.account.id, {
|
|
version: 1,
|
|
characters: [{ id: "character-one", name: "Mendwell" }],
|
|
});
|
|
const second = database.putCloudSave(registered.account.id, {
|
|
version: 1,
|
|
characters: [{ id: "character-one", name: "Mendbetter" }],
|
|
});
|
|
assert.equal(first.revision, 1);
|
|
assert.equal(second.revision, 2);
|
|
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
|
}));
|
|
|
|
test("derives GM authority from the hardcoded developer accounts", () => withDatabase(async (database) => {
|
|
const gm = await database.register("Phenom", "storm-admin");
|
|
const developerGm = await database.register("codex_dev_gm", "developer-admin");
|
|
const player = await database.register("Ordinary", "storm-player");
|
|
assert.deepEqual(gm.account.roles, ["gm"]);
|
|
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
|
|
assert.deepEqual(developerGm.account.roles, ["gm"]);
|
|
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
|
|
assert.equal(database.isGameMaster(database.authenticate(developerGm.token)), true);
|
|
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
|
|
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
|
|
}));
|
|
|
|
test("recovers Manastorm configuration after restart and falls back safely from corrupt data", async () => {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-db-restart-test-"));
|
|
const databasePath = path.join(directory, "game.db");
|
|
try {
|
|
const first = openGameDatabase({ databasePath });
|
|
const gm = await first.register("Phenom", "storm-admin");
|
|
const current = first.getManastormConfig();
|
|
first.putManastormConfig(first.authenticate(gm.token), 0, {
|
|
...current.config,
|
|
global: { ...current.config.global, enabled: false },
|
|
});
|
|
first.close();
|
|
|
|
const restarted = openGameDatabase({ databasePath });
|
|
assert.equal(restarted.getManastormConfig().revision, 1);
|
|
assert.equal(restarted.getManastormConfig().config.global.enabled, false);
|
|
restarted.close();
|
|
|
|
const raw = new DatabaseSync(databasePath);
|
|
raw.prepare("UPDATE manastorm_admin_config SET config_json = ? WHERE id = 1").run("{corrupt");
|
|
raw.close();
|
|
const recovered = openGameDatabase({ databasePath });
|
|
assert.equal(recovered.getManastormConfig().revision, 0);
|
|
assert.deepEqual(recovered.getManastormConfig().config.bosses, {});
|
|
recovered.close();
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("persists revisioned Manastorm GM configuration and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
|
|
const gm = await database.register("Phenom", "storm-admin");
|
|
const player = await database.register("Ordinary", "storm-player");
|
|
const gmAccount = database.authenticate(gm.token);
|
|
const playerAccount = database.authenticate(player.token);
|
|
const initial = database.getManastormConfig();
|
|
assert.equal(initial.revision, 0);
|
|
assert.deepEqual(initial.config.bosses, {});
|
|
|
|
const configured = {
|
|
...initial.config,
|
|
global: { ...initial.config.global, affixesEnabled: false },
|
|
dungeons: {
|
|
deadmines: {
|
|
status: "not-ready",
|
|
note: "The final door trigger needs fixing.",
|
|
reviewedAt: 1,
|
|
reviewedBy: "SpoofedGM",
|
|
},
|
|
},
|
|
};
|
|
const saved = database.putManastormConfig(gmAccount, 0, configured);
|
|
assert.equal(saved.revision, 1);
|
|
assert.equal(saved.config.global.affixesEnabled, false);
|
|
assert.deepEqual(saved.config.dungeons.deadmines, {
|
|
status: "not-ready",
|
|
note: "The final door trigger needs fixing.",
|
|
reviewedAt: saved.updatedAt,
|
|
reviewedBy: "Phenom",
|
|
});
|
|
assert.equal(database.getManastormConfig().updatedBy, "Phenom");
|
|
assert.throws(() => database.putManastormConfig(gmAccount, 0, configured), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 409
|
|
));
|
|
assert.throws(() => database.putManastormConfig(playerAccount, 1, configured), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 403
|
|
));
|
|
assert.throws(() => database.putManastormConfig(gmAccount, 1, {
|
|
...configured,
|
|
bosses: { "boss:incomplete": { status: "ready" } },
|
|
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
|
}));
|
|
|
|
test("persists revisioned companion cooldown policy and rejects stale or unauthorized writes", () => withDatabase(async (database) => {
|
|
const gm = await database.register("Phenom", "cooldown-admin");
|
|
const player = await database.register("Ordinary", "cooldown-player");
|
|
const initial = database.getCompanionCooldownPolicy();
|
|
const configured = {
|
|
...initial.config,
|
|
generic: { ...initial.config.generic, defensiveHealthPercent: 61 },
|
|
mechanics: [{
|
|
id: "tank-buster", enabled: true, bossKey: "test/boss", bossName: "Test Boss",
|
|
bossServerEntry: 123, attackId: "heavy-strike", attackName: "Heavy Strike",
|
|
scope: "tank", leadTimeMs: 2_500, minimumCooldownSeconds: 20,
|
|
}],
|
|
};
|
|
const saved = database.putCompanionCooldownPolicy(database.authenticate(gm.token), 0, configured);
|
|
assert.equal(saved.revision, 1);
|
|
assert.equal(database.getCompanionCooldownPolicy().config.generic.defensiveHealthPercent, 61);
|
|
assert.throws(() => database.putCompanionCooldownPolicy(database.authenticate(gm.token), 0, configured), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 409
|
|
));
|
|
assert.throws(() => database.putCompanionCooldownPolicy(database.authenticate(player.token), 1, configured), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 403
|
|
));
|
|
}));
|
|
|
|
test("persists validated companion readiness with authorization, revisions, and corrupt-data fallback", () => withDatabase(async (database) => {
|
|
const gm = await database.register("Phenom", "readiness-admin");
|
|
const player = await database.register("ReadinessPlayer", "readiness-player");
|
|
const initial = database.getCompanionSpecReadiness();
|
|
assert.equal(initial.revision, 0);
|
|
assert.equal(initial.config.statuses["warrior:protection"], "ready");
|
|
assert.equal(Object.values(initial.config.statuses).filter((status) => status === "ready").length, 5);
|
|
|
|
const configured = {
|
|
...initial.config,
|
|
statuses: { ...initial.config.statuses, "paladin:retribution": "beta" },
|
|
};
|
|
const account = database.authenticate(gm.token);
|
|
const saved = database.putCompanionSpecReadiness(account, 0, configured);
|
|
assert.equal(saved.revision, 1);
|
|
assert.equal(database.getCompanionSpecReadiness().config.statuses["paladin:retribution"], "beta");
|
|
assert.throws(() => database.putCompanionSpecReadiness(account, 0, configured), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 409
|
|
));
|
|
assert.throws(() => database.putCompanionSpecReadiness(database.authenticate(player.token), 1, configured), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 403
|
|
));
|
|
assert.throws(() => database.putCompanionSpecReadiness(account, 1, {
|
|
schemaVersion: 1,
|
|
statuses: { ...configured.statuses, "warrior:protection": "needs-testing" },
|
|
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
|
|
|
const raw = new DatabaseSync(database.databasePath);
|
|
raw.prepare("UPDATE companion_spec_readiness SET config_json = ? WHERE id = 1").run("{not-json");
|
|
raw.close();
|
|
const fallback = database.getCompanionSpecReadiness();
|
|
assert.equal(fallback.revision, 0);
|
|
assert.equal(fallback.config.statuses["warrior:protection"], "ready");
|
|
}));
|
|
|
|
test("stores GM simulation replays and gates exact-hash route approval", () => withDatabase(async (database) => {
|
|
const gmAuth = await database.register("Phenom", "storm-admin");
|
|
const playerAuth = await database.register("RoutePlayer", "route-player");
|
|
const gm = database.authenticate(gmAuth.token);
|
|
const player = database.authenticate(playerAuth.token);
|
|
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "deadmines").hash;
|
|
const replay = {
|
|
schemaVersion: 1,
|
|
dungeonId: "deadmines",
|
|
routeId: "primary",
|
|
routeHash,
|
|
mode: "navigation-only",
|
|
seed: "deadmines:primary",
|
|
result: "completed",
|
|
startedAt: 1_700_000_000_000,
|
|
durationMs: 42_000,
|
|
report: { schemaVersion: 1, findings: [] },
|
|
frames: [{ atMs: 0, actors: [] }],
|
|
events: [],
|
|
};
|
|
|
|
assert.throws(() => database.saveDungeonSimulationReplay(player, replay), (error) => (
|
|
error instanceof GameDatabaseError && error.status === 403
|
|
));
|
|
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
|
|
routeHash, status: "Approved", expectedRevision: 0,
|
|
}), (error) => error instanceof GameDatabaseError && error.code === "route_run_required");
|
|
|
|
const saved = database.saveDungeonSimulationReplay(gm, replay);
|
|
assert.equal(saved.criticalFindings, 0);
|
|
assert.equal(database.getDungeonSimulationReplay(gm, saved.id).frames.length, 1);
|
|
assert.equal(database.getDungeonSimulationReport(gm, saved.id).schemaVersion, 1);
|
|
|
|
const review = database.putDungeonRouteReview(gm, "deadmines", "primary", {
|
|
routeHash, status: "Approved", expectedRevision: 0, note: "Clean navigation-only pass.",
|
|
});
|
|
assert.equal(review.revision, 1);
|
|
assert.deepEqual(database.listDungeonRouteApprovals().approvals.map(({ reviewedAt: _reviewedAt, ...entry }) => entry), [{
|
|
dungeonId: "deadmines", routeId: "primary", routeHash,
|
|
}]);
|
|
assert.throws(() => database.putDungeonRouteReview(gm, "deadmines", "primary", {
|
|
routeHash, status: "NeedsWork", expectedRevision: 0,
|
|
}), (error) => error instanceof GameDatabaseError && error.status === 409);
|
|
assert.equal(database.deleteDungeonSimulationReplay(gm, saved.id).ok, true);
|
|
}));
|
|
|
|
test("requires an override note when the latest exact-hash route run has critical findings", () => withDatabase(async (database) => {
|
|
const gmAuth = await database.register("Phenom", "storm-admin");
|
|
const gm = database.authenticate(gmAuth.token);
|
|
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "blackfathom-deeps").hash;
|
|
database.saveDungeonSimulationReplay(gm, {
|
|
schemaVersion: 1,
|
|
dungeonId: "blackfathom-deeps",
|
|
routeId: "primary",
|
|
routeHash,
|
|
mode: "navigation-only",
|
|
seed: "route-seed",
|
|
result: "completed",
|
|
startedAt: 1_700_000_000_000,
|
|
durationMs: 10_000,
|
|
report: { schemaVersion: 1, findings: [{ severity: "critical", code: "no-path" }] },
|
|
frames: [],
|
|
events: [],
|
|
});
|
|
assert.throws(() => database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
|
|
routeHash, status: "Approved", expectedRevision: 0,
|
|
}), (error) => error instanceof GameDatabaseError && error.code === "route_override_required");
|
|
const review = database.putDungeonRouteReview(gm, "blackfathom-deeps", "primary", {
|
|
routeHash, status: "Approved", expectedRevision: 0, criticalOverrideNote: "Known scripted lift gap.",
|
|
});
|
|
assert.equal(review.status, "Approved");
|
|
assert.equal(review.criticalOverrideNote, "Known scripted lift gap.");
|
|
}));
|
|
|
|
test("requires the latest exact-hash route run to be completed before approval", () => withDatabase(async (database) => {
|
|
const gmAuth = await database.register("Phenom", "storm-admin");
|
|
const gm = database.authenticate(gmAuth.token);
|
|
const routeHash = routeCatalog.routes.find((route) => route.dungeonId === "razorfen-downs").hash;
|
|
database.saveDungeonSimulationReplay(gm, {
|
|
schemaVersion: 1,
|
|
dungeonId: "razorfen-downs",
|
|
routeId: "primary",
|
|
routeHash,
|
|
mode: "full-combat",
|
|
seed: "incomplete-route-seed",
|
|
result: "stopped",
|
|
startedAt: 1_700_000_000_000,
|
|
durationMs: 10_000,
|
|
report: { schemaVersion: 1, findings: [] },
|
|
frames: [],
|
|
events: [],
|
|
});
|
|
assert.throws(() => database.putDungeonRouteReview(gm, "razorfen-downs", "primary", {
|
|
routeHash, status: "Approved", expectedRevision: 0,
|
|
}), (error) => error instanceof GameDatabaseError && error.code === "route_completed_run_required");
|
|
}));
|
|
|
|
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
|
|
const leaderAuth = await database.register("GroupLeader", "group-pass");
|
|
const healerAuth = await database.register("GroupHealer", "healer-pass");
|
|
const leader = database.authenticate(leaderAuth.token);
|
|
const healer = database.authenticate(healerAuth.token);
|
|
|
|
const invitation = database.inviteOnlineGroupMember(leader, "grouphealer");
|
|
assert.equal(invitation.state.group.members.length, 1);
|
|
const incoming = database.onlineGroupState(healer.id).invitations;
|
|
assert.equal(incoming.length, 1);
|
|
assert.equal(incoming[0].inviterUsername, "GroupLeader");
|
|
|
|
const accepted = database.respondToOnlineGroupInvite(healer.id, incoming[0].id, true);
|
|
assert.equal(accepted.group.members.length, 2);
|
|
assert.equal(accepted.group.leaderAccountId, leader.id);
|
|
|
|
database.leaveOnlineGroup(leader.id);
|
|
const transferred = database.onlineGroupState(healer.id);
|
|
assert.equal(transferred.group.leaderAccountId, healer.id);
|
|
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
|
}));
|
|
|
|
test("keeps RoM and WoW characters in separate online groups", () => withDatabase(async (database) => {
|
|
const leaderAuth = await database.register("SourceLeader", "group-pass");
|
|
const memberAuth = await database.register("SourceMember", "member-pass");
|
|
const leader = database.authenticate(leaderAuth.token);
|
|
const member = database.authenticate(memberAuth.token);
|
|
database.inviteOnlineGroupMember(leader, member.username);
|
|
const invite = database.onlineGroupState(member.id).invitations[0];
|
|
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
|
|
|
const wowCharacter = { id: "wow-priest", name: "Wow Priest", classId: "priest", categoryId: "wow", level: 20 };
|
|
const romCharacter = { id: "rom-priest", name: "RoM Priest", classId: "rom-priest", categoryId: "rom", level: 20 };
|
|
database.updateOnlineGroupMember(leader.id, wowCharacter, "healer");
|
|
assert.throws(() => database.updateOnlineGroupMember(member.id, romCharacter, "healer"),
|
|
(error) => error instanceof GameDatabaseError && error.code === "mixed_group_sources");
|
|
assert.equal(database.onlineGroupState(member.id).group.members[1].character, null);
|
|
|
|
database.updateOnlineGroupMember(leader.id, romCharacter, "healer");
|
|
assert.throws(() => database.updateOnlineGroupMember(member.id, wowCharacter, "healer"),
|
|
(error) => error instanceof GameDatabaseError && error.code === "mixed_group_sources");
|
|
database.updateOnlineGroupMember(member.id, { ...romCharacter, id: "rom-mage", name: "RoM Mage", classId: "rom-mage" }, "damage");
|
|
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
|
type: "dungeon", contentId: "randomized-bite-sized-stormwind-stockade", fillWithAi: true, partySize: 5,
|
|
}), (error) => error instanceof GameDatabaseError && error.code === "group_source_incompatible");
|
|
const started = database.startOnlineGroupActivity(leader.id, {
|
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
|
});
|
|
assert.equal(started.group.activity.partySize, 2);
|
|
assert.equal(started.group.activity.fillWithAi, false);
|
|
assert.deepEqual(started.group.activity.companionSpecializationIds, []);
|
|
}));
|
|
|
|
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
|
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
|
const memberAuth = await database.register("ReadyMember", "member-pass");
|
|
const leader = database.authenticate(leaderAuth.token);
|
|
const member = database.authenticate(memberAuth.token);
|
|
database.inviteOnlineGroupMember(leader, member.username);
|
|
const invite = database.onlineGroupState(member.id).invitations[0];
|
|
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
|
|
|
database.updateOnlineGroupMember(leader.id, {
|
|
id: "leader-character", name: "Leadwell", classId: "warrior", specializationId: "warrior:protection", raceId: "human", gender: "male", level: 20,
|
|
}, "tank");
|
|
assert.equal(database.onlineGroupState(leader.id).group.members[0].character.specializationId, "warrior:protection");
|
|
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
|
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
|
|
|
database.updateOnlineGroupMember(member.id, {
|
|
id: "member-character", name: "Mendwell", classId: "priest", specializationId: "priest:holy", raceId: "human", gender: "female", level: 20,
|
|
}, "healer");
|
|
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
|
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
|
});
|
|
assert.equal(playersOnly.group.activity.partySize, 2);
|
|
assert.equal(playersOnly.group.activity.fillWithAi, false);
|
|
|
|
const filled = database.startOnlineGroupActivity(leader.id, {
|
|
type: "manastorm", contentId: "manastorm", fillWithAi: true, includeBetaSpecs: true, partySize: 5, startingLevel: 10,
|
|
});
|
|
assert.equal(filled.group.activity.partySize, 5);
|
|
assert.equal(filled.group.activity.startingLevel, 10);
|
|
assert.equal(filled.group.activity.includeBetaSpecs, true);
|
|
const randomized = database.startOnlineGroupActivity(leader.id, {
|
|
type: "dungeon",
|
|
contentId: "randomized-bite-sized-stormwind-stockade",
|
|
fillWithAi: false,
|
|
includeBetaSpecs: true,
|
|
partySize: 2,
|
|
});
|
|
assert.equal(randomized.group.activity.partySize, 5);
|
|
assert.equal(randomized.group.activity.fillWithAi, true);
|
|
assert.equal(randomized.group.activity.includeBetaSpecs, false);
|
|
assert.equal(randomized.group.activity.companionReadinessRevision, 0);
|
|
assert.deepEqual(randomized.group.activity.companionSpecializationIds.sort(), [
|
|
"hunter:marksmanship",
|
|
"mage:fire",
|
|
"priest:discipline",
|
|
"rogue:assassination",
|
|
"warrior:protection",
|
|
]);
|
|
assert.throws(() => database.startOnlineGroupActivity(member.id, {
|
|
type: "dungeon", contentId: "wailing-caverns",
|
|
}), (error) => error instanceof GameDatabaseError && error.status === 403);
|
|
}));
|