Files
healer-man/shared/bite-sized-layouts.mjs
2026-09-27 21:48:00 -04:00

125 lines
9.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import bundled from "./bite-sized-defaults.json" with { type: "json" };
import catalog from "./bite-sized-catalog.json" with { type: "json" };
export const BITE_SIZED_DEFAULT_CONFIG = bundled;
export const BITE_SIZED_LAYOUT_CATALOG = catalog;
const dungeonBySource = new Map(catalog.dungeons.map((d) => [d.sourceDungeonId, d]));
const creatureByRef = new Map(catalog.creatures.map((c) => [`${c.sourceDungeonId}:${c.entityId}`, c]));
const fail = (message) => { throw new Error(message); };
const identifier = (value) => typeof value === "string" && /^[A-Za-z0-9_:.-]{1,100}$/.test(value);
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
function vector(value, label) {
if (!Array.isArray(value) || value.length !== 3 || !value.every((n) => typeof n === "number" && Number.isFinite(n))) fail(`${label} needs three finite coordinates.`);
return [...value];
}
function spawn(value, label) {
if (!object(value) || !Number.isFinite(value.yaw)) fail(`${label} needs a finite facing.`);
return { footPosition: vector(value.footPosition, label), yaw: value.yaw };
}
export function normalizeBiteSizedSpot(value) {
if (!object(value) || !identifier(value.id)) fail("A spot needs a valid stable ID.");
const dungeon = dungeonBySource.get(value.sourceDungeonId);
if (!dungeon || value.dungeonId !== dungeon.id) fail("Choose a supported Bite-Sized parent dungeon.");
if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 100) fail("Name the spot using 1–100 characters.");
if (!Number.isSafeInteger(value.revision) || value.revision < 0) fail("Invalid spot revision.");
if (!["immediate", "after-mobs"].includes(value.bossActivation)) fail("Choose when the boss appears.");
if (!Array.isArray(value.mobs) || value.mobs.length > 500) fail("A spot supports up to 500 mob placements.");
const ids = new Set();
const mobs = value.mobs.map((m) => {
if (!object(m) || !identifier(m.id) || ids.has(m.id)) fail("Mob placement IDs must be unique.");
ids.add(m.id);
if (creatureByRef.get(`${m.sourceDungeonId}:${m.entityId}`)?.kind !== "mob") fail("Choose a known non-boss creature for each mob placement.");
if (m.yaw !== undefined && !Number.isFinite(m.yaw)) fail("Mob facing must be finite.");
if (m.spawnMask !== undefined && (!Number.isInteger(m.spawnMask) || m.spawnMask < 1 || m.spawnMask > 255)) fail("Invalid mob difficulty mask.");
if (m.spawnChance !== undefined && (!Number.isFinite(m.spawnChance) || m.spawnChance < 0 || m.spawnChance > 1)) fail("Invalid mob spawn chance.");
return { id: m.id, sourceDungeonId: m.sourceDungeonId, entityId: m.entityId, position: vector(m.position, "Mob spawn"), yaw: m.yaw ?? 0,
...(m.spawnMask !== undefined ? { spawnMask: m.spawnMask } : {}), ...(m.spawnChance !== undefined ? { spawnChance: m.spawnChance } : {}) };
});
const partySpawn = spawn(value.partySpawn, "Party spawn");
const bossSpawn = spawn(value.bossSpawn, "Boss spawn");
for (const point of [partySpawn.footPosition, bossSpawn.footPosition, ...mobs.map((m) => m.position)]) {
if (point.some((n, axis) => n < dungeon.bounds.min[axis] - 25 || n > dungeon.bounds.max[axis] + 25)) fail("Spawn points must stay inside the parent dungeon.");
}
return { id: value.id, dungeonId: dungeon.id, sourceDungeonId: dungeon.sourceDungeonId, name: value.name.trim(), revision: value.revision,
legacyLayout: value.legacyLayout === true, partySpawn, mobs, bossSpawn, bossActivation: value.bossActivation };
}
export function normalizeBiteSizedConfig(value) {
if (!object(value) || value.schemaVersion !== 1 || !Array.isArray(value.spots) || value.spots.length > 960) fail("Invalid Bite-Sized layout configuration.");
const spots = value.spots.map(normalizeBiteSizedSpot);
if (new Set(spots.map((s) => s.id)).size !== spots.length) fail("Spot IDs must be unique.");
for (const dungeon of catalog.dungeons) {
const count = spots.filter((s) => s.sourceDungeonId === dungeon.sourceDungeonId).length;
if (count < 1 || count > 80) fail(`${dungeon.title} needs between 1 and 80 spots.`);
}
return { schemaVersion: 1, spots };
}
export function biteSizedGameplayKey(value) {
const { name: _name, revision: _revision, ...gameplay } = normalizeBiteSizedSpot(value);
return JSON.stringify(gameplay);
}
export function biteSizedSeedHash(seed) {
let hash = 2166136261;
for (let i = 0; i < seed.length; i++) { hash ^= seed.charCodeAt(i); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function isLayoutBiteSizedDungeon(id) {
return catalog.dungeons.some((d) => d.id === id) || Object.hasOwn(catalog.pools, id);
}
export function defaultBiteSizedBoardScope(dungeonId) {
const id = Object.hasOwn(catalog.pools, dungeonId) ? "bite-sized-stormwind-stockade" : dungeonId;
const spot = bundled.spots.find((s) => s.dungeonId === id);
return spot ? { spotId: spot.id, spotRevision: 0 } : { spotId: null, spotRevision: null };
}
export function selectBiteSizedRun(config, dungeonId, seed, options = {}) {
const randomized = Object.hasOwn(catalog.pools, dungeonId);
const allowed = options.eligibleDungeonIds ?? catalog.dungeons.map((d) => d.id);
const parents = catalog.dungeons.filter((d) => allowed.includes(d.id) && (randomized || d.id === dungeonId)).sort((a, b) => a.id.localeCompare(b.id));
if (!parents.length) fail("No eligible Bite-Sized dungeon has a published spot.");
const parent = parents[biteSizedSeedHash(`${seed}:location`) % parents.length];
const spots = config.spots.filter((s) => s.dungeonId === parent.id).sort((a, b) => a.id.localeCompare(b.id));
const spot = options.spotId ? spots.find((s) => s.id === options.spotId) : spots[biteSizedSeedHash(`${seed}:spot`) % spots.length];
if (!spot) fail("That Bite-Sized spot is unavailable.");
const bossPool = randomized ? catalog.pools[dungeonId].filter((c) => c.kind === "boss") : parent.bossCandidates;
const boss = bossPool[biteSizedSeedHash(randomized ? `${seed}:boss` : seed) % bossPool.length];
const mobPool = randomized ? catalog.pools[dungeonId].filter((c) => c.kind === "mob") : [];
let remaining = [...mobPool];
const mobs = spot.mobs.map((placement, index) => {
let ref = placement;
if (randomized) {
if (!remaining.length) remaining = [...mobPool];
const entry = /(?:entry-|^rw-.*-)(\d+)$/.exec(placement.entityId)?.[1];
const originalKey = entry ? `${placement.entityId.startsWith("rw-") ? "rom" : "wow"}:mob:${entry}` : null;
const choices = remaining.filter((c) => c.key !== originalKey);
// Preserve the existing deal without replacement. Large authored spots
// start another deterministic deal only after exhausting the pool.
const candidates = choices.length ? choices : remaining;
ref = candidates[biteSizedSeedHash(`${seed}:mob:${placement.id}:${index}`) % candidates.length];
remaining.splice(remaining.indexOf(ref), 1);
}
return { placementId: placement.id, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
});
return normalizeBiteSizedSelection({ schemaVersion: 1, dungeonId, spot, boss, mobs });
}
export function normalizeBiteSizedSelection(value) {
if (!object(value) || value.schemaVersion !== 1 || !isLayoutBiteSizedDungeon(value.dungeonId)) fail("Invalid Bite-Sized run selection.");
const spot = normalizeBiteSizedSpot(value.spot);
const randomized = Object.hasOwn(catalog.pools, value.dungeonId);
if (!randomized && spot.dungeonId !== value.dungeonId) fail("The spot belongs to a different dungeon.");
const parent = dungeonBySource.get(spot.sourceDungeonId);
const pool = randomized ? catalog.pools[value.dungeonId].filter((c) => c.kind === "boss") : parent.bossCandidates;
const boss = pool.find((c) => c.sourceDungeonId === value.boss?.sourceDungeonId && c.entityId === value.boss?.entityId && c.objectiveId === value.boss?.objectiveId);
if (!boss || spot.mobs.some((m) => m.id === boss.objectiveId)) fail("Invalid selected boss or conflicting placement ID.");
if (!Array.isArray(value.mobs) || value.mobs.length !== spot.mobs.length) fail("The run is missing mob assignments.");
const mobs = spot.mobs.map((placement) => {
const entries = value.mobs.filter((m) => m.placementId === placement.id);
const ref = entries[0];
if (entries.length !== 1 || creatureByRef.get(`${ref.sourceDungeonId}:${ref.entityId}`)?.kind !== "mob") fail("Invalid selected mob.");
if (randomized ? !catalog.pools[value.dungeonId].some((c) => c.kind === "mob" && c.sourceDungeonId === ref.sourceDungeonId && c.entityId === ref.entityId)
: ref.sourceDungeonId !== placement.sourceDungeonId || ref.entityId !== placement.entityId) fail("The selected mob is outside this run's creature pool.");
return { placementId: placement.id, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
});
if (value.legacyRandomized && (!randomized || spot.id !== "bite-sized-stormwind-stockade:default" || spot.revision !== 0)) fail("Invalid legacy randomized layout.");
return { schemaVersion: 1, dungeonId: value.dungeonId, spot, boss: { sourceDungeonId: boss.sourceDungeonId, entityId: boss.entityId, objectiveId: boss.objectiveId, name: boss.name }, mobs,
...(value.legacyRandomized ? { legacyRandomized: true } : {}) };
}