- commit
- 0326ea0364d6be62f013a20ff1dfe713afb87fd3
- parent
- 5890316987b39e1289ef7d09c065c6a81bd47819
- author
- Dami <dami.etoile@gmail.com>
| M | openspec/ | 6 | +- |
| M | src/ | 13 | +++++- |
| M | src/ | 2 | + |
| A | src/ | 67 | ++++++++++++++++++++++++++++++ |
| M | src/ | 13 | ++++-- |
| M | tests/ | 21 | +++++++++ |
| M | tests/ | 35 | ++++++++++++++++ |
| M | tests/ | 27 | ++++++++++++- |
| M | tests/ | 18 | ++++++++ |
@@ -3,16 +3,16 @@
33 - [ ] 1.1 Extend the normalized pack/catalog record with one world record per44 pack, including stable order, origin, author, level count, and per-world55 completion counts.6-- [ ] 1.2 Validate `manifest.world.glyph` with `Intl.Segmenter` grapheme6+- [x] 1.2 Validate `manifest.world.glyph` with `Intl.Segmenter` grapheme77 counting and the 16 UTF-16-unit cap; validate tagline length, newlines,88 sentence case, and U+00B7; validate `accent` against the closed token set,99 including `tick`. Emit nonblocking W11 notes and normalized fallback metadata1010 for invalid optional identity fields.11-- [ ] 1.3 Add focused tests for valid metadata, grapheme and UTF-16 edge cases,11+- [x] 1.3 Add focused tests for valid metadata, grapheme and UTF-16 edge cases,1212 each invalid field, the exact middle-dot diagnostic, absent metadata,1313 gameplay-field rejection, replacement position, imported registration order,1414 fork append order, and removal of an imported world.15-- [ ] 1.4 Add a pack-service/share view assertion that world metadata is exposed15+- [x] 1.4 Add a pack-service/share view assertion that world metadata is exposed1616 without changing pack IDs, level IDs, or share collision behavior.1717 1818 ## 2. World-owned INDEX layout@@ -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. */@@ -1,6 +1,7 @@
11 import { parseRules, RuleParseLimitError } from '../engine/rules.js';22 import { CORE_PROPERTY_TOKENS } from '../engine/tokens.js';33 import { CURRENT_RULES_VERSION } from '../mods.js';4+import { resolveWorldMetadata } from './world.js';45 56 export const MAX_MOD_WARNINGS = 64;67 const CORE_PROPERTIES = new Set(CORE_PROPERTY_TOKENS);@@ -10,6 +11,7 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array.
1011 export function collectModWarnings(normalizedMod) {1112 if (!isRecord(normalizedMod)) return [];1213 const warnings = [];14+ warnings.push(...resolveWorldMetadata(normalizedMod).warnings);1315 const manifest = normalizedMod.manifest;1416 if (isRecord(manifest) && Object.prototype.hasOwnProperty.call(manifest, 'rules')) {1517 const rules = Number.isInteger(manifest.rules) ? manifest.rules : 1;@@ -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]);@@ -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) {@@ -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')]));@@ -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' },@@ -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'];@@ -32,6 +32,24 @@ test('shared packs preserve bounded directional palette faces', () => {
3232 assert.equal(decoded.palette.FLAG, '๐ฉ');3333 });3434 35+test('shared data preserves authored world identity for service fallback', () => {36+ const authored = {37+ ...pack,38+ manifest: { ...pack.manifest, world: { glyph: 'โ๏ธ', tagline: 'A clockwork world.', accent: 'tick' } },39+ };40+ const decoded = decodeSharedPack(encodeSharedPack(authored));41+ assert.deepEqual(decoded.manifest.world, authored.manifest.world);42+});43+44+test('shared data preserves invalid authored world fields for preview diagnostics', () => {45+ const authored = {46+ ...pack,47+ manifest: { ...pack.manifest, world: { glyph: '๐๐', accent: 'glow' } },48+ };49+ const decoded = decodeSharedPack(encodeSharedPack(authored));50+ assert.deepEqual(decoded.manifest.world, authored.manifest.world);51+});52+3553 test('shared packs preserve normalized declarative automata', () => {3654 const automata = {3755 ...pack,