clone

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

Refresh settled rules after contact destruction

Dami committed c3537ca
commit
c3537ca700d359747945bb0823e36e16b067e46a
parent
56fd5f3ea24cf56bbe83e8e105ce307bfda44fc1
author
Dami <dami.etoile@gmail.com>
2 files changed, +65 -7
Msrc/engine.js22+++++++++----
Mtests/engine.test.js50++++++++++++++++++++++++++++++
M src/engine.js +15 -7
@@ -130,8 +130,6 @@ export function createGame(level) {
130130 // A single entity can carry both YOU and WIN after a rule changes. It131131 // wins without needing a second occupant in its cell.132132 if (occupants.length === 1) {133- const only = occupants[0];134- if (evaluateWin && properties(only).has('YOU') && properties(only).has('WIN')) state.won = true;135133 continue;136134 }137135 if (occupants.length < 2) continue;
@@ -139,14 +137,24 @@ export function createGame(level) {
139137 const survivors = occupants.filter(o => !removed.has(o.id));140138 const you = survivors.filter(o => properties(o).has('YOU'));141139 if (survivors.some(o => properties(o).has('DEFEAT'))) you.forEach(o => { removed.add(o.id); causes.set(o.id, { cause: 'defeat', at: { x: o.x, y: o.y }, noun: o.noun, text: o.text }); });142- const remaining = survivors.filter(o => !removed.has(o.id));143- const remainingYou = remaining.filter(o => properties(o).has('YOU'));144- const win = remaining.filter(o => properties(o).has('WIN'));145- if (evaluateWin && remainingYou.length && win.length) state.won = true;146140 }147141 if (removed.size) {148142 state.objects = state.objects.filter(o => !removed.has(o.id));149- indexes = buildIndexes(state, state.rules, semanticProperties);143+ // Contact effects use the rules that were active at the start of this144+ // pass. Once destruction is complete, however, the settled state must145+ // expose the remaining words immediately. Reparse here so callers do146+ // not get one turn of phantom STOP/YOU/etc. rules (and so indexes used147+ // by the next move agree with state.rules).148+ refreshRules();149+ }150+ // Re-evaluate victory against the post-contact rule/index set. This is151+ // deliberately a predicate check only: it does not run another contact152+ // pass, so a word removed by SINK/DEFEAT cannot leave a phantom WIN (or153+ // trigger a second destruction cascade) in this turn.154+ if (evaluateWin) {155+ state.won = [...indexes.objectsByCell.values()].some(occupants =>156+ occupants.some(o => properties(o).has('YOU')) && occupants.some(o => properties(o).has('WIN')),157+ );150158 }151159 return causes;152160 }
M tests/engine.test.js +50 -0
@@ -81,6 +81,56 @@ test('accepted turns report movement and rule deltas', () => {
8181 assert.equal(event.won, false);8282 });8383 84+test('reparses rules after SINK removes a rule word, and undo/reset restore it', () => {85+ const game = createGame(level([86+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),87+ word('WATER', 3, 1), word('IS', 4, 1), word('SINK', 5, 1),88+ word('ROCK', 1, 3, 'rock-word'), word('IS', 2, 3, 'rock-is'), word('STOP', 3, 3, 'stop-word'),89+ object('MIAU', 0, 3, 'miau'), object('WATER', 4, 3, 'water'), object('ROCK', 1, 4, 'rock'),90+ ]));91+ assert.equal(game.has('ROCK', 'STOP'), true);92+93+ const event = game.step(1, 0);94+ assert.equal(event.accepted, true);95+ assert.equal(event.moved.some(entry => entry.id === 'stop-word' && entry.to.x === 4), true);96+ assert.equal(event.destroyed.some(entry => entry.id === 'stop-word' && entry.cause === 'sink'), true);97+ assert.deepEqual(event.rulesRemoved.map(rule => rule.text), ['ROCK IS STOP']);98+ assert.equal(game.has('ROCK', 'STOP'), false);99+100+ const next = game.step(0, 1);101+ assert.equal(next.moved.some(entry => entry.id === 'miau'), true);102+ assert.equal(game.getState().objects.find(object => object.id === 'miau').y, 4);103+104+ const restored = game.undo();105+ assert.equal(restored.restored, true);106+ assert.equal(game.has('ROCK', 'STOP'), false);107+ const postContact = game.getState();108+ game.restore(postContact);109+ assert.equal(game.has('ROCK', 'STOP'), false);110+ assert.equal(game.step(0, 1).moved.some(entry => entry.id === 'miau'), true);111+ game.reset();112+ assert.equal(game.has('ROCK', 'STOP'), true);113+ assert.equal(game.getState().objects.some(object => object.id === 'stop-word'), true);114+});115+116+test('a WIN word destroyed by SINK cannot grant a phantom same-entity victory', () => {117+ const game = createGame(level([118+ word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),119+ word('WATER', 3, 1), word('IS', 4, 1), word('SINK', 5, 1),120+ word('BABA', 1, 3), word('IS', 2, 3), word('WIN', 3, 3, 'win-word'),121+ object('MIAU', 0, 3, 'pusher'), object('MIAU', 4, 2, 'visitor'),122+ object('WATER', 4, 3, 'water'), object('BABA', 5, 2, 'baba'),123+ ]));124+ assert.equal(game.has('BABA', 'WIN'), true);125+ const event = game.step(1, 0);126+ assert.equal(event.accepted, true);127+ assert.equal(event.moved.some(entry => entry.id === 'visitor' && entry.to.x === 5), true);128+ assert.equal(event.destroyed.some(entry => entry.id === 'win-word'), true);129+ assert.equal(game.has('BABA', 'WIN'), false);130+ assert.equal(event.won, false);131+ assert.equal(game.getState().won, false);132+});133+84134 test('dynamic rules apply after words move and noun transformation is deterministic', () => {85135 const game = createGame(level([86136 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),