- commit
- 868380043b805bdeae10ae580f99f98cbf8c52e0
- parent
- 9d1d263e4156c77401114c863d78be382de30611
- author
- Dami <dami.etoile@gmail.com>
| M | openspec/ | 16 | +++++----- |
| M | tests/ | 1 | + |
| M | tests/ | 48 | ++++++++++++++++++++++++------ |
| M | tests/ | 3 | +- |
| M | tests/ | 41 | +++++++++++++++++++++++++- |
| M | tests/ | 2 | + |
@@ -1,10 +1,10 @@
11 ## 1. Contract and normalization22 3-- [ ] 1.1 Define and export the current rules revision (`2`) separately from3+- [x] 1.1 Define and export the current rules revision (`2`) separately from44 `MOD_SCHEMA_VERSION`.5-- [ ] 1.2 Validate `manifest.rules` as a positive integer at or below the5+- [x] 1.2 Validate `manifest.rules` as a positive integer at or below the66 current revision; reject malformed and future revisions before registration.7-- [ ] 1.3 Normalize an omitted revision to legacy revision `1` and preserve the7+- [x] 1.3 Normalize an omitted revision to legacy revision `1` and preserve the88 effective revision through local loads, shared decode/encode, CommonJS99 export, catalog records, and pack-service views. Update both manifest1010 projection helpers (`manifestFromView()` and `createPackView()`) so cloned or@@ -14,19 +14,19 @@
1414 1515 ## 2. Diagnostics and sharing1616 17-- [ ] 2.1 Add one bounded, transient pack-level W11 warning for effective17+- [x] 2.1 Add one bounded, transient pack-level W11 warning for effective1818 revisions below `2`, with the exact player message and source detail defined1919 in the level-sharing spec.20-- [ ] 2.2 Return the same warning and normalized manifest for local CommonJS20+- [x] 2.2 Return the same warning and normalized manifest for local CommonJS2121 and data-only shared previews; keep warning fields out of installed data,2222 exports, and share payloads.23-- [ ] 2.3 Add focused tests for explicit current, explicit legacy, omitted23+- [x] 2.3 Add focused tests for explicit current, explicit legacy, omitted2424 legacy, malformed, and future revisions, including atomic catalog behavior.2525 2626 ## 3. Verification and handoff2727 28-- [ ] 3.1 Run focused mod, pack-service, catalog, share, and diagnostics tests.29-- [ ] 3.2 Run the full Node suite and production build.28+- [x] 3.1 Run focused mod, pack-service, catalog, share, and diagnostics tests.29+- [x] 3.2 Run the full Node suite and production build.3030 - [ ] 3.3 Ask the design and worlds teams to review the warning wording and add3131 current `rules: 2` declarations to owned first-party packs.3232 - [ ] 3.4 Archive this change only after the compatibility fixtures and both@@ -14,6 +14,7 @@ const level = (title, id) => ({ id, title, width: 2, height: 1, objects: [
1414 1515 test('starts with stable core pack and preserves additive import order', () => {1616 const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });17+ assert.equal(catalog.getPack('core').manifest.rules, 2);1718 assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core']);1819 catalog.importMod(pack('alpha', 'Alpha', [level('A one', 'start'), level('A two', 'next')]));1920 catalog.importMod(pack('beta', 'Beta', [level('B one', 'start')]));@@ -1,7 +1,7 @@
11 import test from 'node:test';22 import assert from 'node:assert/strict';33 import { readFile } from 'node:fs/promises';4-import { collectModWarnings, loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD_SOURCE_LENGTH, ModValidationError, validateMod } from '../src/mods.js';4+import { collectModWarnings, CURRENT_RULES_VERSION, loadMod, MAX_LEVEL_OBJECTS, MAX_MOD_LEVELS, MAX_MOD_SOURCE_LENGTH, ModValidationError, validateMod } from '../src/mods.js';55 66 const exampleSource = await readFile(new URL('../mods/example.cjs', import.meta.url), 'utf8');77 const automataExampleSource = await readFile(new URL('../mods/automata-example.cjs', import.meta.url), 'utf8');@@ -87,7 +87,7 @@ test('warns on long proof titles without changing authored data', () => {
8787 const shortTitle = 's'.repeat(32);8888 const longTitle = 'A proof title that is deliberately too long';8989 const mod = loadMod(`module.exports = ${JSON.stringify({90- manifest: { id: 'title-warning', name: 'Title Warning', version: '1.0.0' },90+ manifest: { id: 'title-warning', name: 'Title Warning', version: '1.0.0', rules: 2 },9191 levels: [9292 { id: 'short', title: shortTitle, width: 1, height: 1, objects: [] },9393 { id: 'long', title: longTitle, width: 1, height: 1, objects: [] },@@ -168,7 +168,7 @@ test('accepts word-carried automata without an explicit subject', () => {
168168 169169 test('warns on an unknown authored predicate while allowing known noun transforms', () => {170170 const mod = loadMod(`module.exports = {171- manifest: { id: 'predicate-warnings', name: 'Predicate Warnings', version: '1.0.0' },171+ manifest: { id: 'predicate-warnings', name: 'Predicate Warnings', version: '1.0.0', rules: 2 },172172 vocabulary: ['ROCK', 'MIAU'],173173 levels: [{ id: 'intro', width: 9, height: 2, objects: [174174 { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'SHUT', x: 2, y: 0 },@@ -187,7 +187,7 @@ test('warns on an unknown authored predicate while allowing known noun transform
187187 188188 test('warning paths identify each unknown predicate in an AND sentence', () => {189189 const mod = loadMod(`module.exports = {190- manifest: { id: 'two-warnings', name: 'Two Warnings', version: '1.0.0' },190+ manifest: { id: 'two-warnings', name: 'Two Warnings', version: '1.0.0', rules: 2 },191191 levels: [{ id: 'intro', title: 'An open question', width: 5, height: 1, objects: [192192 { text: 'DOOR', x: 0, y: 0 }, { text: 'IS', x: 1, y: 0 },193193 { text: 'SHUT', x: 2, y: 0 }, { text: 'AND', x: 3, y: 0 }, { text: 'LOCK', x: 4, y: 0 },@@ -201,7 +201,7 @@ test('warning paths identify each unknown predicate in an AND sentence', () => {
201201 202202 test('duplicate physical sentences report each unknown predicate word once', () => {203203 const mod = loadMod(`module.exports = {204- manifest: { id: 'duplicate-warnings', name: 'Duplicate Warnings', version: '1.0.0' },204+ manifest: { id: 'duplicate-warnings', name: 'Duplicate Warnings', version: '1.0.0', rules: 2 },205205 levels: [{ id: 'intro', width: 3, height: 2, objects: [206206 { text: 'DOOR', x: 0, y: 0 }, { text: 'IS', x: 1, y: 0 }, { text: 'SHUT', x: 2, y: 0 },207207 { text: 'DOOR', x: 0, y: 1 }, { text: 'IS', x: 1, y: 1 }, { text: 'SHUT', x: 2, y: 1 },@@ -215,7 +215,7 @@ test('duplicate physical sentences report each unknown predicate word once', ()
215215 216216 test('warns on a declared property word left loose or unsupported in another proof', () => {217217 const mod = loadMod(`module.exports = {218- manifest: { id: 'level-local-properties', name: 'Level Local Properties', version: '1.0.0' },218+ manifest: { id: 'level-local-properties', name: 'Level Local Properties', version: '1.0.0', rules: 2 },219219 levels: [220220 { id: 'clock', width: 3, height: 1, objects: [221221 { kind: 'word', text: 'MOUSE', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'TICK', x: 2, y: 0 },@@ -231,9 +231,39 @@ test('warns on a declared property word left loose or unsupported in another pro
231231 assert.match(warnings[0].message, /another proof/);232232 });233233 234+test('normalizes rules compatibility revisions and rejects malformed futures', () => {235+ assert.equal(CURRENT_RULES_VERSION, 2);236+ const legacy = loadMod(`module.exports = {237+ manifest: { id: 'legacy-rules', name: 'Legacy Rules', version: '1.0.0' },238+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],239+ };`);240+ assert.equal(legacy.manifest.rules, 1);241+ assert.deepEqual(collectModWarnings(legacy), [{242+ word: 'RULES', levelId: null, levelTitle: null, sentence: null,243+ message: 'This pack was made for older rules; some proofs may play differently.',244+ detail: 'manifest.rules: effective revision 1; current host revision 2.',245+ }]);246+ const current = loadMod(`module.exports = {247+ manifest: { id: 'current-rules', name: 'Current Rules', version: '1.0.0', rules: 2 },248+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],249+ };`);250+ assert.equal(current.manifest.rules, 2);251+ assert.deepEqual(collectModWarnings(current), []);252+ for (const value of [0, -1, 1.5, '2']) {253+ assert.match(validateMod({254+ manifest: { id: 'bad-rules', name: 'Bad Rules', version: '1.0.0', rules: value },255+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],256+ }).join('; '), /manifest\.rules: must be a positive integer/);257+ }258+ assert.match(validateMod({259+ manifest: { id: 'future-rules', name: 'Future Rules', version: '1.0.0', rules: 3 },260+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],261+ }).join('; '), /manifest\.rules: unsupported future rules revision 3/);262+});263+234264 test('pack palette and vocabulary nouns suppress intentional transformations', () => {235265 const mod = loadMod(`module.exports = {236- manifest: { id: 'known-target', name: 'Known Target', version: '1.0.0' },266+ manifest: { id: 'known-target', name: 'Known Target', version: '1.0.0', rules: 2 },237267 palette: { GEM: '💎' },238268 levels: [{ id: 'intro', width: 4, height: 2, objects: [239269 { kind: 'word', text: 'ROCK', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'GEM', x: 2, y: 0 },@@ -245,7 +275,7 @@ test('pack palette and vocabulary nouns suppress intentional transformations', (
245275 246276 test('flags a noun/property ambiguity across proofs', () => {247277 const mod = loadMod(`module.exports = {248- manifest: { id: 'cross-proof-ambiguity', name: 'Cross Proof Ambiguity', version: '1.0.0' },278+ manifest: { id: 'cross-proof-ambiguity', name: 'Cross Proof Ambiguity', version: '1.0.0', rules: 2 },249279 palette: { SHUT: '🚪' },250280 levels: [251281 { id: 'property', width: 1, height: 1, objects: [], automata: [{ id: 'shut-property', property: 'SHUT', pattern: [{ action: 'wait' }] }] },@@ -264,7 +294,7 @@ test('flags a noun/property ambiguity across proofs', () => {
264294 265295 test('keeps an object-only cross-proof noun as an ordinary unknown note', () => {266296 const mod = loadMod(`module.exports = {267- manifest: { id: 'cross-proof-noun', name: 'Cross Proof Noun', version: '1.0.0' },297+ manifest: { id: 'cross-proof-noun', name: 'Cross Proof Noun', version: '1.0.0', rules: 2 },268298 levels: [269299 { id: 'thing', width: 1, height: 1, objects: [{ kind: 'object', noun: 'LOCK', x: 0, y: 0 }] },270300 { id: 'rule', width: 4, height: 2, objects: [@@ -28,9 +28,10 @@ test('explicit installs persist authored data and hydrate in order without evalu
2828 first.installPack(pack('beta'));2929 assert.deepEqual(first.getPacks().map(item => item.id), ['core', 'bundled', 'alpha', 'beta']);3030 const encoded = saved.dump();31+ const normalized = id => ({ ...pack(id), manifest: { ...pack(id).manifest, rules: 1 } });3132 assert.deepEqual(JSON.parse(encoded), {3233 schema: PACK_LIBRARY_SCHEMA,33- packs: [pack('alpha'), pack('beta')],34+ packs: [normalized('alpha'), normalized('beta')],3435 });3536 3637 const reloaded = createPackLibrary({ catalog: createCatalog({ coreLevels: [level('core')] }), storage: saved });@@ -25,7 +25,7 @@ function tamperSharedPayload(payload, mutate) {
2525 }2626 2727 const example = {28- manifest: { id: 'gift-pack', name: 'Gift Pack', author: 'Ada', version: '1.0.0' },28+ manifest: { id: 'gift-pack', name: 'Gift Pack', author: 'Ada', version: '1.0.0', rules: 2 },2929 palette: { CAT: '🐈' },3030 levels: [{ id: 'hello', title: 'Hello', width: 3, height: 2, objects: [3131 { kind: 'word', text: 'CAT', x: 0, y: 0 },@@ -132,6 +132,45 @@ test('local and shared previews expose rule warnings outside portable pack data'
132132 assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);133133 });134134 135+test('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+ assert.equal(sender.installPack(local.pack).ok, true);148+ assert.equal(sender.listPacks().find(pack => pack.id === 'gift-pack').rules, 1);149+ const exported = await (await sender.exportPack('gift-pack')).blob.text();150+ assert.match(exported, /rules":1/);151+ const link = sender.packLink('gift-pack');152+ const receiver = createPackService();153+ const shared = receiver.decodePackLink(new URL(link.url).hash);154+ assert.equal(shared.ok, true);155+ assert.equal(shared.pack.rules, 1);156+ assert.deepEqual(shared.warnings, local.warnings);157+ assert.equal(receiver.listPacks().some(pack => pack.id === 'gift-pack'), false);158+});159+160+test('future rules revisions fail before catalog registration', () => {161+ const future = structuredClone(example);162+ future.manifest.id = 'future-rules';163+ future.manifest.rules = 3;164+ const service = createPackService();165+ const before = service.catalog.getPacks();166+ const preview = service.importPackText(exportCommonJS({ ...future, manifest: { ...future.manifest, rules: 2 } }), 'future.cjs');167+ assert.equal(preview.ok, true);168+ const rejected = service.importPackText(`module.exports = ${JSON.stringify(future)};`, 'future.cjs');169+ assert.equal(rejected.ok, false);170+ assert.match(rejected.errors[0].detail, /manifest\.rules: unsupported future rules revision 3/);171+ assert.deepEqual(service.catalog.getPacks(), before);172+});173+135174 test('an unsupported required feature has a player-readable preview error', () => {136175 const unsupported = structuredClone(example);137176 unsupported.manifest.features = ['pairs'];@@ -19,6 +19,7 @@ test('shared packs round trip as normalized Unicode data', () => {
1919 assert.match(payload, /^miau1\.[A-Za-z0-9_-]+\.[0-9a-f]{8}$/);2020 const decoded = decodeSharedPack(payload);2121 assert.equal(decoded.manifest.engine, 1);22+ assert.equal(decoded.manifest.rules, 1);2223 assert.equal(decoded.levels[0].objects[0].text, 'MIAU');2324 assert.equal(decoded.levels[0].objects[3].noun, 'MIAU');2425 assert.equal(decoded.palette.MIAU, '🐈');@@ -60,6 +61,7 @@ test('encoding is stable and CommonJS export is deterministic data only', () =>
6061 test('CommonJS export round trips through the trusted loader with authored IDs', () => {6162 const loaded = loadMod(exportCommonJS(pack));6263 assert.equal(loaded.manifest.id, 'emoji-garden');64+ assert.equal(loaded.manifest.rules, 1);6365 assert.equal(loaded.levels[0].id, 'first-garden');6466 assert.equal(loaded.levels[0].objects[0].text, 'MIAU');6567 assert.equal('rules' in loaded.levels[0], false);