Door tween leak: tutorial fade spawned an 800ms tween per frame — now pure tutorialAlpha() driven from update()

Ported from claude/epic-faraday-332679 (6c5889c) — merge was permission-blocked,
same diff applied directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 01:41:21 +10:00
parent fbcbb2e349
commit 41e93d01ab
3 changed files with 62 additions and 3 deletions

View File

@ -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();

View File

@ -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);
}

View File

@ -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;
}
});
});