forked from phenom/healer-man
125 lines
9.1 KiB
JavaScript
125 lines
9.1 KiB
JavaScript
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 } : {}) };
|
||
}
|