Phase 1 review: merge door/floor/juice, fold CCRs into contracts, integration fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 19:26:35 +10:00
parent ca8fd75aac
commit 595371f684
27 changed files with 123 additions and 80 deletions

View File

@ -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
}
]
}

2
.gitignore vendored
View File

@ -1,2 +1,2 @@
node_modules/
node_modules
dist/

View File

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

View File

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

View File

@ -16,7 +16,6 @@ import {
patternStep,
subdivisions,
} from './scheduling';
import '../ui/juiceEvents';
const DEFAULT_BPM = 128;
const DEFAULT_LOOKAHEAD_MS = 100;

View File

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

View File

@ -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 },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import {
CUTOFF_REPLY_SOFT,
ESCALATION_LINES,
type EscalationLine,
} from './strings';
} from '../../data/strings/floor';
export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree';

View File

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

View File

@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -93,12 +93,12 @@ describe('activeRules', () => {
expect(ids(activeRules(LATE))).toEqual([
'noThongsSinglets',
'noLogos',
'noSongRequesters',
'noSunnies',
'noHandHolders',
'noWhiteSneakers',
'noBucketHats',
'noBlazers',
'noSongRequesters',
'noHandHolders',
]);
});
});

View File

@ -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'];

View File

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

View File

@ -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',