clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

main
miau-is-true / src / catalog.js
catalog.js 7.9K
1import { levels as builtInLevels } from './levels.js';2import { LEGACY_RULES_VERSION, MOD_SCHEMA_VERSION, validateMod } from './mods.js';3import { cloneData } from './data/clone.js';4import { resolveWorldMetadata } from './mods/world.js';56export const CORE_PACK_ID = 'core';7const LOCAL_LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;8const ORIGINS = new Set(['local', 'url', 'bundled']);910function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }11function fail(errors) {12 const list = Array.isArray(errors) ? errors : [errors];13 const error = new Error(`Cannot register pack:\n- ${list.join('\n- ')}`);14 error.name = 'CatalogValidationError';15 error.errors = list;16 return error;17}1819/**20 * Return the built-in pack in the same shape as a loaded mod. Built-in levels21 * are copied so a caller cannot mutate the source registry through a game.22 */23export function createCorePack(sourceLevels = builtInLevels) {24 return {25 manifest: {26 id: CORE_PACK_ID,27 name: 'Miau Core',28 author: 'Miau Is True',29 version: '1.0.0',30 engine: MOD_SCHEMA_VERSION,31 rules: 2,32 world: { glyph: '🐈', tagline: 'Where the rules first bend.', accent: 'you' },33 },34 levels: cloneData(sourceLevels),35 palette: {},36 vocabulary: [],37 };38}3940function validatePack(pack) {41 if (!isRecord(pack)) throw fail('pack must be an object');42 const errors = validateMod(pack);43 const seen = new Set();44 if (Array.isArray(pack.levels)) {45 pack.levels.forEach((level, index) => {46 const localId = level?.id;47 if (typeof localId !== 'string' || !LOCAL_LEVEL_ID_RE.test(localId)) {48 errors.push(`levels[${index}].id: must be an authored lowercase id starting with a letter`);49 } else if (seen.has(localId)) {50 errors.push(`levels[${index}].id: duplicates another level id (${localId})`);51 } else {52 seen.add(localId);53 }54 });55 }56 if (errors.length) throw fail(errors);57}5859function makePackRecord(pack) {60 validatePack(pack);61 const normalized = cloneData(pack);62 normalized.manifest.rules ??= LEGACY_RULES_VERSION;63 const worldMetadata = resolveWorldMetadata(normalized);64 const packId = normalized.manifest.id;65 const entries = normalized.levels.map(level => {66 const localId = level.id;67 const id = `${packId}:${localId}`;68 return {69 id,70 localId,71 packId,72 packName: normalized.manifest.name,73 level: { ...level, id },74 palette: normalized.palette || {},75 };76 });77 return {78 id: packId,79 source: normalized,80 manifest: normalized.manifest,81 world: worldMetadata.world,82 worldWarnings: worldMetadata.warnings,83 palette: normalized.palette || {},84 levels: entries,85 };86}8788/**89 * Create an additive, deterministic pack catalog.90 *91 * The core pack is registered first. Imported packs append in registration92 * order. Pack IDs must be unique. Level IDs are unique inside a pack and are93 * exposed globally as `${packId}:${localId}`, so two packs may use the same94 * local level name without overwriting each other.95 */96export function createCatalog(options = {}) {97 const coreLevels = Array.isArray(options) ? options : options.coreLevels;98 const packs = new Map();99 const order = [];100 const levelsById = new Map();101 let orderedLevelIds = [];102 let generation = 0;103 let packSummaries = null;104 let packSummaryGeneration = -1;105 let activeId = null;106 const core = makePackRecord(createCorePack(coreLevels || builtInLevels));107 core.origin = 'bundled';108 packs.set(core.id, core);109 order.push(core.id);110 for (const entry of core.levels) levelsById.set(entry.id, entry);111 orderedLevelIds = core.levels.map(entry => entry.id);112 activeId = core.levels[0]?.id || null;113114 function invalidate() {115 generation += 1;116 }117118 function rebuildLevelOrder() {119 orderedLevelIds = order.flatMap(packId => packs.get(packId).levels.map(entry => entry.id));120 for (const [id, entry] of levelsById) if (!packs.has(entry.packId)) levelsById.delete(id);121 for (const packId of order) for (const entry of packs.get(packId).levels) levelsById.set(entry.id, entry);122 }123124 function firstAvailableLevelId() { return orderedLevelIds[0] || null; }125126 function register(pack, options = {}) {127 const origin = options.origin || 'local';128 const replace = options.replace === true;129 if (!ORIGINS.has(origin)) throw fail(`origin must be one of: ${[...ORIGINS].join(', ')}`);130 const record = makePackRecord(pack);131 record.origin = origin;132 const previous = packs.get(record.id);133 if (previous) {134 if (record.id === CORE_PACK_ID) throw fail('core pack is bundled and cannot be replaced');135 if (!replace) throw fail(`pack id collision: ${record.id}`);136 const replacedActive = levelsById.get(activeId)?.packId === record.id;137 for (const [id, entry] of levelsById) if (entry.packId === record.id) levelsById.delete(id);138 packs.set(record.id, record);139 rebuildLevelOrder();140 invalidate();141 if (replacedActive && !levelsById.has(activeId)) {142 activeId = record.levels[0]?.id || null;143 }144 if (!activeId) activeId = firstAvailableLevelId();145 return record.id;146 }147 packs.set(record.id, record);148 order.push(record.id);149 rebuildLevelOrder();150 invalidate();151 if (!activeId) activeId = record.levels[0]?.id || null;152 return record.id;153 }154155 /**156 * Remove an imported pack. Returns false when the ID is unknown. Bundled157 * packs, including core, are immutable and throw instead of being removed.158 */159 function removePack(id) {160 const record = packs.get(id);161 if (!record) return false;162 if (record.origin === 'bundled' || id === CORE_PACK_ID) throw fail(`bundled pack cannot be removed: ${id}`);163 const wasActive = levelsById.get(activeId)?.packId === id;164 packs.delete(id);165 order.splice(order.indexOf(id), 1);166 rebuildLevelOrder();167 invalidate();168 if (wasActive) activeId = firstAvailableLevelId();169 return true;170 }171172 function getPacks() {173 if (!packSummaries || packSummaryGeneration !== generation) {174 packSummaries = order.map(id => {175 const pack = packs.get(id);176 return {177 id: pack.id,178 origin: pack.origin,179 manifest: { ...cloneData(pack.manifest), world: cloneData(pack.world) },180 world: cloneData(pack.world),181 worldWarnings: cloneData(pack.worldWarnings),182 levelCount: pack.levels.length,183 };184 });185 packSummaryGeneration = generation;186 }187 return cloneData(packSummaries);188 }189 /** Return a validated deep clone of the original pack, or undefined. */190 function getPack(id) {191 const pack = packs.get(id);192 return pack ? cloneData(pack.source) : undefined;193 }194 function getLevels() {195 return order.flatMap(packId => packs.get(packId).levels.map(entry => ({196 ...cloneData(entry.level), id: entry.id, localId: entry.localId, packId: entry.packId,197 packName: entry.packName, origin: packs.get(entry.packId).origin, palette: cloneData(entry.palette),198 })));199 }200 function getLevel(identifier) {201 const id = typeof identifier === 'number' ? orderedLevelIds[identifier] : identifier;202 const entry = levelsById.get(id);203 if (!entry) return undefined;204 const pack = packs.get(entry.packId);205 return {206 ...cloneData(entry.level),207 id: entry.id,208 localId: entry.localId,209 packId: entry.packId,210 packName: entry.packName,211 origin: pack.origin,212 palette: cloneData(entry.palette),213 };214 }215 function setActiveLevel(identifier) {216 const level = getLevel(identifier);217 if (!level) return false;218 activeId = level.id;219 return true;220 }221 function getActiveLevel() { return activeId ? getLevel(activeId) : undefined; }222223 return Object.freeze({224 register,225 importMod: register,226 removePack,227 getPacks,228 getPack,229 getLevels,230 getLevel,231 setActiveLevel,232 getActiveLevel,233 get activeLevelId() { return activeId; },234 get size() { return orderedLevelIds.length; },235 });236}237238export const createPackCatalog = createCatalog;