From 6c5889c9578a0f9cbafead5831dd9a0bc1664cd4 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 19:43:08 +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 The alpha>0 guard never closed (each new tween re-captured the still- positive alpha), so DoorScene accumulated ~1 live tween per frame all night. Fade is now tween-free elapsedMs math with tests pinning that it reaches exactly 0 and stays there. Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 27 ++++++++++++++++++++++ src/scenes/door/DoorScene.ts | 8 ++++--- src/scenes/door/tutorialFade.ts | 17 ++++++++++++++ tests/tutorialFade.test.ts | 40 +++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 src/scenes/door/tutorialFade.ts create mode 100644 tests/tutorialFade.test.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index b575c3f..f89602f 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1338,3 +1338,30 @@ kebab run full cycle with incident + toast ✓. - Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled seeded nights; the band's claim ("denials sting at all") is intact. - SCENARIOS.md: everything except the three phase-scale systems is now ✅. + +--- +### SESSION — FABLE-SOLO-11 (tween-leak chip) — 2026-07-20 +**Branch/commits:** claude/epic-faraday-332679 @ 1b08420..(this commit) +**Done:** Killed the DoorScene tween leak flagged in FABLE-SOLO-10. Root cause: +the tutorial fade at the bottom of `update()` was `if (tutorial.alpha > 0) +tweens.add({ alpha: 0, duration: 800 })` — every new tween re-captured the +still-positive alpha and wrote over the older tweens' output, so the guard +never closed and the scene added one live 800ms tween PER FRAME after the 26s +mark (reproduced in the pane: 841 live tweens after 2,400 stepped frames, all +duration 800, oldest only 703ms advanced — tweens also stall under the pane's +stepped clock, so none ever completed there). Fix: no tween at all — fade is +now pure `tutorialAlpha(elapsedMs)` in `src/scenes/door/tutorialFade.ts`, +driven from update() (same lesson as the kebab-run timer), text setVisible(false) +once it hits 0. Re-ran the same 2,400-frame reproduction post-fix: 0 live +tweens, tutorial alpha 0 / hidden, door renders normally. +**How to see it:** `npm run dev` → #N → in console +`const b=performance.now(); for(let i=1;i<=2400;i++) game.loop.step(b+i*16.67)` +→ `game.scene.getScene('Door').tweens.getTweens().length` → 0 (was ~841). +**Tests:** 669 passing / 0 failing (4 new in tests/tutorialFade.test.ts — pins +that the fade reaches exactly 0, stays 0 a whole night later, and is monotonic). +**Broke / known-wonky:** nothing. No scene-shutdown teardown added — the leak +was update()-loop spawning, not shutdown; Phaser already kills scene tweens on +shutdown. +**Contract change requests:** none +**Questions for reviewer:** none +**Next session should:** merge this branch; nothing else pending from the chip. 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; + } + }); +});