238 lines
9.1 KiB
JavaScript
238 lines
9.1 KiB
JavaScript
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));
|
|
}
|