- commit
- a8473dd3207838c6d4b01f13ad8840954882a5d4
- parent
- 490926fb4b7841f792f68f2d622d2b94e6e9cd7a
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 66 | +++++++++++++++++++++++++++++ |
| A | tests/ | 68 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,66 @@
1+import { WIN_TIMING } from '../render/stage-win.js';2+3+// The card starts after the winning-cell ring, and its action row is the last4+// entrance beat. A confirmation before that beat settles the card once.5+export const WIN_ENTRANCE_END = WIN_TIMING.cardDelay + WIN_TIMING.actions + 200;6+7+/** Scene intent state; the engine remains the sole owner of the won result. */8+export function createWinFlow({ clock, reducedMotion = false, onChange = () => {}, onContinue = () => {} } = {}) {9+ if (!clock || typeof clock.now !== 'function' || typeof clock.setTimeout !== 'function')10+ throw new TypeError('Win flow requires a presentation clock');11+ let start = 0;12+ let skip = false;13+ let exitStart = 0;14+ let exitTimer = null;15+ let active = false;16+ let generation = 0;17+18+ function cancel() {19+ generation += 1;20+ if (exitTimer !== null) clock.clearTimeout(exitTimer);21+ exitTimer = null;22+ active = false;23+ exitStart = 0;24+ }25+26+ function enter({ instant = false } = {}) {27+ cancel();28+ active = true;29+ start = clock.now();30+ skip = Boolean(instant || reducedMotion);31+ onChange();32+ }33+34+ function confirm() {35+ if (!active) return false;36+ if (exitStart) return true;37+ if (!skip && !reducedMotion && clock.now() - start < WIN_ENTRANCE_END) {38+ skip = true;39+ onChange();40+ return true;41+ }42+ exitStart = clock.now();43+ if (reducedMotion) {44+ cancel();45+ onContinue();46+ return true;47+ }48+ const thisGeneration = generation;49+ onChange();50+ exitTimer = clock.setTimeout(() => {51+ if (generation !== thisGeneration || !active) return;52+ cancel();53+ onContinue();54+ }, WIN_TIMING.exit);55+ return true;56+ }57+58+ return Object.freeze({59+ enter,60+ confirm,61+ cancel,62+ get view() { return { winStart: start, winSkip: skip, winExitStart: exitStart }; },63+ get active() { return active; },64+ get exiting() { return Boolean(exitStart); },65+ });66+}@@ -0,0 +1,68 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createWinFlow, WIN_ENTRANCE_END } from '../src/stage/win-flow.js';4+import { WIN_TIMING } from '../src/render/stage-win.js';5+6+function harness(reducedMotion = false) {7+ let now = 100;8+ let pending = null;9+ const changes = [];10+ let continues = 0;11+ const clock = {12+ now: () => now,13+ setTimeout(callback, delay) { pending = { callback, due: now + delay }; return 1; },14+ clearTimeout() { pending = null; },15+ };16+ const flow = createWinFlow({ clock, reducedMotion, onChange: () => changes.push(now), onContinue: () => { continues += 1; } });17+ return {18+ flow, changes,19+ setTime(value) { now = value; if (pending && now >= pending.due) { const task = pending; pending = null; task.callback(); } },20+ get continues() { return continues; },21+ };22+}23+24+test('first confirmation during entrance settles Win, second exits once', () => {25+ const h = harness();26+ h.flow.enter();27+ h.setTime(100 + WIN_ENTRANCE_END - 1);28+ assert.equal(h.flow.confirm(), true);29+ assert.equal(h.flow.view.winSkip, true);30+ assert.equal(h.continues, 0);31+ assert.equal(h.flow.confirm(), true);32+ assert.equal(h.flow.exiting, true);33+ assert.equal(h.flow.confirm(), true);34+ h.setTime(100 + WIN_ENTRANCE_END - 1 + WIN_TIMING.exit);35+ assert.equal(h.continues, 1);36+});37+38+test('settled or initially won cards continue with one confirmation', () => {39+ const h = harness();40+ h.flow.enter();41+ h.setTime(100 + WIN_ENTRANCE_END);42+ h.flow.confirm();43+ h.setTime(100 + WIN_ENTRANCE_END + WIN_TIMING.exit);44+ assert.equal(h.continues, 1);45+ h.flow.enter({ instant: true });46+ assert.equal(h.flow.view.winSkip, true);47+ h.flow.confirm();48+ h.setTime(100 + WIN_ENTRANCE_END + WIN_TIMING.exit * 2);49+ assert.equal(h.continues, 2);50+});51+52+test('cancelling an exit prevents stale INDEX transition after Undo', () => {53+ const h = harness();54+ h.flow.enter({ instant: true });55+ h.flow.confirm();56+ h.flow.cancel();57+ h.setTime(1000);58+ assert.equal(h.continues, 0);59+ assert.equal(h.flow.active, false);60+});61+62+test('reduced motion continues immediately from its settled card', () => {63+ const h = harness(true);64+ h.flow.enter();65+ assert.equal(h.flow.view.winSkip, true);66+ h.flow.confirm();67+ assert.equal(h.continues, 1);68+});