clone

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

feat(engine): expose physical sentence spans for rule effects

Dami committed 5a35bdd
commit
5a35bdd5bf55f3a002fb3c1dd52539801628dbae
parent
3e53b8384f8f2e230f44da585e6ae399b8d7bc86
author
Dami <dami.etoile@gmail.com>
7 files changed, +145 -1
Aopenspec/changes/expose-sentence-spans/.openspec.yaml2+
Aopenspec/changes/expose-sentence-spans/design.md52++++++++++++++++++++++++++++++
Aopenspec/changes/expose-sentence-spans/proposal.md24++++++++++++++
Aopenspec/changes/expose-sentence-spans/specs/rule-engine/spec.md24++++++++++++++
Aopenspec/changes/expose-sentence-spans/tasks.md14++++++++
Msrc/engine.js5++-
Mtests/engine.test.js25++++++++++++++
A openspec/changes/expose-sentence-spans/.openspec.yaml +2 -0
A openspec/changes/expose-sentence-spans/design.md +52 -0
@@ -0,0 +1,52 @@
1+## Context2+3+The engine already deduplicates semantic rules so simulation does not apply the same `subject IS predicate` more than once. The designer's sentence bars need a different view: every physical sentence on the board, including exact duplicates and stacked-word paths. E3 has been implemented in the engine and tests; this change records that landed contract before the implementation is committed.4+5+## Goals / Non-Goals6+7+**Goals:**8+9+- Expose `state.sentences: [{ ids, axis }]` for presentation consumers.10+- Preserve one semantic rule per unique sentence meaning in `state.rules`.11+- Keep exact duplicate physical runs, stacked-word paths, and horizontal/vertical axes deterministic.12+- Drop strict subset spans when a longer span exists on the same axis.13+14+**Non-Goals:**15+16+- Changing rule semantics, movement, transformations, contact effects, or event payloads.17+- Drawing sentence bars or choosing their visual placement.18+- Adding new vocabulary or changing the parser beyond the physical span projection.19+20+## Decisions21+22+### 1. Keep semantic rules and physical spans as separate projections23+24+The parser discovers candidate source paths once. It emits every physical path into `state.sentences`, while the existing semantic map keeps only the first deterministic source for each rule text. A renderer uses spans for bars and rules for behavior.25+26+### 2. Store only source IDs and axis in spans27+28+The span contract is intentionally small: `ids` preserves the source word entity order and `axis` is `h` or `v`. Positions and text remain available from state objects, avoiding a second mutable representation that could drift from the board.29+30+### 3. Filter strict subsets by axis and source membership31+32+After candidates are collected, remove a span when another candidate on the same axis has more source IDs and contains every ID in the shorter span. Exact duplicates are retained because they are distinct candidate records and have equal length. This matches the prototype parser's maximal-span behavior.33+34+### 4. Recompute spans with rules35+36+`state.sentences` is rebuilt whenever rules are refreshed, including reset, word movement, undo restoration, and transformations that trigger a re-scan. It is derived state and is not independently edited by mods or renderers.37+38+## Risks / Trade-offs39+40+- [ID membership can mistake disjoint spans for subsets] → Require same-axis and strict-length comparisons while retaining candidate order; fixtures cover horizontal, vertical, stacked, and duplicate cases.41+- [Consumers assume semantic rule count equals bar count] → Document `state.sentences` as the physical source and keep `state.rules` as the semantic source of truth.42+- [Parser changes alter bar order] → Preserve deterministic discovery order and assert exact ordered spans in engine tests.43+44+## Migration Plan45+46+1. Keep existing `state.rules` consumers unchanged.47+2. Read `state.sentences` in sentence-bar rendering when that separate presentation change lands.48+3. Archive this change after the engine implementation and tests are committed.49+50+## Open Questions51+52+None for the E3 data contract.
A openspec/changes/expose-sentence-spans/proposal.md +24 -0
A openspec/changes/expose-sentence-spans/specs/rule-engine/spec.md +24 -0
@@ -0,0 +1,24 @@
1+## MODIFIED Requirements2+3+### Requirement: Active rules SHALL identify their source words4+Each active semantic rule SHALL expose the word entity IDs that formed it in reading order and whether the sentence runs horizontally or vertically. Duplicate semantic rules at different physical locations SHALL remain deduplicated in the active rule table while retaining deterministic source information. The settled state SHALL additionally expose `sentences`, an ordered list of every physical maximal sentence span as `{ ids, axis }`. A span that is a strict subset of a longer span on the same axis SHALL be omitted; exact duplicate sentences at different locations SHALL both remain.5+6+#### Scenario: A horizontal sentence reports its source7+- **WHEN** adjacent word entities form `MIAU IS YOU` from left to right8+- **THEN** the resulting semantic rule identifies those word IDs in order, reports horizontal axis, and `state.sentences` contains the physical span with those ids and axis `h`9+10+#### Scenario: A vertical sentence reports its source11+- **WHEN** adjacent word entities form `ROCK IS PUSH` from top to bottom12+- **THEN** the resulting semantic rule identifies those word IDs in order, reports vertical axis, and `state.sentences` contains the physical span with those ids and axis `v`13+14+#### Scenario: Exact duplicate sentences remain physical spans15+- **WHEN** two separate word runs form the same semantic sentence16+- **THEN** the active rule table contains one semantic rule while `state.sentences` contains both physical spans17+18+#### Scenario: Strict subsets are removed19+- **WHEN** a candidate span is a strict subset of a longer valid span on the same axis20+- **THEN** `state.sentences` contains only the longer maximal span21+22+#### Scenario: Stacked words create separate spans23+- **WHEN** two noun words share a cell and each forms a valid sentence with the same following words24+- **THEN** `state.sentences` contains one span for each physical noun path
A openspec/changes/expose-sentence-spans/tasks.md +14 -0
M src/engine.js +4 -1
@@ -51,6 +51,7 @@ export function createGame(level) {
5151 const noun = t => t && t !== 'IS' && t !== 'AND' && !CORE_PROPERTIES.has(t);5252 const predicate = t => noun(t) || CORE_PROPERTIES.has(t);5353 const found = new Map();54+ const spans = [];5455 for (const [origin, starts] of words) {5556 const [ox, oy] = origin.split(',').map(Number);5657 for (const first of starts.filter(entry => noun(entry.text))) for (const [dx, dy] of directions) {
@@ -84,6 +85,7 @@ export function createGame(level) {
8485 }8586 // Add expanded subjects and predicates while retaining the first8687 // occurrence of each rule for deterministic stacked-word parsing.88+ for (const s of subjectPaths) for (const p of predicatePaths) spans.push({ ids: [...s.sourceIds, is.id, ...p.sourceIds], axis: dx ? 'h' : 'v' });8789 for (const s of subjectPaths) for (const p of predicatePaths) {8890 const subjectNames = s.text.split(' AND ');8991 const predicateNames = p.text.split(' AND ');
@@ -94,6 +96,7 @@ export function createGame(level) {
9496 }9597 }9698 }99+ state.sentences = spans.filter(span => !spans.some(other => other !== span && other.axis === span.axis && other.ids.length > span.ids.length && span.ids.every(id => other.ids.includes(id))));97100 return [...found.values()];98101 }99102
@@ -153,7 +156,7 @@ export function createGame(level) {
153156 }154157 function reset() {155158 const before = state ? clone(state) : null;156- state = { width: original.width, height: original.height, title: original.title || 'Untitled puzzle', hint: original.hint || '', objects: original.objects.map((o, i) => ({ id: o.id || `o${i}`, kind: o.kind || (o.text != null ? 'word' : 'object'), noun: o.noun?.toUpperCase(), text: o.text?.toUpperCase(), x: o.x, y: o.y })), moves: 0, won: false, rules: [] };159+ state = { width: original.width, height: original.height, title: original.title || 'Untitled puzzle', hint: original.hint || '', objects: original.objects.map((o, i) => ({ id: o.id || `o${i}`, kind: o.kind || (o.text != null ? 'word' : 'object'), noun: o.noun?.toUpperCase(), text: o.text?.toUpperCase(), x: o.x, y: o.y })), moves: 0, won: false, rules: [], sentences: [] };157160 history = [];158161 refreshRules(); transform(); refreshRules();159162 resolveContacts();
M tests/engine.test.js +25 -0
@@ -21,6 +21,31 @@ test('parses AND subjects and predicates in both axes', () => {
2121 }2222 });2323 24+test('sentences expose every physical duplicate and preserve maximal AND spans', () => {25+ const game = createGame(level([26+ word('ROCK', 0, 0, 'h-rock'), word('IS', 1, 0, 'h-is'), word('STOP', 2, 0, 'h-stop'),27+ word('ROCK', 0, 2, 'v-rock'), word('IS', 0, 3, 'v-is'), word('STOP', 0, 4, 'v-stop'),28+ word('ROCK', 3, 0, 'and-rock'), word('AND', 4, 0, 'and-op'), word('FLAG', 5, 0, 'and-flag'), word('IS', 6, 0, 'and-is'), word('WIN', 7, 0, 'and-win'),29+ ]));30+ assert.equal(game.getState().rules.filter(rule => rule.text === 'ROCK IS STOP').length, 1);31+ assert.deepEqual(game.getState().sentences, [32+ { ids: ['h-rock', 'h-is', 'h-stop'], axis: 'h' },33+ { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },34+ { ids: ['and-rock', 'and-op', 'and-flag', 'and-is', 'and-win'], axis: 'h' },35+ ]);36+});37+38+test('stacked words produce separate physical sentence spans', () => {39+ const game = createGame(level([40+ word('ROCK', 0, 0, 'rock-a'), word('FLAG', 0, 0, 'flag-a'),41+ word('IS', 1, 0, 'is-a'), word('WIN', 2, 0, 'win-a'),42+ ]));43+ assert.equal(game.getState().sentences.length, 2);44+ assert.deepEqual(game.getState().sentences.map(sentence => sentence.ids), [45+ ['rock-a', 'is-a', 'win-a'], ['flag-a', 'is-a', 'win-a'],46+ ]);47+});48+2449 test('pushes a whole chain and refuses a STOP wall atomically', () => {2550 const game = createGame(level([2651 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),