clone

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

main
miau-is-true / src / pack-service.js
pack-service.js 9.7K
1/** Presentation-independent pack actions for the stage and developer tools. */2import { collectModWarnings, loadMod } from './mods.js';3import { createCatalog } from './catalog.js';4import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';5import { cloneData } from './data/clone.js';6import { resolveWorldMetadata } from './mods/world.js';7import { playableLevelFromView } from './level-view.js';89function errorDetails(error) {10 return Array.isArray(error?.errors) ? error.errors.map(String)11 : [error instanceof Error ? error.message : String(error)];12}1314function playerError(detail, source) {15 if (/^manifest\.features(?:\[\d+\])?: unsupported feature\b/.test(detail)) {16 return { message: 'This pack needs a newer Miau.', detail };17 }18 if (/^manifest\.rules: unsupported future rules revision\b/.test(detail)) {19 return { message: 'This pack needs a newer version of Miau Is True. Update the game to play it.', detail };20 }21 if (/^manifest\.rules: must be a positive integer\b/.test(detail)) {22 return { message: "This pack's rules version isn't valid, so it can't be installed.", detail };23 }24 const levelMatch = /^levels\[(\d+)\](?:\.([^:]+))?:\s*(.*)$/.exec(detail);25 if (!levelMatch) return { message: `Pack data: ${detail}`, detail };26 const index = Number(levelMatch[1]);27 const level = source?.levels?.[index];28 const label = level?.title ? `Proof ${index + 1} (${level.title})` : `Proof ${index + 1}`;29 const objectMatch = /^objects\[(\d+)\]$/.exec(levelMatch[2] || '');30 const boundsMatch = /^position \((-?\d+),\s*(-?\d+)\) is outside the (\d+)x(\d+) board$/.exec(levelMatch[3]);31 if (objectMatch && boundsMatch) {32 const object = level?.objects?.[Number(objectMatch[1])];33 const noun = object?.kind === 'word' ? object.text : object?.noun;34 if (typeof noun === 'string' && noun) {35 const [, x, y, width, height] = boundsMatch;36 return {37 message: `${label}: a ${noun.toUpperCase()} at column ${Number(x) + 1}, row ${Number(y) + 1} is outside the ${width} by ${height} board.`,38 detail,39 ...(typeof level?.id === 'string' ? { levelId: level.id } : {}),40 };41 }42 }43 return {44 message: `${label}: ${levelMatch[3]}`,45 detail,46 ...(typeof level?.id === 'string' ? { levelId: level.id } : {}),47 };48}4950function result(pack, extras = {}) {51 return { ok: true, pack, conflict: extras.conflict ?? pack?.conflict ?? null, errors: [], warnings: [], ...extras };52}5354function failure(error, source, extras = {}) {55 const context = source || error?.source;56 return {57 ok: false,58 pack: null,59 conflict: extras.conflict || null,60 errors: errorDetails(error).map(detail => playerError(detail, context)),61 warnings: [],62 ...extras,63 };64}6566function manifestFromView(view) {67 return {68 id: view.id,69 name: view.name,70 ...(view.author !== undefined && view.author !== null ? { author: view.author } : {}),71 version: view.version || '1.0.0',72 rules: view.rules ?? 1,73 ...(view.engine !== undefined ? { engine: view.engine } : {}),74 ...(Array.isArray(view.features) ? { features: cloneData(view.features) } : {}),75 ...(view.world !== undefined ? { world: cloneData(view.world) } : {}),76 ...(view.forkOf !== undefined && view.forkOf !== null ? { forkOf: view.forkOf } : {}),77 };78}7980function sourceFromView(view) {81 if (!view || typeof view !== 'object') return view;82 if (view.manifest && Array.isArray(view.levels)) return view;83 if (!view.id || !Array.isArray(view.levels)) return view;84 return {85 manifest: manifestFromView(view),86 vocabulary: cloneData(view.vocabulary || []),87 palette: cloneData(view.palette || {}),88 levels: view.levels.map(playableLevelFromView),89 };90}9192function createPackView(catalog, source, origin, previews, conflict = null, world = undefined) {93 const data = cloneData(source);94 const effectiveWorld = world || resolveWorldMetadata(data).world;95 const view = {96 id: data.manifest.id,97 name: data.manifest.name,98 author: data.manifest.author ?? null,99 version: data.manifest.version,100 rules: data.manifest.rules ?? 1,101 engine: data.manifest.engine,102 features: Array.isArray(data.manifest.features) ? cloneData(data.manifest.features) : null,103 world: cloneData(effectiveWorld),104 forkOf: data.manifest.forkOf ?? null,105 origin: origin || null,106 vocabulary: cloneData(data.vocabulary || []),107 levels: data.levels.map(level => ({108 ...cloneData(level),109 globalId: `${data.manifest.id}:${level.id}`,110 localId: level.id,111 packId: data.manifest.id,112 packName: data.manifest.name,113 origin: origin || null,114 palette: cloneData(data.palette || {}),115 })),116 palette: cloneData(data.palette || {}),117 conflict,118 };119 previews.set(view, data);120 return view;121}122123function conflictFor(catalog, source) {124 const id = source?.manifest?.id;125 if (!id) return null;126 const existing = catalog.getPacks().find(pack => pack.id === id);127 return existing ? { id, name: existing.manifest.name } : null;128}129130/**131 * The returned hooks never install a pack while parsing it. In particular,132 * a #pack fragment is data-only and cannot reach the CommonJS evaluator.133 */134export function createPackService(options = {}) {135 const catalog = options.catalog || options.library?.catalog || createCatalog();136 const library = options.library;137 if (library && library.catalog !== catalog) throw new TypeError('Pack library and service must share one catalog.');138 const baseUrl = options.baseUrl || (typeof location !== 'undefined' ? location.href : 'https://example.invalid/');139 const previews = new WeakMap();140141 function listPacks() {142 return catalog.getPacks().map(record => {143 const source = catalog.getPack(record.id);144 return {145 ...createPackView(catalog, source, record.origin, previews, null, record.world),146 // Notes are a view of the current host's rule vocabulary. They never147 // become authored pack data or part of a portable share payload.148 warnings: collectModWarnings(source),149 };150 });151 }152 function view(id) {153 const record = catalog.getPacks().find(item => item.id === id);154 return record ? createPackView(catalog, catalog.getPack(record.id), record.origin, previews, null, record.world) : undefined;155 }156 function importPackText(source, filename = 'pack.cjs') {157 try {158 const authored = loadMod(source, { filename });159 return result(createPackView(catalog, authored, 'local', previews, conflictFor(catalog, authored)), { warnings: collectModWarnings(authored) });160 } catch (error) { return failure(error, error?.source); }161 }162 function decodePackLink(hash) {163 try {164 if (typeof hash !== 'string') throw new TypeError('Pack link must be text.');165 const fragment = hash.includes('#') ? hash.slice(hash.indexOf('#')) : hash;166 if (!fragment.startsWith('#pack=')) throw new TypeError('This link does not contain a pack.');167 const payload = fragment.slice('#pack='.length);168 if (!payload || payload.includes('&')) throw new TypeError('Pack link is incomplete.');169 const authored = decodeSharedPack(payload);170 return result(createPackView(catalog, authored, 'url', previews, conflictFor(catalog, authored)), { warnings: collectModWarnings(authored) });171 } catch (error) { return failure(error); }172 }173 function installPack(input, options = {}) {174 let source;175 try {176 source = sourceFromView(input && typeof input === 'object' ? (previews.get(input) || input) : input);177 if (options.keepBoth && options.replace) throw new TypeError('Choose either Replace or Keep both.');178 const origin = options.origin || 'local';179 if (origin !== 'local' && origin !== 'url') throw new TypeError('Installed packs must come from a local file or a shared link.');180 const conflict = conflictFor(catalog, source);181 let candidate = source;182 if (options.keepBoth && conflict) {183 const original = source.manifest.id;184 let suffix = 2;185 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;186 while (catalog.getPack(forkId())) suffix++;187 candidate = cloneData(source);188 candidate.manifest.id = forkId();189 candidate.manifest.forkOf = original;190 }191 const id = library192 ? library.installPack(candidate, { origin, replace: options.replace === true })193 : catalog.register(candidate, { origin, replace: options.replace === true });194 const installed = view(id);195 return result(installed, { warnings: collectModWarnings(catalog.getPack(id)) });196 } catch (error) { return failure(error, source, { conflict: conflictFor(catalog, source) }); }197 }198 function removePack(id) {199 try {200 const removed = library ? library.removePack(id) : catalog.removePack(id);201 return removed ? result(null, { id, removed: true }) : failure(new Error(`Pack ${id} is not installed.`));202 } catch (error) { return failure(error); }203 }204 function exportPack(id) {205 const source = catalog.getPack(id);206 try {207 if (!source) throw new Error(`Pack ${id} is not installed.`);208 return result(view(id), { blob: new Blob([exportCommonJS(source)], { type: 'text/javascript' }) });209 } catch (error) { return failure(error, source); }210 }211 function packLink(id) {212 const source = catalog.getPack(id);213 try {214 if (!source) throw new Error(`Pack ${id} is not installed.`);215 const url = new URL(baseUrl);216 url.search = '';217 url.hash = `pack=${encodeSharedPack(source)}`;218 return result(view(id), { url: url.toString() });219 } catch (error) { return failure(error, source); }220 }221222 return Object.freeze({223 catalog,224 library,225 listPacks,226 importPackText,227 exportPack,228 packLink,229 decodePackLink,230 installPack,231 removePack,232 });233}