Compare commits

...

1 Commits

Author SHA1 Message Date
type-two
6c5889c957 Door tween leak: tutorial fade spawned an 800ms tween per frame — now pure tutorialAlpha() driven from update()
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 <noreply@anthropic.com>
2026-07-20 19:43:08 +10:00
4 changed files with 89 additions and 3 deletions

View File

@ -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.

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