1import test from 'node:test';2import assert from 'node:assert/strict';3import { createPackService } from '../src/pack-service.js';4import { createPackLibrary } from '../src/pack-library.js';5import { exportCommonJS } from '../src/share.js';67function fnv1a(text) {8 let hash = 0x811c9dc5;9 for (const byte of new TextEncoder().encode(text)) {10 hash ^= byte;11 hash = Math.imul(hash, 0x01000193) >>> 0;12 }13 return hash.toString(16).padStart(8, '0');14}1516function tamperSharedPayload(payload, mutate) {17 const prefix = 'miau1.';18 assert.ok(payload.startsWith(prefix));19 const body = payload.slice(prefix.length);20 const split = body.lastIndexOf('.');21 const envelope = JSON.parse(Buffer.from(body.slice(0, split), 'base64url').toString('utf8'));22 mutate(envelope.pack);23 const json = JSON.stringify(envelope);24 return `${prefix}${Buffer.from(json, 'utf8').toString('base64url')}.${fnv1a(json)}`;25}2627const example = {28 manifest: { id: 'gift-pack', name: 'Gift Pack', author: 'Ada', version: '1.0.0', rules: 2 },29 palette: { CAT: '๐' },30 levels: [{ id: 'hello', title: 'Hello', width: 3, height: 2, objects: [31 { kind: 'word', text: 'CAT', x: 0, y: 0 },32 { kind: 'word', text: 'IS', x: 1, y: 0 },33 { kind: 'word', text: 'YOU', x: 2, y: 0 },34 { kind: 'object', noun: 'CAT', x: 0, y: 1 },35 ] }],36};3738test('previews and installed packs share a full, presentation-ready shape', () => {39 const service = createPackService();40 const preview = service.importPackText(exportCommonJS(example), 'gift.cjs');41 assert.equal(preview.ok, true);42 assert.equal(preview.conflict, null);43 assert.equal(preview.pack.id, 'gift-pack');44 assert.equal(preview.pack.name, 'Gift Pack');45 assert.equal(preview.pack.author, 'Ada');46 assert.equal(preview.pack.levels[0].id, 'hello');47 assert.equal(preview.pack.levels[0].width, 3);48 assert.equal(preview.pack.levels[0].objects.length, 4);49 const { warnings: installedNotes, ...listedPack } = service.listPacks()[0];50 assert.ok(Array.isArray(installedNotes));51 assert.deepEqual(Object.keys(preview.pack).sort(), Object.keys(listedPack).sort());52 assert.equal(service.listPacks().length, 1);53 const installed = service.installPack(preview.pack);54 assert.equal(installed.ok, true);55 assert.equal(installed.pack.levels[0].objects.length, 4);56});5758test('previews report an existing pack conflict without installing', () => {59 const service = createPackService();60 assert.equal(service.installPack(example).ok, true);61 const preview = service.importPackText(exportCommonJS(example), 'gift.cjs');62 assert.deepEqual(preview.conflict, { id: 'gift-pack', name: 'Gift Pack' });63 assert.equal(service.listPacks().filter(pack => pack.id === 'gift-pack').length, 1);64});6566test('link decoding is data-only and does not install until confirmed', () => {67 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/?dev#old' });68 assert.equal(sender.installPack(example).ok, true);69 const linkResult = sender.packLink('gift-pack');70 assert.equal(linkResult.ok, true);71 assert.match(linkResult.url, /^https:\/\/fcbc\.n\.zip\/#pack=miau1\./);72 const receiver = createPackService();73 const preview = receiver.decodePackLink(new URL(linkResult.url).hash);74 assert.equal(preview.ok, true);75 assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);76 assert.equal(receiver.installPack(preview.pack, { origin: 'url' }).ok, true);77 assert.equal(receiver.listPacks().find(pack => pack.id === 'gift-pack').origin, 'url');78});7980test('rejects malformed shared automata atomically without changing the catalog', () => {81 const automataPack = structuredClone(example);82 automataPack.manifest.id = 'automata-pack';83 automataPack.manifest.name = 'Automata Pack';84 automataPack.levels[0].automata = [{85 id: 'clock',86 property: 'TICK',87 pattern: [{ action: 'wait' }],88 }];89 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/' });90 assert.equal(sender.installPack(automataPack).ok, true);91 const shared = sender.packLink('automata-pack');92 assert.equal(shared.ok, true);93 const malformedPayload = tamperSharedPayload(new URL(shared.url).hash.slice('#pack='.length), pack => {94 pack.levels[0].automata[0].pattern[0] = { action: 'move', dx: 2, dy: 0 };95 });9697 const receiver = createPackService();98 assert.equal(receiver.installPack(example).ok, true);99 const beforePacks = receiver.catalog.getPacks();100 const beforeLevels = receiver.catalog.getLevels();101 const result = receiver.decodePackLink(`#pack=${malformedPayload}`);102103 assert.equal(result.ok, false);104 assert.match(result.errors[0].detail, /levels\[0\]\.automata\[0\]\.pattern\[0\].*cardinal/);105 assert.deepEqual(receiver.catalog.getPacks(), beforePacks);106 assert.deepEqual(receiver.catalog.getLevels(), beforeLevels);107});108109test('local and shared previews expose rule warnings outside portable pack data', () => {110 const warned = structuredClone(example);111 warned.levels[0].objects[2].text = 'SHUT';112 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/' });113 const local = sender.importPackText(exportCommonJS(warned), 'warning.cjs');114 assert.equal(local.ok, true);115 assert.equal(local.errors.length, 0);116 assert.equal(local.warnings.length, 1);117 assert.match(local.warnings[0].message, /SHUT/);118 assert.equal(local.warnings[0].levelId, 'hello');119 assert.equal('warnings' in local.pack, false);120121 assert.equal(sender.installPack(local.pack).ok, true);122 const installed = sender.listPacks().find(pack => pack.id === 'gift-pack');123 assert.deepEqual(installed.warnings, local.warnings);124 assert.equal('warnings' in sender.catalog.getPack('gift-pack'), false);125 const link = sender.packLink('gift-pack');126 assert.equal(link.ok, true);127 const receiver = createPackService();128 const shared = receiver.decodePackLink(new URL(link.url).hash);129 assert.equal(shared.ok, true);130 assert.deepEqual(shared.warnings, local.warnings);131 assert.equal('warnings' in shared.pack, false);132 assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);133});134135test('legacy rules revisions warn once and survive pack-service view round trips', async () => {136 const legacy = structuredClone(example);137 delete legacy.manifest.rules;138 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/' });139 const local = sender.importPackText(exportCommonJS(legacy), 'legacy.cjs');140 assert.equal(local.ok, true);141 assert.equal(local.pack.rules, 1);142 assert.deepEqual(local.warnings, [{143 word: 'RULES', levelId: null, levelTitle: null, sentence: null,144 message: 'This pack was made for older rules; some proofs may play differently.',145 detail: 'manifest.rules: effective revision 1; current host revision 2.',146 }]);147 const installed = sender.installPack(local.pack);148 assert.equal(installed.ok, true);149 assert.deepEqual(installed.warnings, local.warnings);150 assert.equal(sender.listPacks().find(pack => pack.id === 'gift-pack').rules, 1);151 const exported = await (await sender.exportPack('gift-pack')).blob.text();152 assert.match(exported, /rules":1/);153 const link = sender.packLink('gift-pack');154 const receiver = createPackService();155 const shared = receiver.decodePackLink(new URL(link.url).hash);156 assert.equal(shared.ok, true);157 assert.equal(shared.pack.rules, 1);158 assert.deepEqual(shared.warnings, local.warnings);159 assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);160});161162test('future rules revisions fail before catalog registration', () => {163 const future = structuredClone(example);164 future.manifest.id = 'future-rules';165 future.manifest.rules = 3;166 const service = createPackService();167 const before = service.catalog.getPacks();168 const preview = service.importPackText(exportCommonJS({ ...future, manifest: { ...future.manifest, rules: 2 } }), 'future.cjs');169 assert.equal(preview.ok, true);170 const rejected = service.importPackText(`module.exports = ${JSON.stringify(future)};`, 'future.cjs');171 assert.equal(rejected.ok, false);172 assert.equal(rejected.errors[0].message, 'This pack needs a newer version of Miau Is True. Update the game to play it.');173 assert.match(rejected.errors[0].detail, /manifest\.rules: unsupported future rules revision 3/);174 assert.deepEqual(service.catalog.getPacks(), before);175});176177test('malformed rules revisions use player language while preserving technical detail', () => {178 const malformed = structuredClone(example);179 malformed.manifest.id = 'malformed-rules';180 malformed.manifest.rules = 'two';181 const service = createPackService();182 const preview = service.importPackText(`module.exports = ${JSON.stringify(malformed)};`, 'malformed.cjs');183 assert.equal(preview.ok, false);184 assert.equal(preview.errors[0].message, "This pack's rules version isn't valid, so it can't be installed.");185 assert.match(preview.errors[0].detail, /manifest\.rules: must be a positive integer/);186 assert.equal(service.catalog.getPacks().some(pack => pack.id === 'malformed-rules'), false);187});188189test('shared rules compatibility errors use the same player language', () => {190 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/' });191 assert.equal(sender.installPack(example).ok, true);192 const shared = sender.packLink('gift-pack');193194 const futurePayload = tamperSharedPayload(new URL(shared.url).hash.slice('#pack='.length), pack => {195 pack.manifest.rules = 3;196 });197 const malformedPayload = tamperSharedPayload(new URL(shared.url).hash.slice('#pack='.length), pack => {198 pack.manifest.rules = 'two';199 });200 const receiver = createPackService();201 const future = receiver.decodePackLink(`#pack=${futurePayload}`);202 const malformed = receiver.decodePackLink(`#pack=${malformedPayload}`);203204 assert.equal(future.ok, false);205 assert.equal(future.errors[0].message, 'This pack needs a newer version of Miau Is True. Update the game to play it.');206 assert.match(future.errors[0].detail, /manifest\.rules: unsupported future rules revision 3/);207 assert.equal(malformed.ok, false);208 assert.equal(malformed.errors[0].message, "This pack's rules version isn't valid, so it can't be installed.");209 assert.match(malformed.errors[0].detail, /manifest\.rules: must be a positive integer/);210 assert.equal(receiver.catalog.getPacks().length, 1);211});212213test('local and shared previews expose fallback world metadata and notes', () => {214 const authored = structuredClone(example);215 authored.manifest.id = 'world-preview';216 authored.manifest.name = 'World Preview';217 authored.manifest.world = { glyph: '๐๐', tagline: 'Bad ยท copy', accent: 'glow' };218 const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/' });219 const local = sender.importPackText(exportCommonJS(authored), 'world.cjs');220 assert.equal(local.ok, true);221 assert.deepEqual(local.pack.world, { glyph: '๐', tagline: null, accent: null });222 assert.equal(local.warnings.length, 3);223 assert.equal(local.warnings.some(note => note.message === "Tagline can't use ยท; use a comma or a dash."), true);224 const installed = sender.installPack(local.pack);225 assert.equal(installed.ok, true);226 assert.deepEqual(installed.warnings, local.warnings);227 assert.deepEqual(sender.listPacks().find(pack => pack.id === 'world-preview').world, local.pack.world);228 const link = sender.packLink('world-preview');229 const receiver = createPackService();230 const shared = receiver.decodePackLink(new URL(link.url).hash);231 assert.equal(shared.ok, true);232 assert.deepEqual(shared.pack.world, local.pack.world);233 assert.deepEqual(shared.warnings, local.warnings);234});235236test('an unsupported required feature has a player-readable preview error', () => {237 const unsupported = structuredClone(example);238 unsupported.manifest.features = ['pairs'];239 const service = createPackService();240 const preview = service.importPackText(`module.exports = ${JSON.stringify(unsupported)};`, 'future.cjs');241 assert.equal(preview.ok, false);242 assert.equal(preview.errors[0].message, 'This pack needs a newer Miau.');243 assert.match(preview.errors[0].detail, /manifest\.features\[0\]: unsupported feature/);244 assert.deepEqual(preview.warnings, []);245 assert.equal(service.listPacks().some(pack => pack.id === 'gift-pack'), false);246});247248test('Gift-cloned previews retain required feature declarations through installation and sharing', () => {249 const featured = structuredClone(example);250 featured.manifest.features = ['automata'];251 const service = createPackService({ baseUrl: 'https://fcbc.n.zip/' });252 const preview = service.importPackText(`module.exports = ${JSON.stringify(featured)};`, 'featured.cjs');253 assert.equal(preview.ok, true);254 const installed = service.installPack(structuredClone(preview.pack));255 assert.equal(installed.ok, true);256 assert.deepEqual(installed.pack.features, ['automata']);257 assert.deepEqual(service.catalog.getPack('gift-pack').manifest.features, ['automata']);258 assert.match(service.packLink('gift-pack').url, /#pack=miau1\./);259});260261test('duplicate choices replace or fork with fresh identity', () => {262 const service = createPackService();263 assert.equal(service.installPack(example).ok, true);264 const duplicate = service.installPack(example);265 assert.equal(duplicate.ok, false);266 assert.deepEqual(duplicate.conflict, { id: 'gift-pack', name: 'Gift Pack' });267 assert.match(duplicate.errors[0].message, /Pack data/);268 const fork = service.installPack(example, { keepBoth: true });269 assert.equal(fork.ok, true);270 assert.equal(fork.pack.id, 'gift-pack-2');271 assert.equal(fork.pack.forkOf, 'gift-pack');272 assert.equal(service.catalog.getLevel('gift-pack-2:hello').id, 'gift-pack-2:hello');273 assert.equal(service.installPack(example, { keepBoth: true }).pack.id, 'gift-pack-3');274 const changed = structuredClone(example);275 changed.manifest.name = 'New Edition';276 assert.equal(service.installPack(changed, { replace: true }).pack.name, 'New Edition');277 assert.equal(service.listPacks().filter(pack => pack.id.startsWith('gift-pack')).length, 3);278});279280test('player hooks return structured results instead of throwing', async () => {281 const service = createPackService();282 assert.equal(service.installPack(null).ok, false);283 const missingExport = service.exportPack('missing');284 assert.equal(missingExport.ok, false);285 assert.equal(typeof missingExport.errors[0].message, 'string');286 assert.equal(missingExport.errors[0].detail, 'Pack missing is not installed.');287 const missingLink = service.packLink('missing');288 assert.equal(missingLink.ok, false);289 const missingRemove = service.removePack('missing');290 assert.equal(missingRemove.ok, false);291 assert.equal(service.removePack('core').ok, false);292 assert.equal(service.installPack(example).ok, true);293 const exported = service.exportPack('gift-pack');294 assert.equal(exported.ok, true);295 const exportedAgain = service.exportPack('gift-pack');296 assert.equal(await exported.blob.text(), await exportedAgain.blob.text());297 assert.equal(service.removePack('gift-pack').ok, true);298});299300test('validation errors retain paths and add proof-level player copy', () => {301 const service = createPackService();302 const preview = service.importPackText(`module.exports = ${JSON.stringify({303 ...example,304 levels: [{ ...example.levels[0], id: 'broken', title: 'Broken Proof', width: 1, objects: [305 { kind: 'object', noun: 'CAT', x: 2, y: 0 },306 ] }],307 })};`, 'bad.cjs');308 assert.equal(preview.ok, false);309 assert.equal(preview.errors[0].detail, 'levels[0].objects[0]: position (2, 0) is outside the 1x2 board');310 assert.equal(preview.errors[0].message, 'Proof 1 (Broken Proof): a CAT at column 3, row 1 is outside the 1 by 2 board.');311 assert.equal(preview.errors[0].levelId, 'broken');312 assert.match(preview.errors[0].detail, /^levels\[0\]\.objects\[0\]:/);313});314315test('bad files and fragments return error lists without state changes', () => {316 const service = createPackService();317 const before = service.listPacks().length;318 assert.equal(service.importPackText('module.exports = {levels: []}').ok, false);319 assert.equal(service.decodePackLink('#pack=broken').ok, false);320 assert.equal(service.decodePackLink('#else=x').ok, false);321 assert.equal(service.installPack(example, { origin: 'bundled' }).ok, false);322 assert.equal(service.listPacks().length, before);323});324325test('service installs and removals persist through its library', () => {326 let saved = null;327 const storage = { getItem: () => saved, setItem: (_key, value) => { saved = value; } };328 const library = createPackLibrary({ storage });329 const service = createPackService({ library });330 assert.equal(service.installPack(example).ok, true);331 assert.match(saved, /gift-pack/);332 assert.equal(service.removePack('gift-pack').ok, true);333 assert.deepEqual(JSON.parse(saved).packs, []);334});