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>
This commit is contained in:
parent
1b0842024b
commit
6c5889c957
@ -1338,3 +1338,30 @@ kebab run full cycle with incident + toast ✓.
|
|||||||
- Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled
|
- Aggro sim band floor eased 15 → 12: doubling the encounter pool reshuffled
|
||||||
seeded nights; the band's claim ("denials sting at all") is intact.
|
seeded nights; the band's claim ("denials sting at all") is intact.
|
||||||
- SCENARIOS.md: everything except the three phase-scale systems is now ✅.
|
- 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.
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
|
|||||||
import { encounterById } from '../../rules/encounters';
|
import { encounterById } from '../../rules/encounters';
|
||||||
import { SobrietyModal } from './SobrietyModal';
|
import { SobrietyModal } from './SobrietyModal';
|
||||||
import { stamp, type StampHandle } from '../../ui/Stamp';
|
import { stamp, type StampHandle } from '../../ui/Stamp';
|
||||||
|
import { tutorialAlpha } from './tutorialFade';
|
||||||
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
|
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
|
||||||
import { shouldRecordStrike, type NightContext } from './NightScene';
|
import { shouldRecordStrike, type NightContext } from './NightScene';
|
||||||
|
|
||||||
@ -39,7 +40,6 @@ const KAYDEN_RULE_MS = 5000;
|
|||||||
const UP_FOOT_Y = 244;
|
const UP_FOOT_Y = 244;
|
||||||
const DOOR_X = 566;
|
const DOOR_X = 566;
|
||||||
const QUEUE_FOOT_Y = 228;
|
const QUEUE_FOOT_Y = 228;
|
||||||
const TUTORIAL_MS = 26_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule
|
* 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;
|
const locked = this.night.state.clockMin >= LOCKOUT_MIN;
|
||||||
this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label);
|
this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label);
|
||||||
this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0');
|
this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0');
|
||||||
if (this.tutorial.alpha > 0 && this.elapsedMs > TUTORIAL_MS) {
|
if (this.tutorial.visible) {
|
||||||
this.tweens.add({ targets: this.tutorial, alpha: 0, duration: 800 });
|
const a = tutorialAlpha(this.elapsedMs);
|
||||||
|
this.tutorial.setAlpha(a);
|
||||||
|
if (a <= 0) this.tutorial.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncQueueSprites();
|
this.syncQueueSprites();
|
||||||
|
|||||||
17
src/scenes/door/tutorialFade.ts
Normal file
17
src/scenes/door/tutorialFade.ts
Normal 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);
|
||||||
|
}
|
||||||
40
tests/tutorialFade.test.ts
Normal file
40
tests/tutorialFade.test.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user