1const WORLD_ACCENTS = new Set(['you', 'push', 'stop', 'win', 'sink', 'defeat', 'tick']);2const WORLD_FIELDS = new Set(['glyph', 'tagline', 'accent']);34function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }56function 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}1415function graphemeCount(value) {16 if (typeof Intl?.Segmenter !== 'function') return null;17 return [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)].length;18}1920function 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}2526function 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}3233/** Resolve effective world presentation metadata without changing authored data. */34export 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}6667export const WORLD_ACCENT_TOKENS = Object.freeze([...WORLD_ACCENTS]);