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:
parent
ca8fd75aac
commit
595371f684
@ -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
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
dist/
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
patternStep,
|
||||
subdivisions,
|
||||
} from './scheduling';
|
||||
import '../ui/juiceEvents';
|
||||
|
||||
const DEFAULT_BPM = 128;
|
||||
const DEFAULT_LOOKAHEAD_MS = 100;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
CUTOFF_REPLY_SOFT,
|
||||
ESCALATION_LINES,
|
||||
type EscalationLine,
|
||||
} from './strings';
|
||||
} from '../../data/strings/floor';
|
||||
|
||||
export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree';
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {};
|
||||
@ -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',
|
||||
|
||||
@ -93,12 +93,12 @@ describe('activeRules', () => {
|
||||
expect(ids(activeRules(LATE))).toEqual([
|
||||
'noThongsSinglets',
|
||||
'noLogos',
|
||||
'noSongRequesters',
|
||||
'noSunnies',
|
||||
'noHandHolders',
|
||||
'noWhiteSneakers',
|
||||
'noBucketHats',
|
||||
'noBlazers',
|
||||
'noSongRequesters',
|
||||
'noHandHolders',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user