export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 2; export const GM_REVIEW_NOTE_MAX_LENGTH = 1_000; export const DEFAULT_MANASTORM_ADMIN_CONFIG = Object.freeze({ schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global: Object.freeze({ enabled: true, levelingEnabled: true, endgameEnabled: true, affixesEnabled: true, chaoticLinkEnabled: true, milestoneEncountersEnabled: true, }), dungeonSpawns: Object.freeze({}), dungeons: Object.freeze({}), bosses: Object.freeze({}), }); const READINESS_STATUSES = new Set(["ready", "needs-tested", "not-ready"]); const MAX_RECORDS = 10_000; const MAX_ID_LENGTH = 256; function invalid(message) { const error = new Error(message); error.code = "invalid_manastorm_config"; return error; } function record(value, label) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw invalid(`${label} must be an object.`); } return value; } function identifier(value, label) { if (typeof value !== "string" || !value.trim() || value.length > MAX_ID_LENGTH) { throw invalid(`${label} must be a non-empty string no longer than ${MAX_ID_LENGTH} characters.`); } return value.trim(); } function optionalIdentifier(value, label) { return value === undefined || value === null ? undefined : identifier(value, label); } function reviewFields(value, label) { const fields = {}; if (value.note !== undefined && value.note !== null) { if (typeof value.note !== "string" || value.note.length > GM_REVIEW_NOTE_MAX_LENGTH) { throw invalid(`${label}.note must be a string no longer than ${GM_REVIEW_NOTE_MAX_LENGTH} characters.`); } const note = value.note.replace(/\r\n?/g, "\n").trim(); if (note) fields.note = note; } if (value.reviewedAt !== undefined && value.reviewedAt !== null) { if (!Number.isInteger(value.reviewedAt) || value.reviewedAt <= 0) { throw invalid(`${label}.reviewedAt must be a positive integer timestamp.`); } fields.reviewedAt = value.reviewedAt; } if (value.reviewedBy !== undefined && value.reviewedBy !== null) { fields.reviewedBy = identifier(value.reviewedBy, `${label}.reviewedBy`); } return fields; } function finiteNumber(value, label) { if (typeof value !== "number" || !Number.isFinite(value)) { throw invalid(`${label} must be a finite number.`); } return value; } function vector(value, label) { if (!Array.isArray(value) || value.length !== 3) { throw invalid(`${label} must contain exactly three coordinates.`); } return value.map((coordinate, index) => finiteNumber(coordinate, `${label}[${index}]`)); } function worldSpawn(value, label) { const input = record(value, label); return { footPosition: vector(input.footPosition, `${label}.footPosition`), yaw: finiteNumber(input.yaw, `${label}.yaw`), }; } function boolean(value, label) { if (typeof value !== "boolean") throw invalid(`${label} must be a boolean.`); return value; } function normalizeGlobal(value) { const input = record(value, "config.global"); return { enabled: boolean(input.enabled, "config.global.enabled"), levelingEnabled: boolean(input.levelingEnabled, "config.global.levelingEnabled"), endgameEnabled: boolean(input.endgameEnabled, "config.global.endgameEnabled"), affixesEnabled: boolean(input.affixesEnabled, "config.global.affixesEnabled"), chaoticLinkEnabled: boolean(input.chaoticLinkEnabled, "config.global.chaoticLinkEnabled"), milestoneEncountersEnabled: boolean( input.milestoneEncountersEnabled, "config.global.milestoneEncountersEnabled", ), }; } function normalizeStage(value, label) { const input = record(value, label); const linkedMobRuntimeIds = Array.isArray(input.linkedMobRuntimeIds) ? input.linkedMobRuntimeIds.map((id, index) => identifier(id, `${label}.linkedMobRuntimeIds[${index}]`)) : (() => { throw invalid(`${label}.linkedMobRuntimeIds must be an array.`); })(); if (new Set(linkedMobRuntimeIds).size !== linkedMobRuntimeIds.length) { throw invalid(`${label}.linkedMobRuntimeIds must not contain duplicates.`); } const requiredKillCount = finiteNumber(input.requiredKillCount, `${label}.requiredKillCount`); if (!Number.isInteger(requiredKillCount) || requiredKillCount < 0 || requiredKillCount > linkedMobRuntimeIds.length) { throw invalid(`${label}.requiredKillCount must be an integer between zero and the linked mob count.`); } const mapId = finiteNumber(input.mapId, `${label}.mapId`); if (!Number.isInteger(mapId) || mapId <= 0) throw invalid(`${label}.mapId must be a positive integer.`); return { dungeonId: identifier(input.dungeonId, `${label}.dungeonId`), mapId, bossRuntimeId: identifier(input.bossRuntimeId, `${label}.bossRuntimeId`), bossEntityId: identifier(input.bossEntityId, `${label}.bossEntityId`), bossName: identifier(input.bossName, `${label}.bossName`), ...(optionalIdentifier(input.baseStageId, `${label}.baseStageId`) ? { baseStageId: optionalIdentifier(input.baseStageId, `${label}.baseStageId`) } : {}), ...(optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`) ? { assetPackageId: optionalIdentifier(input.assetPackageId, `${label}.assetPackageId`) } : {}), bossPosition: vector(input.bossPosition, `${label}.bossPosition`), bossYaw: finiteNumber(input.bossYaw, `${label}.bossYaw`), groupSpawn: worldSpawn(input.groupSpawn, `${label}.groupSpawn`), portalPosition: vector(input.portalPosition, `${label}.portalPosition`), linkedMobRuntimeIds, requiredKillCount, }; } function normalizeDictionary(value, label, normalizeValue) { const input = record(value, label); const entries = Object.entries(input); if (entries.length > MAX_RECORDS) throw invalid(`${label} contains too many records.`); return Object.fromEntries(entries.map(([key, entry]) => [ identifier(key, `${label} key`), normalizeValue(entry, `${label}.${key}`), ])); } export function normalizeManastormAdminConfig(value) { const input = record(value, "config"); if (input.schemaVersion !== 1 && input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) { throw invalid(`Unsupported Manastorm admin config schema ${String(input.schemaVersion)}.`); } const dungeonSpawns = normalizeDictionary(input.dungeonSpawns, "config.dungeonSpawns", worldSpawn); const dungeons = input.dungeons === undefined ? {} : normalizeDictionary(input.dungeons, "config.dungeons", (rawDungeon, label) => { const dungeon = record(rawDungeon, label); if (!READINESS_STATUSES.has(dungeon.status)) throw invalid(`${label}.status is invalid.`); return { status: dungeon.status, ...reviewFields(dungeon, label) }; }); const bosses = normalizeDictionary(input.bosses, "config.bosses", (rawBoss, label) => { const boss = record(rawBoss, label); if (!READINESS_STATUSES.has(boss.status)) throw invalid(`${label}.status is invalid.`); if (boss.status === "ready" && (boss.stage === undefined || boss.stage === null)) { throw invalid(`${label} cannot be Ready without a complete stage.`); } return { status: boss.status, ...reviewFields(boss, label), ...(boss.stage === undefined || boss.stage === null ? {} : { stage: normalizeStage(boss.stage, `${label}.stage`) }), }; }); return { schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global: normalizeGlobal(input.global), dungeonSpawns, dungeons, bosses, }; } export function publicManastormAdminConfig(config) { return { ...config, dungeons: Object.fromEntries(Object.entries(config.dungeons).map(([key, record]) => [ key, { status: record.status }, ])), bosses: Object.fromEntries(Object.entries(config.bosses).map(([key, record]) => [ key, { status: record.status, ...(record.stage ? { stage: record.stage } : {}) }, ])), }; } function stampedReviewRecord(previous, next, reviewedBy, reviewedAt) { const changed = (previous?.status ?? "needs-tested") !== next.status || (previous?.note ?? "") !== (next.note ?? ""); const { reviewedAt: _incomingReviewedAt, reviewedBy: _incomingReviewedBy, ...record } = next; if (changed) return { ...record, reviewedAt, reviewedBy }; return { ...record, ...(previous?.reviewedAt ? { reviewedAt: previous.reviewedAt } : {}), ...(previous?.reviewedBy ? { reviewedBy: previous.reviewedBy } : {}), }; } export function stampManastormReviewMetadata(previous, next, reviewedBy, reviewedAt) { const reviewer = identifier(reviewedBy, "reviewedBy"); if (!Number.isInteger(reviewedAt) || reviewedAt <= 0) { throw invalid("reviewedAt must be a positive integer timestamp."); } return { ...next, dungeons: Object.fromEntries(Object.entries(next.dungeons).map(([key, record]) => [ key, stampedReviewRecord(previous.dungeons[key], record, reviewer, reviewedAt), ])), bosses: Object.fromEntries(Object.entries(next.bosses).map(([key, record]) => [ key, stampedReviewRecord(previous.bosses[key], record, reviewer, reviewedAt), ])), }; } export function cloneDefaultManastormAdminConfig() { return JSON.parse(JSON.stringify(DEFAULT_MANASTORM_ADMIN_CONFIG)); }