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 } : {}) }; }