- commit
- 7b2d4982b8ffd6e0085fe205b611808ef30b8586
- parent
- 6f178f8a519291dccce356ac51bb6ba77125d93d
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 9 | ++++++ |
| M | src/ | 10 | ++++-- |
| A | tests/ | 48 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,9 @@
1+/**2+ * A delayed scene handoff must belong to the game and stable level that3+ * created it. The catalog can replace a pack while a presentation timer is4+ * waiting, including reusing the same `${packId}:${localId}` identity.5+ */6+export function winHandoffIsCurrent({ currentGame, expectedGame, expectedLevelId, activeLevel }) {7+ if (!expectedLevelId || currentGame !== expectedGame || activeLevel?.id !== expectedLevelId) return false;8+ return Boolean(currentGame?.getState?.().won);9+}@@ -18,6 +18,7 @@ import { createTurnActions } from './app/turns.js';
1818 import { createDevView } from './app/dev-view.js';1919 import { createIntentRouter } from './app/intent-router.js';2020 import { createPresentationClock, parseSlowmo } from './presentation-clock.js';21+import { winHandoffIsCurrent } from './app/level-identity.js';2122 import './style.css';2223 import './overrides.css';2324 import './font.css';@@ -238,9 +239,9 @@ function scheduleInitialWin() {
238239 refreshIndex(level.id);239240 }240241 const promote = () => {241- if (game !== expectedGame || catalog.getActiveLevel()?.id !== expectedLevelId || !game?.getState().won) return;242+ if (!winHandoffIsCurrent({ currentGame: game, expectedGame, expectedLevelId, activeLevel: catalog.getActiveLevel() })) return;242243 if (stage.scene === 'proof') {243- stage.show('win', { steps: game.getState().moves, nodeId: expectedLevelId, instant: true });244+ stage.show('win', { steps: expectedGame.getState().moves, nodeId: expectedLevelId, instant: true });244245 return;245246 }246247 // Retry after the INDEX exit on the presentation clock, preserving slowmo.@@ -266,6 +267,8 @@ const turns = createTurnActions({
266267 announceTurn,267268 onWin: now => {268269 const level = activeLevel();270+ const expectedGame = game;271+ const expectedLevelId = level?.id;269272 if (level?.packId && level?.localId) {270273 progress.markSolved(level.packId, level.localId);271274 refreshIndex(level.id);@@ -274,7 +277,8 @@ const turns = createTurnActions({
274277 presentationClock.clearTimeout(winTimer);275278 const beatStart = juice.frame(now).win?.beatStart ?? now;276279 winTimer = presentationClock.setTimeout(() => {277- if (game?.getState().won && catalog.getActiveLevel()?.id === level?.id) stage.show('win', { steps: game.getState().moves, nodeId: level?.id });280+ if (!winHandoffIsCurrent({ currentGame: game, expectedGame, expectedLevelId, activeLevel: catalog.getActiveLevel() })) return;281+ stage.show('win', { steps: expectedGame.getState().moves, nodeId: expectedLevelId });278282 }, Math.max(0, beatStart - now));279283 },280284 onRestart: () => {@@ -0,0 +1,48 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { winHandoffIsCurrent } from '../src/app/level-identity.js';4+5+function game(won = true, moves = 0) {6+ return { getState: () => ({ won, moves }) };7+}8+9+test('delayed win remains valid for the game and stable level that created it', () => {10+ const expectedGame = game(true, 4);11+ assert.equal(winHandoffIsCurrent({12+ currentGame: expectedGame,13+ expectedGame,14+ expectedLevelId: 'pack:proof',15+ activeLevel: { id: 'pack:proof' },16+ }), true);17+});18+19+test('same stable id after pack replacement cannot promote the old game', () => {20+ const expectedGame = game(true, 4);21+ const replacementGame = game(true, 0);22+ assert.equal(winHandoffIsCurrent({23+ currentGame: replacementGame,24+ expectedGame,25+ expectedLevelId: 'pack:proof',26+ activeLevel: { id: 'pack:proof' },27+ }), false);28+});29+30+test('removing the active pack and falling back cannot promote its delayed win', () => {31+ const expectedGame = game(true, 4);32+ assert.equal(winHandoffIsCurrent({33+ currentGame: expectedGame,34+ expectedGame,35+ expectedLevelId: 'pack:proof',36+ activeLevel: { id: 'core:intro' },37+ }), false);38+});39+40+test('a game that has been undone out of a win cannot promote', () => {41+ const expectedGame = game(false, 5);42+ assert.equal(winHandoffIsCurrent({43+ currentGame: expectedGame,44+ expectedGame,45+ expectedLevelId: 'pack:proof',46+ activeLevel: { id: 'pack:proof' },47+ }), false);48+});