- commit
- df2fd0476045f9e19f3241a5b2c6fa27b1604505
- parent
- 15450306a61927847a179066d6cab91625d586d2
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 23 | ++++- |
| M | src/ | 179 | ++++++++++++++++++++++++------ |
| M | tests/ | 83 | ++++++++++---- |
@@ -18,11 +18,12 @@ const TOKEN_RE = /^[A-Z][A-Z0-9_-]*$/;
1818 const RESERVED = new Set(['IS', 'AND', 'YOU', 'PUSH', 'STOP', 'WIN', 'SINK', 'DEFEAT']);1919 2020 export class ModValidationError extends Error {21- constructor(errors) {21+ constructor(errors, source = null) {2222 const list = Array.isArray(errors) ? errors : [errors];2323 super(`Invalid mod${list.length === 1 ? '' : ' (multiple errors)'}:\n- ${list.join('\n- ')}`);2424 this.name = 'ModValidationError';2525 this.errors = list;26+ this.source = source;2627 }2728 }2829 @@ -181,7 +182,25 @@ export function loadMod(source, options = {}) {
181182 throw new Error(`Could not load ${filename}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });182183 }183184 const errors = validateMod(module.exports);184- if (errors.length) throw new ModValidationError(errors);185+ if (errors.length) {186+ // Keep only the fields needed to translate validation paths into player187+ // copy. This avoids handing the evaluator's complete export to UI code.188+ const context = Array.isArray(module.exports?.levels)189+ ? { levels: module.exports.levels.map(level => ({190+ id: level?.id,191+ title: level?.title,192+ objects: Array.isArray(level?.objects) ? level.objects.map(object => ({193+ id: object?.id,194+ kind: object?.kind,195+ noun: object?.noun,196+ text: object?.text,197+ x: object?.x,198+ y: object?.y,199+ })) : [],200+ })) }201+ : null;202+ throw new ModValidationError(errors, context);203+ }185204 return normalizeMod(module.exports);186205 }187206 @@ -3,30 +3,115 @@ import { loadMod } from './mods.js';
33 import { createCatalog } from './catalog.js';44 import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';55 6-function errorsFrom(error) {7- return Array.isArray(error?.errors) ? error.errors.map(String) : [error instanceof Error ? error.message : String(error)];6+function clone(value) {7+ return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));88 }99 10-function result(pack) { return { ok: true, pack, errors: [] }; }11-function failure(error) { return { ok: false, pack: null, errors: errorsFrom(error) }; }12-function clone(value) { return typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value)); }10+function errorDetails(error) {11+ return Array.isArray(error?.errors) ? error.errors.map(String)12+ : [error instanceof Error ? error.message : String(error)];13+}14+15+function playerError(detail, source) {16+ const levelMatch = /^levels\[(\d+)\](?:\.([^:]+))?:\s*(.*)$/.exec(detail);17+ if (!levelMatch) return { message: `Pack data: ${detail}`, detail };18+ const index = Number(levelMatch[1]);19+ const level = source?.levels?.[index];20+ const label = level?.title ? `Proof ${index + 1} (${level.title})` : `Proof ${index + 1}`;21+ const objectMatch = /^objects\[(\d+)\]$/.exec(levelMatch[2] || '');22+ const boundsMatch = /^position \((-?\d+),\s*(-?\d+)\) is outside the (\d+)x(\d+) board$/.exec(levelMatch[3]);23+ if (objectMatch && boundsMatch) {24+ const object = level?.objects?.[Number(objectMatch[1])];25+ const noun = object?.kind === 'word' ? object.text : object?.noun;26+ if (typeof noun === 'string' && noun) {27+ const [, x, y, width, height] = boundsMatch;28+ return {29+ message: `${label}: a ${noun.toUpperCase()} at column ${Number(x) + 1}, row ${Number(y) + 1} is outside the ${width} by ${height} board.`,30+ detail,31+ ...(typeof level?.id === 'string' ? { levelId: level.id } : {}),32+ };33+ }34+ }35+ return {36+ message: `${label}: ${levelMatch[3]}`,37+ detail,38+ ...(typeof level?.id === 'string' ? { levelId: level.id } : {}),39+ };40+}41+42+function result(pack, extras = {}) {43+ return { ok: true, pack, conflict: extras.conflict ?? pack?.conflict ?? null, errors: [], ...extras };44+}1345 14-function viewPack(catalog, record) {15- const data = catalog.getPack(record.id);46+function failure(error, source, extras = {}) {47+ const context = source || error?.source;1648 return {17- id: record.id,18- name: record.manifest.name,19- author: record.manifest.author,20- forkOf: record.manifest.forkOf,21- origin: record.origin,22- levels: catalog.getLevels().filter(level => level.packId === record.id).map(level => ({23- id: level.localId,24- globalId: level.id,25- title: level.title,26- hint: level.hint,49+ ok: false,50+ pack: null,51+ conflict: extras.conflict || null,52+ errors: errorDetails(error).map(detail => playerError(detail, context)),53+ ...extras,54+ };55+}56+57+function manifestFromView(view) {58+ return {59+ id: view.id,60+ name: view.name,61+ ...(view.author !== undefined && view.author !== null ? { author: view.author } : {}),62+ version: view.version || '1.0.0',63+ ...(view.engine !== undefined ? { engine: view.engine } : {}),64+ ...(view.forkOf !== undefined && view.forkOf !== null ? { forkOf: view.forkOf } : {}),65+ };66+}67+68+function sourceFromView(view) {69+ if (!view || typeof view !== 'object') return view;70+ if (view.manifest && Array.isArray(view.levels)) return view;71+ if (!view.id || !Array.isArray(view.levels)) return view;72+ return {73+ manifest: manifestFromView(view),74+ vocabulary: clone(view.vocabulary || []),75+ palette: clone(view.palette || {}),76+ levels: view.levels.map(level => {77+ const { globalId, localId, packId, packName, origin, palette, ...authored } = level;78+ return authored;79+ }),80+ };81+}82+83+function createPackView(catalog, source, origin, previews, conflict = null) {84+ const data = clone(source);85+ const view = {86+ id: data.manifest.id,87+ name: data.manifest.name,88+ author: data.manifest.author ?? null,89+ version: data.manifest.version,90+ engine: data.manifest.engine,91+ forkOf: data.manifest.forkOf ?? null,92+ origin: origin || null,93+ vocabulary: clone(data.vocabulary || []),94+ levels: data.levels.map(level => ({95+ ...clone(level),96+ globalId: `${data.manifest.id}:${level.id}`,97+ localId: level.id,98+ packId: data.manifest.id,99+ packName: data.manifest.name,100+ origin: origin || null,101+ palette: clone(data.palette || {}),27102 })),28- palette: data.palette || {},103+ palette: clone(data.palette || {}),104+ conflict,29105 };106+ previews.set(view, data);107+ return view;108+}109+110+function conflictFor(catalog, source) {111+ const id = source?.manifest?.id;112+ if (!id) return null;113+ const existing = catalog.getPacks().find(pack => pack.id === id);114+ return existing ? { id, name: existing.manifest.name } : null;30115 }31116 32117 /**@@ -38,15 +123,20 @@ export function createPackService(options = {}) {
38123 const library = options.library;39124 if (library && library.catalog !== catalog) throw new TypeError('Pack library and service must share one catalog.');40125 const baseUrl = options.baseUrl || (typeof location !== 'undefined' ? location.href : 'https://example.invalid/');126+ const previews = new WeakMap();41127 42- function listPacks() { return catalog.getPacks().map(record => viewPack(catalog, record)); }128+ function listPacks() {129+ return catalog.getPacks().map(record => createPackView(catalog, catalog.getPack(record.id), record.origin, previews));130+ }43131 function view(id) {44132 const record = catalog.getPacks().find(item => item.id === id);45- return record ? viewPack(catalog, record) : undefined;133+ return record ? createPackView(catalog, catalog.getPack(record.id), record.origin, previews) : undefined;46134 }47135 function importPackText(source, filename = 'pack.cjs') {48- try { return result(loadMod(source, { filename })); }49- catch (error) { return failure(error); }136+ try {137+ const authored = loadMod(source, { filename });138+ return result(createPackView(catalog, authored, 'local', previews, conflictFor(catalog, authored)));139+ } catch (error) { return failure(error, error?.source); }50140 }51141 function decodePackLink(hash) {52142 try {@@ -55,21 +145,25 @@ export function createPackService(options = {}) {
55145 if (!fragment.startsWith('#pack=')) throw new TypeError('This link does not contain a pack.');56146 const payload = fragment.slice('#pack='.length);57147 if (!payload || payload.includes('&')) throw new TypeError('Pack link is incomplete.');58- return result(decodeSharedPack(payload));148+ const authored = decodeSharedPack(payload);149+ return result(createPackView(catalog, authored, 'url', previews, conflictFor(catalog, authored)));59150 } catch (error) { return failure(error); }60151 }61- function installPack(pack, options = {}) {152+ function installPack(input, options = {}) {153+ let source;62154 try {155+ source = sourceFromView(input && typeof input === 'object' ? (previews.get(input) || input) : input);63156 if (options.keepBoth && options.replace) throw new TypeError('Choose either Replace or Keep both.');64157 const origin = options.origin || 'local';65158 if (origin !== 'local' && origin !== 'url') throw new TypeError('Installed packs must come from a local file or a shared link.');66- let candidate = pack;67- if (options.keepBoth && catalog.getPack(pack?.manifest?.id)) {68- const original = pack.manifest.id;159+ const conflict = conflictFor(catalog, source);160+ let candidate = source;161+ if (options.keepBoth && conflict) {162+ const original = source.manifest.id;69163 let suffix = 2;70164 const forkId = () => `${original.slice(0, 64 - String(suffix).length - 1)}-${suffix}`;71165 while (catalog.getPack(forkId())) suffix++;72- candidate = clone(pack);166+ candidate = clone(source);73167 candidate.manifest.id = forkId();74168 candidate.manifest.forkOf = original;75169 }@@ -77,21 +171,30 @@ export function createPackService(options = {}) {
77171 ? library.installPack(candidate, { origin, replace: options.replace === true })78172 : catalog.register(candidate, { origin, replace: options.replace === true });79173 return result(view(id));174+ } catch (error) { return failure(error, source, { conflict: conflictFor(catalog, source) }); }175+ }176+ function removePack(id) {177+ try {178+ const removed = library ? library.removePack(id) : catalog.removePack(id);179+ return removed ? result(null, { id, removed: true }) : failure(new Error(`Pack ${id} is not installed.`));80180 } catch (error) { return failure(error); }81181 }82- function removePack(id) { return library ? library.removePack(id) : catalog.removePack(id); }83182 function exportPack(id) {84- const pack = catalog.getPack(id);85- if (!pack) throw new Error(`Pack ${id} is not installed.`);86- return new Blob([exportCommonJS(pack)], { type: 'text/javascript' });183+ const source = catalog.getPack(id);184+ try {185+ if (!source) throw new Error(`Pack ${id} is not installed.`);186+ return result(view(id), { blob: new Blob([exportCommonJS(source)], { type: 'text/javascript' }) });187+ } catch (error) { return failure(error, source); }87188 }88189 function packLink(id) {89- const pack = catalog.getPack(id);90- if (!pack) throw new Error(`Pack ${id} is not installed.`);91- const url = new URL(baseUrl);92- url.search = '';93- url.hash = `pack=${encodeSharedPack(pack)}`;94- return url.toString();190+ const source = catalog.getPack(id);191+ try {192+ if (!source) throw new Error(`Pack ${id} is not installed.`);193+ const url = new URL(baseUrl);194+ url.search = '';195+ url.hash = `pack=${encodeSharedPack(source)}`;196+ return result(view(id), { url: url.toString() });197+ } catch (error) { return failure(error, source); }95198 }96199 97200 return Object.freeze({@@ -15,27 +15,40 @@ const example = {
1515 ] }],1616 };1717 18-test('CommonJS import previews first, then installs on explicit choice', () => {18+test('previews and installed packs share a full, presentation-ready shape', () => {1919 const service = createPackService();20- const before = service.listPacks().length;2120 const preview = service.importPackText(exportCommonJS(example), 'gift.cjs');2221 assert.equal(preview.ok, true);23- assert.equal(service.listPacks().length, before);22+ assert.equal(preview.conflict, null);23+ assert.equal(preview.pack.id, 'gift-pack');24+ assert.equal(preview.pack.name, 'Gift Pack');25+ assert.equal(preview.pack.author, 'Ada');26+ assert.equal(preview.pack.levels[0].id, 'hello');27+ assert.equal(preview.pack.levels[0].width, 3);28+ assert.equal(preview.pack.levels[0].objects.length, 4);29+ assert.deepEqual(Object.keys(preview.pack).sort(), Object.keys(service.listPacks()[0]).sort());30+ assert.equal(service.listPacks().length, 1);2431 const installed = service.installPack(preview.pack);2532 assert.equal(installed.ok, true);26- assert.deepEqual(installed.pack.levels.map(level => level.id), ['hello']);27- assert.equal(installed.pack.author, 'Ada');28- assert.equal(service.listPacks().length, before + 1);29- assert.equal(service.catalog.getLevel('gift-pack:hello').title, 'Hello');33+ assert.equal(installed.pack.levels[0].objects.length, 4);34+});35+36+test('previews report an existing pack conflict without installing', () => {37+ const service = createPackService();38+ assert.equal(service.installPack(example).ok, true);39+ const preview = service.importPackText(exportCommonJS(example), 'gift.cjs');40+ assert.deepEqual(preview.conflict, { id: 'gift-pack', name: 'Gift Pack' });41+ assert.equal(service.listPacks().filter(pack => pack.id === 'gift-pack').length, 1);3042 });3143 3244 test('link decoding is data-only and does not install until confirmed', () => {3345 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/?dev#old' });34- sender.installPack(example);35- const link = sender.packLink('gift-pack');36- assert.match(link, /^https:\/\/fcbc\.n\.zip\/#pack=miau1\./);46+ assert.equal(sender.installPack(example).ok, true);47+ const linkResult = sender.packLink('gift-pack');48+ assert.equal(linkResult.ok, true);49+ assert.match(linkResult.url, /^https:\/\/fcbc\.n\.zip\/#pack=miau1\./);3750 const receiver = createPackService();38- const preview = receiver.decodePackLink(new URL(link).hash);51+ const preview = receiver.decodePackLink(new URL(linkResult.url).hash);3952 assert.equal(preview.ok, true);4053 assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);4154 assert.equal(receiver.installPack(preview.pack, { origin: 'url' }).ok, true);@@ -45,7 +58,10 @@ test('link decoding is data-only and does not install until confirmed', () => {
4558 test('duplicate choices replace or fork with fresh identity', () => {4659 const service = createPackService();4760 assert.equal(service.installPack(example).ok, true);48- assert.equal(service.installPack(example).ok, false);61+ const duplicate = service.installPack(example);62+ assert.equal(duplicate.ok, false);63+ assert.deepEqual(duplicate.conflict, { id: 'gift-pack', name: 'Gift Pack' });64+ assert.match(duplicate.errors[0].message, /Pack data/);4965 const fork = service.installPack(example, { keepBoth: true });5066 assert.equal(fork.ok, true);5167 assert.equal(fork.pack.id, 'gift-pack-2');@@ -58,16 +74,39 @@ test('duplicate choices replace or fork with fresh identity', () => {
5874 assert.equal(service.listPacks().filter(pack => pack.id.startsWith('gift-pack')).length, 3);5975 });6076 61-test('exports installed pack as a reusable CommonJS Blob and removes local pack', async () => {77+test('player hooks return structured results instead of throwing', async () => {6278 const service = createPackService();63- service.installPack(example);64- const text = await service.exportPack('gift-pack').text();65- const preview = service.importPackText(text);66- assert.equal(preview.ok, true);67- assert.equal(preview.pack.manifest.id, 'gift-pack');68- assert.equal(service.removePack('gift-pack'), true);69- assert.equal(service.listPacks().some(pack => pack.id === 'gift-pack'), false);70- assert.throws(() => service.exportPack('missing'), /not installed/);79+ assert.equal(service.installPack(null).ok, false);80+ const missingExport = service.exportPack('missing');81+ assert.equal(missingExport.ok, false);82+ assert.equal(typeof missingExport.errors[0].message, 'string');83+ assert.equal(missingExport.errors[0].detail, 'Pack missing is not installed.');84+ const missingLink = service.packLink('missing');85+ assert.equal(missingLink.ok, false);86+ const missingRemove = service.removePack('missing');87+ assert.equal(missingRemove.ok, false);88+ assert.equal(service.removePack('core').ok, false);89+ assert.equal(service.installPack(example).ok, true);90+ const exported = service.exportPack('gift-pack');91+ assert.equal(exported.ok, true);92+ const exportedAgain = service.exportPack('gift-pack');93+ assert.equal(await exported.blob.text(), await exportedAgain.blob.text());94+ assert.equal(service.removePack('gift-pack').ok, true);95+});96+97+test('validation errors retain paths and add proof-level player copy', () => {98+ const service = createPackService();99+ const preview = service.importPackText(`module.exports = ${JSON.stringify({100+ ...example,101+ levels: [{ ...example.levels[0], id: 'broken', title: 'Broken Proof', width: 1, objects: [102+ { kind: 'object', noun: 'CAT', x: 2, y: 0 },103+ ] }],104+ })};`, 'bad.cjs');105+ assert.equal(preview.ok, false);106+ assert.equal(preview.errors[0].detail, 'levels[0].objects[0]: position (2, 0) is outside the 1x2 board');107+ assert.equal(preview.errors[0].message, 'Proof 1 (Broken Proof): a CAT at column 3, row 1 is outside the 1 by 2 board.');108+ assert.equal(preview.errors[0].levelId, 'broken');109+ assert.match(preview.errors[0].detail, /^levels\[0\]\.objects\[0\]:/);71110 });72111 73112 test('bad files and fragments return error lists without state changes', () => {@@ -87,6 +126,6 @@ test('service installs and removals persist through its library', () => {
87126 const service = createPackService({ library });88127 assert.equal(service.installPack(example).ok, true);89128 assert.match(saved, /gift-pack/);90- assert.equal(service.removePack('gift-pack'), true);129+ assert.equal(service.removePack('gift-pack').ok, true);91130 assert.deepEqual(JSON.parse(saved).packs, []);92131 });