clone

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

Validate and expose world identity metadata

Dami committed 0326ea0
commit
0326ea0364d6be62f013a20ff1dfe713afb87fd3
parent
5890316987b39e1289ef7d09c065c6a81bd47819
author
Dami <dami.etoile@gmail.com>
9 files changed, +193 -9
Mopenspec/changes/add-world-index-navigation/tasks.md6+-
Msrc/catalog.js13+++++-
Msrc/mods/diagnostics.js2+
Asrc/mods/world.js67++++++++++++++++++++++++++++++
Msrc/pack-service.js13++++--
Mtests/catalog.test.js21+++++++++
Mtests/mods.test.js35++++++++++++++++
Mtests/pack-service.test.js27++++++++++++-
Mtests/share.test.js18++++++++
M openspec/changes/add-world-index-navigation/tasks.md +3 -3
M src/catalog.js +12 -1
@@ -1,6 +1,7 @@
11 import { levels as builtInLevels } from './levels.js';22 import { LEGACY_RULES_VERSION, MOD_SCHEMA_VERSION, validateMod } from './mods.js';33 import { cloneData } from './data/clone.js';4+import { resolveWorldMetadata } from './mods/world.js';45 56 export const CORE_PACK_ID = 'core';67 const LOCAL_LEVEL_ID_RE = /^[a-z][a-z0-9._-]{0,63}$/;
@@ -51,6 +52,7 @@ function makePackRecord(pack) {
5152 validatePack(pack);5253 const normalized = cloneData(pack);5354 normalized.manifest.rules ??= LEGACY_RULES_VERSION;55+ const worldMetadata = resolveWorldMetadata(normalized);5456 const packId = normalized.manifest.id;5557 const entries = normalized.levels.map(level => {5658 const localId = level.id;
@@ -68,6 +70,8 @@ function makePackRecord(pack) {
6870 id: packId,6971 source: normalized,7072 manifest: normalized.manifest,73+ world: worldMetadata.world,74+ worldWarnings: worldMetadata.warnings,7175 palette: normalized.palette || {},7276 levels: entries,7377 };
@@ -133,7 +137,14 @@ export function createCatalog(options = {}) {
133137 function getPacks() {134138 return order.map(id => {135139 const pack = packs.get(id);136- return { id: pack.id, origin: pack.origin, manifest: cloneData(pack.manifest), levelCount: pack.levels.length };140+ return {141+ id: pack.id,142+ origin: pack.origin,143+ manifest: { ...cloneData(pack.manifest), world: cloneData(pack.world) },144+ world: cloneData(pack.world),145+ worldWarnings: cloneData(pack.worldWarnings),146+ levelCount: pack.levels.length,147+ };137148 });138149 }139150 /** Return a validated deep clone of the original pack, or undefined. */
M src/mods/diagnostics.js +2 -0
A src/mods/world.js +67 -0
@@ -0,0 +1,67 @@
1+const WORLD_ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat', 'tick']);2+const WORLD_FIELDS = new Set(['glyph', 'tagline', 'accent']);3+4+function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }5+6+function firstPaletteGlyph(palette) {7+ if (!isRecord(palette)) return null;8+ for (const value of Object.values(palette)) {9+ const glyph = typeof value === 'string' ? value : value?.glyph;10+ if (typeof glyph === 'string' && glyph.length > 0) return glyph;11+ }12+ return null;13+}14+15+function graphemeCount(value) {16+ if (typeof Intl?.Segmenter !== 'function') return null;17+ return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].length;18+}19+20+function hasSentenceCase(value) {21+ const letters = [...value].filter(character => /\p{L}/u.test(character));22+ if (letters.length === 0) return false;23+ return /\p{Lu}/u.test(letters[0]) && !letters.every(character => /\p{Lu}/u.test(character));24+}25+26+function note(detail, message = `World identity uses a fallback: ${detail}`) {27+ return {28+ word: 'WORLD', levelId: null, levelTitle: null, sentence: null, message,29+ detail: `manifest.world.${detail}`,30+ };31+}32+33+/** Resolve effective world presentation metadata without changing authored data. */34+export function resolveWorldMetadata(pack) {35+ const authored = pack?.manifest?.world;36+ const fallback = { glyph: firstPaletteGlyph(pack?.palette), tagline: null, accent: null };37+ if (authored === undefined) return { world: fallback, warnings: [] };38+ const warnings = [];39+ if (!isRecord(authored)) warnings.push(note('must be an object; using fallback world identity'));40+ else {41+ const unknownField = Object.keys(authored).find(key => !WORLD_FIELDS.has(key));42+ if (unknownField) warnings.push(note(`${unknownField}: unsupported field; using fallback world identity`));43+ if (typeof authored.glyph !== 'string' || authored.glyph.length === 0) {44+ warnings.push(note('glyph: must be exactly one grapheme cluster and at most 16 UTF-16 code units; using fallback world identity'));45+ } else {46+ const count = graphemeCount(authored.glyph);47+ if (count !== 1 || authored.glyph.length > 16) warnings.push(note('glyph: must be exactly one grapheme cluster and at most 16 UTF-16 code units; using fallback world identity'));48+ }49+ if (authored.tagline !== undefined) {50+ if (typeof authored.tagline !== 'string' || authored.tagline.length === 0 || authored.tagline.length > 80) {51+ warnings.push(note('tagline: must be at most 80 characters, on one line, in sentence case; using fallback world identity'));52+ } else if (/\r|\n/u.test(authored.tagline)) {53+ warnings.push(note('tagline: must be on one line; using fallback world identity'));54+ } else if (authored.tagline.includes('ยท')) {55+ warnings.push(note("tagline: Tagline can't use ยท; use a comma or a dash.", "Tagline can't use ยท; use a comma or a dash."));56+ } else if (!hasSentenceCase(authored.tagline)) {57+ warnings.push(note('tagline: must use sentence case; using fallback world identity'));58+ }59+ }60+ if (authored.accent !== undefined && (typeof authored.accent !== 'string' || !WORLD_ACCENTS.has(authored.accent))) {61+ warnings.push(note("accent: must be one of you, push, stop, win, sink, defeat, or tick; using fallback world identity"));62+ }63+ }64+ return { world: warnings.length ? fallback : { glyph: authored.glyph, tagline: authored.tagline ?? null, accent: authored.accent ?? null }, warnings };65+}66+67+export const WORLD_ACCENT_TOKENS = Object.freeze([...WORLD_ACCENTS]);
M src/pack-service.js +9 -4
@@ -3,6 +3,7 @@ import { collectModWarnings, loadMod } from './mods.js';
33 import { createCatalog } from './catalog.js';44 import { decodeSharedPack, encodeSharedPack, exportCommonJS } from './share.js';55 import { cloneData } from './data/clone.js';6+import { resolveWorldMetadata } from './mods/world.js';67 78 function errorDetails(error) {89 return Array.isArray(error?.errors) ? error.errors.map(String)
@@ -64,6 +65,7 @@ function manifestFromView(view) {
6465 rules: view.rules ?? 1,6566 ...(view.engine !== undefined ? { engine: view.engine } : {}),6667 ...(Array.isArray(view.features) ? { features: cloneData(view.features) } : {}),68+ ...(view.world !== undefined ? { world: cloneData(view.world) } : {}),6769 ...(view.forkOf !== undefined && view.forkOf !== null ? { forkOf: view.forkOf } : {}),6870 };6971 }
@@ -83,8 +85,9 @@ function sourceFromView(view) {
8385 };8486 }8587 86-function createPackView(catalog, source, origin, previews, conflict = null) {88+function createPackView(catalog, source, origin, previews, conflict = null, world = undefined) {8789 const data = cloneData(source);90+ const effectiveWorld = world || resolveWorldMetadata(data).world;8891 const view = {8992 id: data.manifest.id,9093 name: data.manifest.name,
@@ -93,6 +96,7 @@ function createPackView(catalog, source, origin, previews, conflict = null) {
9396 rules: data.manifest.rules ?? 1,9497 engine: data.manifest.engine,9598 features: Array.isArray(data.manifest.features) ? cloneData(data.manifest.features) : null,99+ world: cloneData(effectiveWorld),96100 forkOf: data.manifest.forkOf ?? null,97101 origin: origin || null,98102 vocabulary: cloneData(data.vocabulary || []),
@@ -134,7 +138,7 @@ export function createPackService(options = {}) {
134138 return catalog.getPacks().map(record => {135139 const source = catalog.getPack(record.id);136140 return {137- ...createPackView(catalog, source, record.origin, previews),141+ ...createPackView(catalog, source, record.origin, previews, null, record.world),138142 // Notes are a view of the current host's rule vocabulary. They never139143 // become authored pack data or part of a portable share payload.140144 warnings: collectModWarnings(source),
@@ -143,7 +147,7 @@ export function createPackService(options = {}) {
143147 }144148 function view(id) {145149 const record = catalog.getPacks().find(item => item.id === id);146- return record ? createPackView(catalog, catalog.getPack(record.id), record.origin, previews) : undefined;150+ return record ? createPackView(catalog, catalog.getPack(record.id), record.origin, previews, null, record.world) : undefined;147151 }148152 function importPackText(source, filename = 'pack.cjs') {149153 try {
@@ -183,7 +187,8 @@ export function createPackService(options = {}) {
183187 const id = library184188 ? library.installPack(candidate, { origin, replace: options.replace === true })185189 : catalog.register(candidate, { origin, replace: options.replace === true });186- return result(view(id));190+ const installed = view(id);191+ return result(installed, { warnings: collectModWarnings(catalog.getPack(id)) });187192 } catch (error) { return failure(error, source, { conflict: conflictFor(catalog, source) }); }188193 }189194 function removePack(id) {
M tests/catalog.test.js +21 -0
@@ -15,6 +15,7 @@ const level = (title, id) => ({ id, title, width: 2, height: 1, objects: [
1515 test('starts with stable core pack and preserves additive import order', () => {1616 const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });1717 assert.equal(catalog.getPack('core').manifest.rules, 2);18+ assert.deepEqual(catalog.getPacks()[0].world, { glyph: null, tagline: null, accent: null });1819 assert.deepEqual(catalog.getPacks().map(pack => pack.id), ['core']);1920 catalog.importMod(pack('alpha', 'Alpha', [level('A one', 'start'), level('A two', 'next')]));2021 catalog.importMod(pack('beta', 'Beta', [level('B one', 'start')]));
@@ -24,6 +25,26 @@ test('starts with stable core pack and preserves additive import order', () => {
2425 assert.equal(catalog.getLevel('alpha:start').origin, 'local');2526 });2627 28+test('invalid world identity falls back without losing authored pack data', () => {29+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });30+ const source = pack('world-bad', 'World Bad', [level('Proof', 'intro')]);31+ source.palette = { MIAU: '๐Ÿˆ' };32+ source.manifest.world = { glyph: '๐Ÿˆ๐Ÿˆ', tagline: 'too long'.repeat(20), accent: 'glow' };33+ catalog.register(source, { origin: 'local' });34+ const record = catalog.getPacks().find(item => item.id === 'world-bad');35+ assert.deepEqual(record.world, { glyph: '๐Ÿˆ', tagline: null, accent: null });36+ assert.equal(record.worldWarnings.length, 3);37+ assert.deepEqual(catalog.getPack('world-bad').manifest.world, source.manifest.world);38+});39+40+test('invalid gameplay still rejects a pack with world metadata atomically', () => {41+ const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });42+ const source = pack('world-invalid-gameplay', 'World Invalid Gameplay', [{ ...level('Bad', 'intro'), objects: [{ kind: 'object', noun: 'MIAU', x: 4, y: 0 }] }]);43+ source.manifest.world = { glyph: '๐Ÿˆ๐Ÿˆ', accent: 'glow' };44+ assert.throws(() => catalog.register(source), /outside the 2x1 board/);45+ assert.equal(catalog.getPack('world-invalid-gameplay'), undefined);46+});47+2748 test('supports ordered and ID based level lookup plus active selection', () => {2849 const catalog = createCatalog({ coreLevels: [level('Core', 'intro')] });2950 catalog.register(pack('alpha', 'Alpha', [level('A one', 'start')]));
M tests/mods.test.js +35 -0
@@ -131,6 +131,41 @@ test('normalizes plain CommonJS noun and word values to uppercase', () => {
131131 assert.equal(mod.levels[0].objects[2].noun, 'MIAU');132132 });133133 134+test('accepts grapheme-safe world identity metadata including tick accent', () => {135+ const mod = loadMod(`module.exports = {136+ manifest: { id: 'world-identity', name: 'World Identity', version: '1.0.0', rules: 2, world: {137+ glyph: 'โš™๏ธ', tagline: 'A clockwork world.', accent: 'tick',138+ } },139+ palette: { MIAU: '๐Ÿˆ' },140+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],141+ };`);142+ assert.deepEqual(validateMod(mod), []);143+ assert.deepEqual(collectModWarnings(mod), []);144+ assert.equal(mod.manifest.world.glyph, 'โš™๏ธ');145+});146+147+test('warns when one grapheme exceeds the world glyph UTF-16 cap', () => {148+ const glyph = '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆโ€๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ';149+ assert.equal([...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(glyph)].length, 1);150+ assert.ok(glyph.length > 16);151+ const mod = loadMod(`module.exports = {152+ manifest: { id: 'long-glyph', name: 'Long Glyph', version: '1.0.0', rules: 2, world: { glyph: ${JSON.stringify(glyph)} } },153+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],154+ };`);155+ assert.match(collectModWarnings(mod)[0].detail, /manifest\.world\.glyph/);156+});157+158+test('world identity notes stay bounded when optional metadata has many unknown fields', () => {159+ const world = Object.fromEntries(Array.from({ length: 100 }, (_, index) => [`extra${index}`, index]));160+ const mod = loadMod(`module.exports = {161+ manifest: { id: 'many-world-fields', name: 'Many World Fields', version: '1.0.0', rules: 2, world: ${JSON.stringify(world)} },162+ levels: [{ id: 'intro', width: 1, height: 1, objects: [] }],163+ };`);164+ const worldNotes = collectModWarnings(mod).filter(warning => warning.word === 'WORLD');165+ assert.equal(worldNotes.length, 2);166+ assert.match(worldNotes[0].detail, /extra0: unsupported field/);167+});168+134169 test('accepts directional palette faces and preserves plain strings', () => {135170 const mod = loadMod(`module.exports = {136171 manifest: { id: 'facing-demo', name: 'Facing Demo', version: '1.0.0' },
M tests/pack-service.test.js +26 -1
@@ -144,7 +144,9 @@ test('legacy rules revisions warn once and survive pack-service view round trips
144144 message: 'This pack was made for older rules; some proofs may play differently.',145145 detail: 'manifest.rules: effective revision 1; current host revision 2.',146146 }]);147- assert.equal(sender.installPack(local.pack).ok, true);147+ const installed = sender.installPack(local.pack);148+ assert.equal(installed.ok, true);149+ assert.deepEqual(installed.warnings, local.warnings);148150 assert.equal(sender.listPacks().find(pack => pack.id === 'gift-pack').rules, 1);149151 const exported = await (await sender.exportPack('gift-pack')).blob.text();150152 assert.match(exported, /rules":1/);
@@ -171,6 +173,29 @@ test('future rules revisions fail before catalog registration', () => {
171173 assert.deepEqual(service.catalog.getPacks(), before);172174 });173175 176+test('local and shared previews expose fallback world metadata and notes', () => {177+ const authored = structuredClone(example);178+ authored.manifest.id = 'world-preview';179+ authored.manifest.name = 'World Preview';180+ authored.manifest.world = { glyph: '๐Ÿˆ๐Ÿˆ', tagline: 'Bad ยท copy', accent: 'glow' };181+ const sender = createPackService({ baseUrl: 'https://fcbc.n.zip/' });182+ const local = sender.importPackText(exportCommonJS(authored), 'world.cjs');183+ assert.equal(local.ok, true);184+ assert.deepEqual(local.pack.world, { glyph: '๐Ÿˆ', tagline: null, accent: null });185+ assert.equal(local.warnings.length, 3);186+ assert.equal(local.warnings.some(note => note.message === "Tagline can't use ยท; use a comma or a dash."), true);187+ const installed = sender.installPack(local.pack);188+ assert.equal(installed.ok, true);189+ assert.deepEqual(installed.warnings, local.warnings);190+ assert.deepEqual(sender.listPacks().find(pack => pack.id === 'world-preview').world, local.pack.world);191+ const link = sender.packLink('world-preview');192+ const receiver = createPackService();193+ const shared = receiver.decodePackLink(new URL(link.url).hash);194+ assert.equal(shared.ok, true);195+ assert.deepEqual(shared.pack.world, local.pack.world);196+ assert.deepEqual(shared.warnings, local.warnings);197+});198+174199 test('an unsupported required feature has a player-readable preview error', () => {175200 const unsupported = structuredClone(example);176201 unsupported.manifest.features = ['pairs'];
M tests/share.test.js +18 -0