From 41e93d01ab5c2c35ef48e8dfed773ce8f6cd9d1a Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 01:41:21 +1000 Subject: [PATCH] =?UTF-8?q?Door=20tween=20leak:=20tutorial=20fade=20spawne?= =?UTF-8?q?d=20an=20800ms=20tween=20per=20frame=20=E2=80=94=20now=20pure?= =?UTF-8?q?=20tutorialAlpha()=20driven=20from=20update()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from claude/epic-faraday-332679 (6c5889c) — merge was permission-blocked, same diff applied directly. Co-Authored-By: Claude Fable 5 --- src/scenes/door/DoorScene.ts | 8 ++++--- src/scenes/door/tutorialFade.ts | 17 ++++++++++++++ tests/tutorialFade.test.ts | 40 +++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/scenes/door/tutorialFade.ts create mode 100644 tests/tutorialFade.test.ts diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 7277f9a..c799d6c 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -27,6 +27,7 @@ import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel'; import { encounterById } from '../../rules/encounters'; import { SobrietyModal } from './SobrietyModal'; import { stamp, type StampHandle } from '../../ui/Stamp'; +import { tutorialAlpha } from './tutorialFade'; import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui'; import { shouldRecordStrike, type NightContext } from './NightScene'; @@ -39,7 +40,6 @@ const KAYDEN_RULE_MS = 5000; const UP_FOOT_Y = 244; const DOOR_X = 566; const QUEUE_FOOT_Y = 228; -const TUTORIAL_MS = 26_000; /** * TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule @@ -894,8 +894,10 @@ export class DoorScene extends Phaser.Scene { const locked = this.night.state.clockMin >= LOCKOUT_MIN; this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label); this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0'); - if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) { - this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 }); + if (this.tutorial.visible) { + const a = tutorialAlpha(this.elapsedMs); + this.tutorial.setAlpha(a); + if (a <= 0) this.tutorial.setVisible(false); } this.syncQueueSprites(); diff --git a/src/scenes/door/tutorialFade.ts b/src/scenes/door/tutorialFade.ts new file mode 100644 index 0000000..ec402db --- /dev/null +++ b/src/scenes/door/tutorialFade.ts @@ -0,0 +1,17 @@ +/** How long the door tutorial text stays fully lit before it starts to fade. */ +export const TUTORIAL_MS = 26_000; +/** Length of the fade once the hold expires. */ +export const TUTORIAL_FADE_MS = 800; + +/** + * Tutorial text alpha at `elapsedMs` into the night. Pure update()-driven math, + * deliberately NOT a tween: the old `if (alpha > 0) tweens.add(...)` guard + * spawned a fresh 800ms tween every frame (each new tween re-captured the + * current alpha and kept it above zero, so the guard never closed — ~1,100 live + * tweens by night's end), and tweens stall under the preview pane's stepped + * clock anyway. + */ +export function tutorialAlpha(elapsedMs: number): number { + if (elapsedMs <= TUTORIAL_MS) return 1; + return Math.max(0, 1 - (elapsedMs - TUTORIAL_MS) / TUTORIAL_FADE_MS); +} diff --git a/tests/tutorialFade.test.ts b/tests/tutorialFade.test.ts new file mode 100644 index 0000000..ed85c61 --- /dev/null +++ b/tests/tutorialFade.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { TUTORIAL_FADE_MS, TUTORIAL_MS, tutorialAlpha } from '../src/scenes/door/tutorialFade'; + +// Regression guard for the door-tutorial fade. The original implementation was +// `if (alpha > 0) tweens.add({ alpha: 0, duration: 800 })` inside update(): +// each frame's new tween re-captured the still-positive alpha, so the guard +// never closed and DoorScene accumulated one live 800ms tween per frame +// (~1,100 by the end of a night). The fade is now this pure function; these +// tests pin the property that killed the tween version — it must actually +// reach 0 and stay there. + +describe('tutorialAlpha', () => { + it('holds at full alpha through the tutorial window', () => { + expect(tutorialAlpha(0)).toBe(1); + expect(tutorialAlpha(TUTORIAL_MS)).toBe(1); + }); + + it('fades linearly once the hold expires', () => { + expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS / 2)).toBeCloseTo(0.5); + expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS / 4)).toBeCloseTo(0.75); + }); + + it('reaches exactly 0 at fade end and never goes negative after', () => { + expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS)).toBe(0); + expect(tutorialAlpha(TUTORIAL_MS + TUTORIAL_FADE_MS + 1)).toBe(0); + // A whole night later it is still pinned at 0 — the condition the old + // tween-spawning guard could never satisfy. + expect(tutorialAlpha(TUTORIAL_MS + 4 * 60 * 60 * 1000)).toBe(0); + }); + + it('is monotonically non-increasing frame over frame', () => { + let prev = Infinity; + for (let ms = 0; ms < TUTORIAL_MS + 2 * TUTORIAL_FADE_MS; ms += 16.67) { + const a = tutorialAlpha(ms); + expect(a).toBeLessThanOrEqual(prev); + expect(a).toBeGreaterThanOrEqual(0); + prev = a; + } + }); +});