not-tonight/tests/dollFrames.test.ts
type-two 9bc7dd1bfd Three jobs: sweep out of the god object, story beats per role, and dolls that breathe
- scenes/floor/sweep.ts: the closing sweep extracted behind an accessor host.
  Scene 2100 -> 1981 lines, behaviour verified identical.
- data/roleBeats.ts: 18 beats across 6 roles x 3 nights, landing in the venue
  group chat. The glassie is spoken about; the HoS is spoken to.
- dollPlan(p, pose, frame): 4-frame idle on the queue pose. NOT pre-rendered
  sheets — those would hide the outfit tells the dress code convicts on, which
  design 4.1 forbids; the pre-render path must go UNDER the outfit rects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 20:49:42 +10:00

85 lines
3.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { IDLE_FRAMES, dollPlan } from '../src/patrons/dollPlan';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import { SeededRNG } from '../src/core/SeededRNG';
import { violations, activeRules } from '../src/rules/dressCode';
const NIGHT = new Date('2026-07-18T00:00:00');
describe('idle frames (design §4.1 must survive animation)', () => {
it('every frame draws the SAME set of outfit pixels, only displaced', () => {
// The dress code convicts on things the doll draws. If a frame dropped or
// recoloured a rect, the player could be judged on a tell that blinked.
_resetPatronSerial();
const rng = new SeededRNG(31);
for (let i = 0; i < 40; i++) {
const p = generatePatron({ rng, nightDate: NIGHT }, 120);
const base = dollPlan(p, 'queue', 0);
for (let f = 1; f < IDLE_FRAMES; f++) {
const frame = dollPlan(p, 'queue', f);
expect(frame.rects.length, `p${i} f${f} rect count`).toBe(base.rects.length);
// Same colours, same sizes, in the same order — only x/y may move.
for (let r = 0; r < base.rects.length; r++) {
expect(frame.rects[r]!.colour).toBe(base.rects[r]!.colour);
expect(frame.rects[r]!.w).toBe(base.rects[r]!.w);
expect(frame.rects[r]!.h).toBe(base.rects[r]!.h);
}
}
}
});
it('a frame never moves anything more than a pixel or two', () => {
_resetPatronSerial();
const rng = new SeededRNG(7);
for (let i = 0; i < 30; i++) {
const p = generatePatron({ rng, nightDate: NIGHT }, 200);
const base = dollPlan(p, 'queue', 0);
for (let f = 1; f < IDLE_FRAMES; f++) {
const frame = dollPlan(p, 'queue', f);
for (let r = 0; r < base.rects.length; r++) {
expect(Math.abs(frame.rects[r]!.x - base.rects[r]!.x)).toBeLessThanOrEqual(2);
expect(Math.abs(frame.rects[r]!.y - base.rects[r]!.y)).toBeLessThanOrEqual(1);
}
}
}
});
it('the feet stay on the floor — nobody hovers', () => {
_resetPatronSerial();
const p = generatePatron({ rng: new SeededRNG(5), nightDate: NIGHT }, 60);
const base = dollPlan(p, 'queue', 0);
const feet = (plan: typeof base): number => Math.max(...plan.rects.map((r) => r.y + r.h));
for (let f = 1; f < IDLE_FRAMES; f++) {
expect(feet(dollPlan(p, 'queue', f))).toBe(feet(base));
}
});
it('what the rules judge is unchanged by which frame is showing', () => {
_resetPatronSerial();
const rng = new SeededRNG(99);
for (let i = 0; i < 40; i++) {
const p = generatePatron({ rng, nightDate: NIGHT }, 240);
// violations read outfit DATA, which frames never touch — this pins that
// the animation stayed on the render side of the contract.
const before = violations(p, 240).map((r) => r.id);
dollPlan(p, 'queue', 2);
expect(violations(p, 240).map((r) => r.id)).toEqual(before);
}
expect(activeRules(240).length).toBeGreaterThan(0);
});
it('frame 0 is exactly what every existing caller already got', () => {
_resetPatronSerial();
const p = generatePatron({ rng: new SeededRNG(12), nightDate: NIGHT }, 100);
expect(dollPlan(p, 'queue', 0)).toEqual(dollPlan(p, 'queue'));
});
it('the other poses ignore frames — the ID photo does not breathe', () => {
_resetPatronSerial();
const p = generatePatron({ rng: new SeededRNG(3), nightDate: NIGHT }, 100);
for (const pose of ['idPhoto', 'floorTopDown'] as const) {
expect(dollPlan(p, pose, 2)).toEqual(dollPlan(p, pose, 0));
}
});
});