- commit
- c3537ca700d359747945bb0823e36e16b067e46a
- parent
- 56fd5f3ea24cf56bbe83e8e105ce307bfda44fc1
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 22 | +++++++++---- |
| M | tests/ | 50 | ++++++++++++++++++++++++++++++ |
@@ -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 }@@ -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),