diff --git a/.claude/launch.json b/.claude/launch.json index 1ae78e5..46ebafd 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,6 +1,15 @@ { "version": "0.0.1", "configurations": [ - { "name": "not-tonight", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5199 } + { + "name": "not-tonight", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "dev" + ], + "port": 5199, + "autoPort": true + } ] -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b947077..44d646d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -node_modules/ +node_modules dist/ diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 0663762..556a52d 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -477,3 +477,65 @@ character), and does the sweep *feel* like a door rather than a filter. - Someone with speakers should ear-check the mix and tell me what to retune. - Consider extracting `resyncBeat` into `scheduling.ts` so the throttled-tab resync gets direct test coverage. + +--- +### REVIEW — Fable — 2026-07-19 (Phase 1 review & integration) +**Reviewed:** lane/door @ e0b5831 (231 tests), lane/floor @ 3eee843 (169), +lane/juice @ f5e1151 (111). All three gates verified green per-branch before merge. +**Merged to main:** yes — door, then floor, then juice; 413 tests green post-merge. +Verified in-browser after integration: Door night boots and plays (called a patron +up, stamped her, meters moved per the judge table), FloorDemo cone/crowd runs (F), +JuiceDemo runs with AUDIO LIVE @ 128 BPM after unlock (J). + +**Quality note to all three lanes:** outstanding sessions. The play-found bug +lists (door's 10 + review pass, floor's meter-collapse and frozen-beat catches) +and juice's honest "verified by ear: NOTHING" are exactly what this file is for. + +**Contract decisions (all folded into `src/data/types.ts` + CONTRACTS.md §4 by me):** +1. CCR-1 `PatronFlags.handHolding` — ACCEPTED, folded in; doorTypes.ts augmentation removed. +2. CCR-2 `hypeDelta` — ACCEPTED onto `VerdictOutcome` (not just DoorOutcome); judge.ts augmentation removed. +3. CCR-3 (per-slot logos, distinct shoe/outer silhouettes) — ACCEPTED for the + v0.4 art pass; logged. `LOGO_VISIBLE_SLOTS` stays the guard until then. +4. CCR-4 — resolved by adding optional `short?: string` to `DressCodeRule`. + Door's runtime guard may stay or simplify; either is fine. +5. JUICE-1 `door:phoneTheatre` + `audio:unlocked` + `beat:bar` — ACCEPTED, folded in; + `ui/juiceEvents.ts` deleted (its three importers updated). +6. FLOOR-1 `incident:log` — ACCEPTED; `core/meters.ts` is now the single incident + writer and all three direct `incidents.push` sites (floor demo, DoorScene, + NightScene deferred) now emit the event instead. +7. FLOOR-2 aggro decay — deferred to the Phase-2 tuning pass, alongside door's + economy findings (vibe pinning, hype ratchet, queue slack). One tuning pass, + one owner, against a human player. + +**Rulings on questions:** +- `declare module` augmentation: it was the right unblocking move, and both lanes + executed it cleanly — but it does NOT become standing practice. Contract + extensions land in `types.ts` at each review, augmentations are deleted on + folding. One source of truth wins over lane autonomy here. +- `src/main.ts`: now integration-owned (reviewer/Phase-2). Lanes request routing + changes via handover. Current dev routes: N night · P parade · F floor · J juice, + same hashes. +- `data/strings/.ts`: ACCEPTED as the standing convention — new string + files there are lane-owned, no sign-off needed (floor's moved: + `src/data/strings/floor.ts`). `types.ts` stays frozen. +- Heat 3-strikes: keep summary-declared for v0.1; Phase 2's shell makes it an + immediate "licence pulled" cut — logged for integration. +- Vibe-pins-at-100 / economy retune: HELD for a human playtest (John). Door's + measured numbers are the starting point for that session, not auto-applied. +- Juice beat-0 anchor: `audio:unlocked.atMs` should carry the anchored beat-0 + origin — implement next juice session. +- MeterHud heat count: read `NightState.heatStrikes.length` at construction + (single source of truth) — implement next juice session. +- Floor escalation balance ("aggression never feels rewarded"): CONFIRMED as + design intent; door should mirror the shape at the rope. +- `.gitignore` slash: fixed. + +**Also applied at integration:** dazza fromMin retunes per door's measurements +(noSongRequesters 240→105, noHandHolders 280→135; two pinned-order tests updated), +floor strings moved to `data/strings/floor.ts`, vite port now respects `$PORT` +(preview autoPort), 413 tests + lint + build green on main. + +**Next:** Phase 2 integration is OPEN — one lane, `lanes/` file to follow. Big +rocks: night-flow state machine (door⇄floor interleave + radio pulls), TechnoEngine +replacing StubBeatClock in the night, ui/Stamp + MeterHud replacing door's local +versions, save/progression wiring, then the tuning pass with a human. diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 7ea6210..a05997a 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -120,9 +120,18 @@ export interface EventMap { 'meters:delta': { vibe?: number; aggro?: number; hype?: number }; 'meters:changed': { vibe: number; aggro: number; hype: number }; 'heat:strike': HeatStrike; + // folded in at the Phase-1 review (2026-07-19): + 'incident:log': IncidentRecord; // single writer: core/meters.ts appends to NightState.incidents + 'door:phoneTheatre': { durationMs: number }; + 'audio:unlocked': { atMs: number }; + 'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number }; } ``` +Phase-1 review also added: `PatronFlags.handHolding?` (CCR-1), +`VerdictOutcome.hypeDelta?` (CCR-2), `DressCodeRule.short?` (CCR-4). +`src/data/types.ts` remains the source of truth where this doc lags. + **Bus discipline:** scenes emit `meters:delta`; only `core/meters.ts` mutates state and emits `meters:changed`. HUD listens to `meters:changed` only. This keeps three lanes from fighting over the same numbers. diff --git a/src/audio/TechnoEngine.ts b/src/audio/TechnoEngine.ts index 5cb0644..1e61d86 100644 --- a/src/audio/TechnoEngine.ts +++ b/src/audio/TechnoEngine.ts @@ -16,7 +16,6 @@ import { patternStep, subdivisions, } from './scheduling'; -import '../ui/juiceEvents'; const DEFAULT_BPM = 128; const DEFAULT_LOOKAHEAD_MS = 100; diff --git a/src/core/meters.ts b/src/core/meters.ts index d0ae20e..a1abe88 100644 --- a/src/core/meters.ts +++ b/src/core/meters.ts @@ -36,6 +36,9 @@ export class Meters { bus.on('night:phaseChange', ({ location }) => { this.state.location = location; }), + bus.on('incident:log', (incident) => { + this.state.incidents.push(incident); + }), bus.on('door:clicker', ({ direction }) => { this.state.capacity.clickerShown = Math.max( 0, diff --git a/src/data/strings/dazza.ts b/src/data/strings/dazza.ts index ca86ce7..a528ae2 100644 --- a/src/data/strings/dazza.ts +++ b/src/data/strings/dazza.ts @@ -16,11 +16,11 @@ export const DAZZA_TEXTS: readonly DazzaLine[] = [ { id: 'rule-basics', text: 'mate its 930 no thongs no singlets we are not a beer garden', ruleId: 'noThongsSinglets', fromMin: 30 }, { id: 'rule-logos', text: 'seeing too many logos in there. no visible logos from NOW', ruleId: 'noLogos', fromMin: 90 }, { id: 'rule-sneakers', text: 'white sneakers are OVER. unless vintage. u know the difference', ruleId: 'noWhiteSneakers', fromMin: 150 }, - { id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 240 }, + { id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 105 }, { id: 'rule-bucketHats', text: 'bucket hats r making the room feel like a festival toilet queue. gone', ruleId: 'noBucketHats', fromMin: 180 }, { id: 'rule-sunnies', text: 'anyone wearing sunnies inside at 11pm is a cop or a flog. neither gets in', ruleId: 'noSunnies', fromMin: 120 }, { id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 }, - { id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 280 }, + { id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 135 }, // vibe commentary { id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 }, diff --git a/src/scenes/floor/strings.ts b/src/data/strings/floor.ts similarity index 100% rename from src/scenes/floor/strings.ts rename to src/data/strings/floor.ts diff --git a/src/data/types.ts b/src/data/types.ts index 4626ef0..4a5e8b6 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -40,6 +40,8 @@ export interface PatronFlags { uvStamped?: boolean; timesDeniedBefore?: number; disguised?: boolean; + /** Set on couples by door/QueueManager; predicate for noHandHolders (CCR-1). */ + handHolding?: boolean; } export interface Patron { @@ -59,6 +61,8 @@ export interface Patron { export interface DressCodeRule { id: string; text: string; + /** Card label for the on-screen dress-code board (CCR-4). */ + short?: string; activeFrom: number; // clock minutes since 21:00 violates: (p: Patron) => boolean; } @@ -73,6 +77,8 @@ export interface HeatStrike { export interface VerdictOutcome { vibeDelta: number; aggroDelta: number; + /** Queue-theatre payoff channel — the 'wait' verdict's reward (CCR-2). */ + hypeDelta?: number; heatStrike?: HeatStrike; dazzaText?: string; } @@ -137,4 +143,14 @@ export interface EventMap { 'meters:delta': { vibe?: number; aggro?: number; hype?: number }; 'meters:changed': { vibe: number; aggro: number; hype: number }; 'heat:strike': HeatStrike; + + // ---- folded in at Phase-1 review (see LANEHANDOVER.md REVIEW 2026-07-19) ---- + /** Single-writer incident trail: core/meters.ts appends to NightState.incidents. */ + 'incident:log': IncidentRecord; + /** Player fiddled with their phone while a patron waits — pure theatre. */ + 'door:phoneTheatre': { durationMs: number }; + /** AudioContext resumed after the first real user gesture. */ + 'audio:unlocked': { atMs: number }; + /** Bar line boundary (every 4th beat) — for anything that wants downbeats. */ + 'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number }; } diff --git a/src/rules/doorTypes.ts b/src/rules/doorTypes.ts index a0c9997..5522223 100644 --- a/src/rules/doorTypes.ts +++ b/src/rules/doorTypes.ts @@ -1,28 +1,9 @@ -// LANE-DOOR local contract extensions. -// -// Everything here is ADDITIVE (docs/CONTRACTS.md preamble: "Lanes may EXTEND -// (new event names in their namespace, new fields marked optional) but never -// repurpose existing fields"). Nothing in src/core/ or src/data/types.ts is -// edited — the PatronFlags addition below is a TypeScript interface -// augmentation, so the frozen file stays byte-identical. -// -// TODO(contract): CCR-1 in LANEHANDOVER.md asks the reviewer to fold -// `handHolding` into data/types.ts properly at Phase-2 integration; until then -// this augmentation is the whole implementation. +// LANE-DOOR type extensions. CCR-1 (PatronFlags.handHolding) and CCR-2 +// (VerdictOutcome.hypeDelta) were folded into data/types.ts at the Phase-1 +// review — this file now only holds door-domain types. import type { HeatStrike, Patron, VerdictOutcome } from '../data/types'; -declare module '../data/types' { - interface PatronFlags { - /** - * Set by door/QueueManager when two patrons arrive as a couple. Rendered as - * a linked-hands pixel between them, so `noHandHolders` stays a rule the - * player can actually SEE — the whole point of design §4.1. - */ - handHolding?: boolean; - } -} - /** * A consequence that lands LATER: Dazza does a lap and spots the bloke in * thongs you waved through, the inspector audits at 3 AM. Deferred hits are the diff --git a/src/rules/judge.ts b/src/rules/judge.ts index 2d6e1c8..1bd32c6 100644 --- a/src/rules/judge.ts +++ b/src/rules/judge.ts @@ -13,15 +13,6 @@ import type { Patron, Verdict } from '../data/types'; // pays vibe, it just costs far more aggro than denying a real violator does. // Arbitrary power is rewarded and expensive at the same time. That is the game. -// TODO(contract): CCR-2 — VerdictOutcome has no hype channel, but design §3.1 -// gives rope theatre its hype. Additive augmentation (same pattern doorTypes.ts -// uses for PatronFlags) until the reviewer folds hypeDelta in properly. -declare module './doorTypes' { - interface DoorOutcome { - hypeDelta?: number; - } -} - export const JUDGE_TUNING = { /** Deny someone who genuinely broke something. The job, done right. */ denyViolatorVibe: 6, diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index 186d022..8f3c2a3 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -465,7 +465,7 @@ export class DoorScene extends Phaser.Scene { if (outcome.deferred?.length) this.night.scheduleDeferred(outcome.deferred); if (outcome.note) this.showToast(outcome.note); - this.night.state.incidents.push({ + this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind: verdict, patronId: patron.id, diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 57de465..b65e8e5 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -197,7 +197,7 @@ export class NightScene extends Phaser.Scene { if (hit.heatStrike && shouldRecordStrike(this.state, hit.heatStrike)) { this.bus.emit('heat:strike', hit.heatStrike); } - this.state.incidents.push({ + this.bus.emit('incident:log', { clockMin, kind: 'deferred', patronId: hit.patronId, diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 1c43bd9..01892e6 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -19,7 +19,7 @@ import { layoutPockets, type Pocket } from './patDown'; import { CutOffOverlay } from './overlays/CutOffOverlay'; import { StallOverlay } from './overlays/StallOverlay'; import { PatDownOverlay } from './overlays/PatDownOverlay'; -import { HELP_TEXT, NO_STAMP_RADIO } from './strings'; +import { HELP_TEXT, NO_STAMP_RADIO } from '../../data/strings/floor'; const VIEW_H = 360; const INTERACT_RANGE = 46; @@ -398,9 +398,7 @@ export class FloorDemoScene extends Phaser.Scene { } private logIncident(kind: string, patronId: string | undefined, detail: string): void { - // TODO(contract): core has no incident API and EventMap is frozen, so the - // floor appends to its own NightState. Requested `incident:log` in LANEHANDOVER. - this.night.incidents.push({ clockMin: this.clock.clockMin, kind, patronId, detail }); + this.bus.emit('incident:log', { clockMin: this.clock.clockMin, kind, patronId, detail }); } private drawHud(dt: number): void { diff --git a/src/scenes/floor/escalation.ts b/src/scenes/floor/escalation.ts index e94a3d4..c25c77e 100644 --- a/src/scenes/floor/escalation.ts +++ b/src/scenes/floor/escalation.ts @@ -8,7 +8,7 @@ import { CUTOFF_REPLY_SOFT, ESCALATION_LINES, type EscalationLine, -} from './strings'; +} from '../../data/strings/floor'; export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree'; diff --git a/src/scenes/floor/overlays/CutOffOverlay.ts b/src/scenes/floor/overlays/CutOffOverlay.ts index 5132e9e..4a8f7a6 100644 --- a/src/scenes/floor/overlays/CutOffOverlay.ts +++ b/src/scenes/floor/overlays/CutOffOverlay.ts @@ -2,7 +2,7 @@ import Phaser from 'phaser'; import { renderDoll } from '../../../patrons/doll'; import type { DrunkStage, Patron } from '../../../data/types'; import { judgeEscalation, type EscalationResult } from '../escalation'; -import { ESCALATION_LINES, type EscalationLine } from '../strings'; +import { ESCALATION_LINES, type EscalationLine } from '../../../data/strings/floor'; const W = 640; const H = 360; diff --git a/src/scenes/floor/overlays/PatDownOverlay.ts b/src/scenes/floor/overlays/PatDownOverlay.ts index 41f2092..ff72043 100644 --- a/src/scenes/floor/overlays/PatDownOverlay.ts +++ b/src/scenes/floor/overlays/PatDownOverlay.ts @@ -1,7 +1,7 @@ import Phaser from 'phaser'; import { CONTRABAND } from '../../../data/contraband'; import type { Patron } from '../../../data/types'; -import { PATDOWN_INTRO } from '../strings'; +import { PATDOWN_INTRO } from '../../../data/strings/floor'; import { PATDOWN_MS, POCKET_COUNT, foundLine, scorePatDown } from '../patDown'; import type { PatDownResult, Pocket } from '../patDown'; diff --git a/src/scenes/floor/overlays/StallOverlay.ts b/src/scenes/floor/overlays/StallOverlay.ts index c6c3357..715e697 100644 --- a/src/scenes/floor/overlays/StallOverlay.ts +++ b/src/scenes/floor/overlays/StallOverlay.ts @@ -13,7 +13,7 @@ import { type StallOutcome, type StallState, } from '../bangJudge'; -import { STALL_BUSTED, STALL_MUFFLED, STALL_OFF_BEAT } from '../strings'; +import { STALL_BUSTED, STALL_MUFFLED, STALL_OFF_BEAT } from '../../../data/strings/floor'; const W = 640; const H = 360; diff --git a/src/scenes/floor/patDown.ts b/src/scenes/floor/patDown.ts index 522dd11..e1516c9 100644 --- a/src/scenes/floor/patDown.ts +++ b/src/scenes/floor/patDown.ts @@ -4,7 +4,7 @@ import type { RngStream } from '../../core/SeededRNG'; import { CONTRABAND } from '../../data/contraband'; import type { Patron } from '../../data/types'; -import { PATDOWN_CLEAR, PATDOWN_FOUND, PATDOWN_MISSED } from './strings'; +import { PATDOWN_CLEAR, PATDOWN_FOUND, PATDOWN_MISSED } from '../../data/strings/floor'; export const PATDOWN_MS = 10_000; export const POCKET_COUNT = 6; diff --git a/src/ui/JuiceDemoScene.ts b/src/ui/JuiceDemoScene.ts index 4596b9e..48c4ff2 100644 --- a/src/ui/JuiceDemoScene.ts +++ b/src/ui/JuiceDemoScene.ts @@ -13,7 +13,6 @@ import { MeterHud } from './MeterHud'; import { Phone } from './Phone'; import { stamp, type StampHandle } from './Stamp'; import { UI, chunkyButton, font } from './style'; -import './juiceEvents'; const W = 640; const H = 360; diff --git a/src/ui/Phone.ts b/src/ui/Phone.ts index 4a1a170..378a543 100644 --- a/src/ui/Phone.ts +++ b/src/ui/Phone.ts @@ -1,7 +1,6 @@ import Phaser from 'phaser'; import type { EventBus } from '../core/EventBus'; import { UI, font, panel } from './style'; -import './juiceEvents'; // The player's only line to management. Dazza is a man who is frightened of the // owner, so every buzz is a small emergency. The phone is deliberately fiddly: diff --git a/src/ui/juiceEvents.ts b/src/ui/juiceEvents.ts deleted file mode 100644 index 2020d09..0000000 --- a/src/ui/juiceEvents.ts +++ /dev/null @@ -1,23 +0,0 @@ -// LANE-JUICE event extensions. -// -// TODO(contract): `door:phoneTheatre` lives outside the audio domain, so it needs -// reviewer sign-off before it moves into data/types.ts. Until then we merge it -// into EventMap by declaration merging rather than editing the frozen contract -// file — same typing guarantees, zero edits to LANE-0 territory. See the CONTRACT -// CHANGE REQUEST in LANEHANDOVER.md. -// -// `audio:*` / `beat:*` additions are ours to extend freely (LANE_JUICE.md), but -// they are declared here too so the whole juice surface reads in one place. - -declare module '../data/types' { - interface EventMap { - /** Player fiddled with their phone while a patron waits — pure theatre. */ - 'door:phoneTheatre': { durationMs: number }; - /** AudioContext resumed after the first real user gesture. */ - 'audio:unlocked': { atMs: number }; - /** Bar line boundary (every 4th beat) — for anything that wants downbeats. */ - 'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number }; - } -} - -export {}; diff --git a/tests/dazzaSchedule.test.ts b/tests/dazzaSchedule.test.ts index 13a5b40..541a0ee 100644 --- a/tests/dazzaSchedule.test.ts +++ b/tests/dazzaSchedule.test.ts @@ -51,12 +51,12 @@ describe('dazzaDue', () => { expect(ids(due.filter((l) => l.ruleId))).toEqual([ 'rule-basics', 'rule-logos', + 'rule-songRequest', 'rule-sunnies', + 'rule-couples', 'rule-sneakers', 'rule-bucketHats', 'rule-suits', - 'rule-songRequest', - 'rule-couples', ]); expect(ids(due.filter((l) => !l.ruleId))).toEqual([ 'vibe-empty', diff --git a/tests/dressCode.test.ts b/tests/dressCode.test.ts index 4ef9165..7a54222 100644 --- a/tests/dressCode.test.ts +++ b/tests/dressCode.test.ts @@ -93,12 +93,12 @@ describe('activeRules', () => { expect(ids(activeRules(LATE))).toEqual([ 'noThongsSinglets', 'noLogos', + 'noSongRequesters', 'noSunnies', + 'noHandHolders', 'noWhiteSneakers', 'noBucketHats', 'noBlazers', - 'noSongRequesters', - 'noHandHolders', ]); }); }); diff --git a/tests/floor/escalation.test.ts b/tests/floor/escalation.test.ts index 9e90e33..77c896b 100644 --- a/tests/floor/escalation.test.ts +++ b/tests/floor/escalation.test.ts @@ -12,7 +12,7 @@ import { CUTOFF_REPLY_SOFT, ESCALATION_LINES, type EscalationLine, -} from '../../src/scenes/floor/strings'; +} from '../../src/data/strings/floor'; const STAGES: readonly DrunkStage[] = ['sober', 'tipsy', 'loose', 'messy', 'maggot']; const LINES: readonly EscalationLine[] = ['water', 'done', 'escort']; diff --git a/tests/floor/patDown.test.ts b/tests/floor/patDown.test.ts index ac9c040..4ca56fc 100644 --- a/tests/floor/patDown.test.ts +++ b/tests/floor/patDown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { SeededRNG } from '../../src/core/SeededRNG'; -import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/scenes/floor/strings'; +import { PATDOWN_CLEAR, PATDOWN_MISSED } from '../../src/data/strings/floor'; import { foundLine, layoutPockets, diff --git a/vite.config.ts b/vite.config.ts index 0ca6e31..0568e39 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ base: './', - server: { port: 5199 }, + server: { port: Number(process.env.PORT) || 5199 }, test: { include: ['tests/**/*.test.ts'], environment: 'node',