- commit
- 60c0fd220df549103b69139f3c8b42efe2309477
- parent
- 3b357e14a85b18ad7273048691fb7142965cc80f
- author
- Dami <dami.etoile@gmail.com>
| M | openspec/ | 4 | +- |
| M | openspec/ | 14 | ++++++--- |
| M | src/ | 30 | ++++++++++++++++--- |
| M | tests/ | 48 | ++++++++++++++++++++++++++++++ |
@@ -26,7 +26,7 @@ Clockwork needs diagnostics without changing turn semantics. Existing `#pack` v1
2626 2727 Keep `validateMod()` as the existing error-array API. Add a pure diagnostic function that takes validated, normalized pack data and returns a bounded list of warning records with rule/word source context. It uses the engine's rule parser for authored horizontal, vertical, stacked, and `AND` sentences so the warning logic does not invent a second grammar. Warnings are nonfatal because a new noun transform is legal.2828 29-The diagnostic scans **every authored word tile**, including loose words. Its known noun set is level-local: placed object nouns and that level's automata subjects. A pack-wide palette supplies glyphs, not evidence that a word is a noun in this proof; pack-wide vocabulary or an object in another proof cannot suppress the note. The semantic property set is core properties plus implemented declarations in this level. A glossary entry without a matching implemented declaration does not suppress a note. If a word names a property implemented elsewhere in the pack but not here, the note specifically says it has no behavior in this proof.29+The diagnostic scans **every authored word tile**, including loose words. Its known noun set contains placed object nouns and that level's automata subjects, plus nouns in the pack palette or vocabulary. This recognizes intentional future transformation targets such as Word Lab's `ROCK IS GEM`. An object in another proof alone does not make a word known here. The semantic property set is core properties plus implemented declarations in this level. If a word names a property implemented elsewhere in the pack but not here, the note says it has no property behavior here unless the word is a known noun. If a word is both an implemented property somewhere in the pack and an object noun somewhere else, the note calls out that ambiguity even when the palette or vocabulary names it.3030 3131 The rule parser still supplies context for already formed sentences. The note records `word`, `levelId`, `levelTitle`, `sentence` (or `null` for a loose word), player-language `message`, and a source-path `detail`. It is de-duplicated by word text and proof, with a cap for hostile or oversized packs. Thus two physical SHUT tiles in one proof produce one note, while a SHUT in another proof receives its own note.3232 @@ -44,7 +44,7 @@ Local import and shared decode both run the diagnostic pass and return `warnings
4444 4545 ## Risks / Trade-offs4646 47-- [Risk] A future legitimate noun transform can mention a noun not yet placed in the proof. → Keep notes nonfatal and explain the transform consequence; authors can intentionally keep such a note.47+- [Risk] A future legitimate noun transform can mention a noun not yet placed in the proof. → Recognize palette/vocabulary nouns; keep other notes nonfatal and explain the transform consequence.4848 - [Risk] A word may form several different sentences during play. → Report one note for that word and proof, using an authored sentence for context when available.4949 - [Risk] Feature flags do not protect old deployed code. → Preserve v1 compatibility here and require a future engine version bump for a new unsupported mechanic.5050 - [Risk] Warning rendering can crowd the Gift card. → Use design's expandable NOTE card with at most five visible lines and a count for the remainder.@@ -1,7 +1,7 @@
11 ## ADDED Requirements22 33 ### Requirement: Pack validation SHALL distinguish unknown rule words from unsupported mechanics4-The loader SHALL accept optional `manifest.features` as a list of unique supported feature identifiers and SHALL reject unknown required features before registration with an upgrade message. A level SHALL reject an object noun equal to a custom property implemented by that level. For each word tile whose text is neither a core word, a property implemented in that proof, nor a noun placed or named as an automaton subject in that proof, the loader SHALL report a nonfatal note with word and proof context, whether or not the word currently completes a sentence. A property implemented in another proof SHALL still receive a note if it has no behavior in this one. Existing packs with automata and no feature list SHALL remain valid.4+The loader SHALL accept optional `manifest.features` as a list of unique supported feature identifiers and SHALL reject unknown required features before registration with an upgrade message. A level SHALL reject an object noun equal to a custom property implemented by that level. A word SHALL count as a known noun when it is placed or named as an automaton subject in that proof, or listed in the pack's palette or vocabulary. Other words that are neither core words nor implemented properties in that proof SHALL receive a nonfatal note with word and proof context, whether or not they currently complete a sentence. A word declared as a property in one proof and used as an object noun in another SHALL receive an ambiguity note even if it is in the pack palette or vocabulary. Existing packs with automata and no feature list SHALL remain valid.55 66 #### Scenario: Unknown predicate receives a note77 - **WHEN** a proof contains `DOOR IS SHUT` with `SHUT` absent from that proof's implemented properties and nouns@@ -15,9 +15,13 @@ The loader SHALL accept optional `manifest.features` as a list of unique support
1515 - **WHEN** a proof contains `ROCK IS MIAU` and places a `MIAU` object in that proof1616 - **THEN** no unknown-word note is returned for `MIAU`1717 18-#### Scenario: Another proof's noun does not silence a note19-- **WHEN** one proof uses `SHUT` as an object noun and another proof contains the `SHUT` word without a local SHUT noun or behavior20-- **THEN** the second proof receives a note even if the pack palette and vocabulary list SHUT18+#### Scenario: Pack-defined transformation target is known19+- **WHEN** one proof contains `ROCK IS GEM`, the pack palette or vocabulary defines `GEM`, and no GEM object starts in that proof20+- **THEN** no unknown-word note is returned for `GEM`21+22+#### Scenario: Pack-wide property and thing ambiguity receives a note23+- **WHEN** one proof declares `SHUT` as a custom property and another proof uses `SHUT` as an object noun and places its word24+- **THEN** preview reports a nonfatal ambiguity note for `SHUT` even if the pack palette or vocabulary names it2125 2226 #### Scenario: Declared automaton property remains semantic2327 - **WHEN** a level declares `TICK` through `automata[].property` and contains `MOUSE IS TICK`@@ -25,7 +29,7 @@ The loader SHALL accept optional `manifest.features` as a list of unique support
2529 2630 #### Scenario: Property declared only in another proof receives a note2731 - **WHEN** one proof declares `TICK` through automata and another proof contains `CLOCK IS TICK` without a TICK automaton28-- **THEN** the second proof reports that TICK has no behavior there32+- **THEN** the second proof reports that TICK has no property behavior there unless the pack defines TICK as a noun2933 3034 #### Scenario: Property noun collision is rejected3135 - **WHEN** a level declares `TICK` as an automaton property and places an object whose noun is `TICK`@@ -11,9 +11,15 @@ export function collectModWarnings(normalizedMod) {
1111 const warnings = [];1212 const levels = Array.isArray(normalizedMod.levels) ? normalizedMod.levels : [];1313 const allProperties = new Set(CORE_PROPERTIES);14+ const packNouns = new Set(Array.isArray(normalizedMod.vocabulary) ? normalizedMod.vocabulary.filter(value => typeof value === 'string').map(value => value.toUpperCase()) : []);15+ for (const noun of Object.keys(isRecord(normalizedMod.palette) ? normalizedMod.palette : {})) packNouns.add(noun.toUpperCase());16+ const allObjectNouns = new Set();1417 for (const level of levels) for (const automaton of Array.isArray(level?.automata) ? level.automata : []) {1518 if (typeof automaton?.property === 'string') allProperties.add(automaton.property.toUpperCase());1619 }20+ for (const level of levels) for (const object of Array.isArray(level?.objects) ? level.objects : []) {21+ if (object?.kind !== 'word' && typeof object?.noun === 'string') allObjectNouns.add(object.noun.toUpperCase());22+ }1723 for (const [levelIndex, level] of levels.entries()) {1824 const levelId = typeof level?.id === 'string' ? level.id : undefined;1925 const levelTitle = typeof level?.title === 'string' && level.title ? level.title : `Proof ${levelIndex + 1}`;@@ -67,19 +73,33 @@ export function collectModWarnings(normalizedMod) {
6773 for (const object of objects) {6874 if (object.kind !== 'word' || typeof object.text !== 'string') continue;6975 const word = object.text.toUpperCase();70- if (word === 'IS' || word === 'AND' || semantic.has(word) || knownNouns.has(word)) continue;76+ const crossPropertyThing = allProperties.has(word) && allObjectNouns.has(word);77+ if (word === 'IS' || word === 'AND' || semantic.has(word) || (knownNouns.has(word) && !crossPropertyThing) || (packNouns.has(word) && !crossPropertyThing)) continue;7178 const context = formed.get(object.id);7279 const otherProperty = allProperties.has(word) && !semantic.has(word);80+ const crossPackThing = allObjectNouns.has(word) && !knownNouns.has(word) && !crossPropertyThing;7381 const sentence = context?.sentence || null;7482 const message = context?.role === 'predicate'75- ? (otherProperty76- ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} will transform ${context.subject} into ${word}.`83+ ? (crossPropertyThing84+ ? `${word} is declared as a property and used as a thing in another proof; ${sentence} will transform ${context.subject} into ${word}.`85+ : crossPackThing86+ ? `${word} is used as a thing in another proof and has no property behavior here; ${sentence} will transform ${context.subject} into ${word}.`87+ : otherProperty88+ ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} will transform ${context.subject} into ${word}.`7789 : `${word} is not a declared property here; ${sentence} will transform ${context.subject} into ${word}.`)7890 : context?.role === 'subject'79- ? (otherProperty91+ ? (crossPropertyThing92+ ? `${word} is declared as a property and used as a thing in another proof; ${sentence} uses it as a noun.`93+ : crossPackThing94+ ? `${word} is used as a thing in another proof but is not a known noun here; ${sentence} uses it as a noun.`95+ : otherProperty8096 ? `${word} is declared as a property in another proof but has no behavior here; ${sentence} uses it as a noun.`8197 : `${word} is not a known noun in this proof; ${sentence} uses it as a noun.`)82- : otherProperty98+ : crossPropertyThing99+ ? `${word} is declared as a property and used as a thing in another proof, so this proof needs an explicit choice.`100+ : crossPackThing101+ ? `${word} is used as a thing in another proof but is not a known noun here.`102+ : otherProperty83103 ? `${word} is declared as a property in another proof but has no behavior here.`84104 : `${word} is not a known noun or declared property in this proof.`;85105 addWarning({ word, sourceId: object.id, sentence, message, detail: context@@ -208,6 +208,54 @@ test('warns on a declared property word left loose or unsupported in another pro
208208 assert.match(warnings[0].message, /another proof/);209209 });210210 211+test('pack palette and vocabulary nouns suppress intentional transformations', () => {212+ const mod = loadMod(`module.exports = {213+ manifest: { id: 'known-target', name: 'Known Target', version: '1.0.0' },214+ palette: { GEM: '💎' },215+ levels: [{ id: 'intro', width: 4, height: 2, objects: [216+ { kind: 'word', text: 'ROCK', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'GEM', x: 2, y: 0 },217+ { kind: 'object', noun: 'ROCK', x: 0, y: 1 },218+ ] }],219+ };`);220+ assert.deepEqual(collectModWarnings(mod), []);221+});222+223+test('flags a noun/property ambiguity across proofs', () => {224+ const mod = loadMod(`module.exports = {225+ manifest: { id: 'cross-proof-ambiguity', name: 'Cross Proof Ambiguity', version: '1.0.0' },226+ palette: { SHUT: '🚪' },227+ levels: [228+ { id: 'property', width: 1, height: 1, objects: [], automata: [{ id: 'shut-property', property: 'SHUT', pattern: [{ action: 'wait' }] }] },229+ { id: 'thing', width: 1, height: 1, objects: [{ kind: 'object', noun: 'SHUT', x: 0, y: 0 }] },230+ { id: 'rule', width: 4, height: 2, objects: [231+ { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'SHUT', x: 2, y: 0 },232+ { kind: 'object', noun: 'DOOR', x: 0, y: 1 }, { kind: 'object', noun: 'SHUT', x: 1, y: 1 },233+ ] },234+ ],235+ };`);236+ const warning = collectModWarnings(mod).find(entry => entry.levelId === 'rule');237+ assert.equal(warning.word, 'SHUT');238+ assert.match(warning.message, /declared as a property and used as a thing/);239+ assert.equal(warning.sentence, 'DOOR IS SHUT');240+});241+242+test('keeps an object-only cross-proof noun as an ordinary unknown note', () => {243+ const mod = loadMod(`module.exports = {244+ manifest: { id: 'cross-proof-noun', name: 'Cross Proof Noun', version: '1.0.0' },245+ levels: [246+ { id: 'thing', width: 1, height: 1, objects: [{ kind: 'object', noun: 'LOCK', x: 0, y: 0 }] },247+ { id: 'rule', width: 4, height: 2, objects: [248+ { kind: 'word', text: 'DOOR', x: 0, y: 0 }, { kind: 'word', text: 'IS', x: 1, y: 0 }, { kind: 'word', text: 'LOCK', x: 2, y: 0 },249+ { kind: 'object', noun: 'DOOR', x: 0, y: 1 },250+ ] },251+ ],252+ };`);253+ const warning = collectModWarnings(mod).find(entry => entry.levelId === 'rule');254+ assert.equal(warning.word, 'LOCK');255+ assert.match(warning.message, /used as a thing in another proof/);256+ assert.doesNotMatch(warning.message, /declared as a property/);257+});258+211259 test('declared automaton properties cannot also be object nouns', () => {212260 assert.throws(() => loadMod(`module.exports = {213261 manifest: { id: 'property-collision', name: 'Property Collision', version: '1.0.0' },