not-tonight/LANEHANDOVER.md
type-two 595371f684 Phase 1 review: merge door/floor/juice, fold CCRs into contracts, integration fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:26:35 +10:00

542 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LANE HANDOVER LOG
Coordination file between lanes (Opus 4.8 execution sessions) and the reviewer
(Fable). **Append-only** — never edit or delete previous blocks. Newest block at the
BOTTOM. Push after every session so the reviewer can see state remotely.
## Protocol
1. Before starting work: read this whole file + your lane file in `lanes/` +
`docs/CONTRACTS.md`. Check for a REVIEW block addressed to your lane — it
overrides your lane file where they conflict.
2. Work only inside your lane's owned directories (see BUILD_PLAN.md §2).
3. End of session: append a SESSION block (template below), commit, push your
`lane/<name>` branch.
4. Never merge to main — the reviewer does that.
5. Blocked on a contract ambiguity? Don't stall and don't silently change the
contract: stub locally with `// TODO(contract):`, log a CONTRACT CHANGE REQUEST
below, keep moving.
## SESSION block template
```markdown
---
### SESSION — <LANE-NAME> — <date> <time>
**Branch/commits:** lane/<name> @ <short-sha>..<short-sha>
**Done:** bullet list of what actually works now (be honest — "renders but untested" ≠ "done")
**How to see it:** exact command + what to click (e.g. `npm run dev` → press D → deny 3 patrons)
**Tests:** X passing / Y failing (name the failures)
**Broke / known-wonky:** anything you left imperfect
**Contract change requests:** none | numbered list with justification
**Questions for reviewer:** none | numbered list
**Next session should:** bullet list
```
## REVIEW block template (reviewer only)
```markdown
---
### REVIEW — Fable — <date>
**Reviewed:** <lanes/commits>
**Merged to main:** <yes/no + sha>
**Contract decisions:** rulings on any open change requests
**Instructions for <LANE>:** what to do next / what to fix first
```
---
*(log starts below)*
---
### SESSION — LANE-0 (executed by Fable, reviewer) — 2026-07-19 16:30
**Branch/commits:** main (reviewer executed Phase 0 directly; no lane branch)
**Done:**
- Vite + strict-TS + Phaser 3.90 + Vitest + ESLint scaffold; `Math.random` banned by lint rule.
- All CONTRACTS.md types verbatim in `src/data/types.ts`.
- `core/`: SeededRNG (mulberry32, named streams), typed EventBus (snapshot-emit
semantics), GameClock (real-ms driven, `ageOn` birthday math incl. leap-day),
Meters (single-writer, hype multiplies positive vibe only, clicker drift),
StubBeatClock (128 BPM), versioned corrupt-safe localStorage save.
- `data/`: outfit vocabulary (+PALETTE), 9 archetypes, 10 contraband items,
17 Dazza texts (8 rule-bearing with ruleIds).
- `patrons/`: generator (fake-ID tell system, DOBs clustered near the 18
boundary, late-arrival drunk drift), pure `dollPlan` + Phaser `renderDoll`
(3 poses, drunk tells baked), regulars' memory store.
- `rules/drunk.ts`: drunkStage thresholds.
- ParadeScene demo: rainy street, kebab-shop + venue dressing, 24-walker parade
with ID cards rendered from the same data, SPACE reseed / D toggle cards.
- 49 tests green; lint/build/test gate clean.
**How to see it:** `npm run dev` → http://localhost:5199 (in the Claude preview
pane, drive frames per CLAUDE.md "Dev notes"). Watch for an `almost18` label
walking under an ID card claiming 18+ — that's the whole game in one image.
**Tests:** 49 passing / 0 failing.
**Broke / known-wonky:**
- ID cards overlap when the footpath is crowded — cosmetic, parade-only.
- `window.game` debug handle exposed in main.ts (deliberate, for pane-driving).
- Spec deviations: doll renderer split into `dollPlan.ts` (pure) + `doll.ts`
(Phaser) — better than spec'd, plans are pixel-testable without a GPU; added
a 5th fake-ID tell (expired real card, no boolean tells); no MainMenu scene
(Boot → Parade direct) — menu is Phase 2 shell work.
**Contract change requests:** none (contracts implemented as written).
**Questions for reviewer:** n/a (self-reviewed).
**Next session should:** Phase 1 lanes are OPEN — see REVIEW block below.
---
### REVIEW — Fable — 2026-07-19
**Reviewed:** Phase 0 (self-executed; verified via test gate + in-browser parade).
**Merged to main:** yes (direct).
**Contract decisions:** CONTRACTS.md now frozen. Extension requests via this file.
**Instructions for Phase 1 (LANE-DOOR, LANE-FLOOR, LANE-JUICE):**
1. Branch from current main into `lane/door` / `lane/floor` / `lane/juice`
(worktrees if sharing a machine). Read your lane file's new
"Phase-0 reality" section FIRST — it corrects the original spec where the
landed code differs.
2. LANE-DOOR is the critical path; if only one Opus session is available, run
DOOR first, JUICE second, FLOOR third.
3. Every session ends with the gate + a SESSION block here + a push. Fable
reviews on request ("review the lanes").
---
### SESSION — LANE-DOOR — 2026-07-19 18:50
**Branch/commits:** lane/door @ 9805ff8..(this commit) — branched from main after Phase 0,
worked in worktree `../not-tonight-door`.
**Done** (all verified by playing it, not just by tests):
- **Full Door-only night is playable start→summary.** `npm run dev` boots straight into
9:00 PM, patrons arrive on a curve, you work the rope, rule on them, Dazza texts land,
meters move, 3:00 AM → summary. Both fail states reachable.
- **Rules engine** (`src/rules/`, pure, no Phaser):
- `dressCode.ts` — 8 rules. `text`/`activeFrom` are READ OUT of `data/strings/dazza.ts`
by ruleId rather than retyped, and the module throws at load if a rule and its
announcing text ever disagree, so the card can't drift from the message.
- `idCheck.ts` — all the ID date maths (`idVerdict`, `idDisplayLines`). Builds on
`ageOn`, does not reimplement it. Catches the 5th tell (expired-but-otherwise-real).
- `judge.ts` — the single scoring function + `JUDGE_TUNING` (every magic number named).
- `sobriety.ts` — challenge content + the drunk-performance model.
- `doorTypes.ts` — this lane's contract EXTENSIONS (see change requests below).
- **Scene layer** (`src/scenes/door/`): `NightScene` (thin night shell — clock, meters,
deferred consequences, fail states, marked `TODO(integration)`), `DoorScene` (tableau +
desk + all input), `QueueManager` + `arrivalCurve` (pure, no Phaser), `PatronUpView`
(×3 doll, 5 hover zones), `IdCardView` (tray thumbnail + half-screen zoom),
`PhoneWidget` (buzz, preview, scrollback thread), `DressCodeCard` (NEW badges),
`SobrietyModal`, `StampFx` (marked `TODO(juice)`), `NightSummaryScene`, `inspect.ts`,
`dazzaSchedule.ts`, `ui.ts`.
- **Tests: 49 → 229** (+180). Every judge branch, every ID boundary (18th birthday
tonight, leap-day DOB, expiry today vs tomorrow), every dress-code predicate, the queue
economy, the Dazza schedule, and the inspection vocabulary.
**How to see it:** `npm run dev` → http://localhost:5199.
Click the rope (or NEXT!) → patron steps up ×3 → **hover their shoes/torso/face** for
tooltips → click the ID in the tray to zoom it → ADMIT or NOT TONIGHT. Stamp someone to
see the signature interaction. Wait ~30 in-game min for Dazza's first rule text.
To fail on purpose: deny everyone (aggro riots by ~10:30 PM), or admit everyone (three
heat strikes, licence gone at 3 AM).
In the Claude preview pane, drive frames per CLAUDE.md "Dev notes" — `document.hidden`
freezes the Phaser loop at frame 0 there. **Drive a whole run inside ONE `javascript_exec`
call**: splitting the frame stepping across calls hands Phaser a multi-second delta and
its tweens/timers land in a state that looks like a game bug but isn't. Cost me an hour.
**Tests:** 229 passing / 0 failing.
**Bugs found by playing it that the test gate could never have caught** (all fixed):
1. Game opened on ~45 real seconds of empty street — the 9 PM arrival rate was honest and
unplayable. Raised the curve, capped the max gap, and the QueueManager now seeds one
patron at construction so somebody is always at the rope on the first frame.
2. `noLogos` fired on 47.6% of patrons, but `dollPlan.ts` only draws a logo for the TOP
slot — ~28% of all patrons were deniable for a logo that was never on screen. Narrowed
to visibly-rendered slots (see CCR-3). Dazza's own text says "no *visible* logos".
3. **One patron could be ruled up to 11 times.** Game state was being mutated inside the
stamp's tween callback; if the tween didn't land, the patron stayed at a rope the
player could keep scoring. All state now settles synchronously in `rule()` and the
tweens only move pixels. This class of bug is why `busy` also has a token-guarded
watchdog.
4. A correctly-played night still rioted at 1:37 AM. Aggro relief only fired on a
completely empty street, which never happens during the slam. Relief now scales with
queue slack — the pressure is "keep it moving", not "never deny".
5. Admit-everything logged **40** heat strikes (one per patron over capacity).
CONTRACTS.md §3 says heatStrikes is "run-scoped, max 3"; now enforced, and a persistent
breach is one write-up rather than one per patron.
6. One stubborn regular reappeared 6 times and ate a third of the night's denials.
Comebacks capped at 2.
7. Dazza sent the identical lap text 5 times in a row. Seven phrasings now, picked by the
same roll that sets the delay.
8. Determinism leak: denying a regular drew from the `arrivals` stream, so one player
decision reshuffled every remaining arrival time. Moved to its own `comebacks` stream
(CONTRACTS.md §6 exists precisely to stop this).
9. ID card modal covered the patron, making photo-vs-face impossible. Moved right.
The genuine-hologram reference swatch now sits on the same cream backing as the card —
a translucent teal reads as a different colour over dark laminate than over card stock,
so the reference was lying.
10. Phone preview text spilled off the screen onto the desk; strike reasons on the summary
ran off the right edge of the canvas. Both clamped.
**Broke / known-wonky:**
- `src/main.ts` is the one file I touched outside my lane — 8 lines, to register the three
door scenes and boot into `Night` instead of `Parade`. Unavoidable for the "npm run dev
→ playable night" deliverable. Boot/Parade are still registered; `game.scene.start('Parade')`
from the console still works. Flagging it explicitly for the merge.
- `src/data/strings/door.ts` is a NEW file in `data/strings/` — CONTRACTS.md §7 says Aussie
strings live there, and it adds no types, but `data/` is nominally frozen-ish. Move it if
you'd rather.
- With placeholder art, `noThongsSinglets` / `noWhiteSneakers` / `noBlazers` are not
distinguishable by silhouette (a thong and a boot are both a 3×4 rect). They ARE
readable via the hover tooltips, which is why I built the inspection surface before the
stamp. It costs the player time, which costs aggro — that tradeoff is fine, arguably
good. It stops being a compromise at the v0.4 art pass (CCR-3).
- Vibe pins at 100 for a competent player by ~midnight, so late-night vibe stops being a
live pressure. Aggro and heat still are. Worth a tuning pass in Phase 2; I did not want
to over-tune against a bot that plays with perfect information and zero deliberation
time. A human is much slower and will not see these numbers.
- `NightScene` renders nothing and `DoorScene` draws the HUD. Deliberate, but it means the
HUD moves to wherever Phase 2 puts the shell.
- No audio (LANE-JUICE). `StubBeatClock` runs and is consumed for nothing yet — no rhythm
flourish landed; the stamp is tween-timed, not beat-timed.
**Contract change requests:**
1. **CCR-1 — `PatronFlags.handHolding?: boolean`.** Needed by `noHandHolders`, which is one
of the 8 already-written Dazza rule texts and otherwise has no data to predicate on.
Implemented as a `declare module` interface augmentation in `src/rules/doorTypes.ts`, so
`src/data/types.ts` is byte-identical to main. QueueManager sets it on couples and
DoorScene draws a linked-hands pixel between them so the rule is visible, not psychic.
Request: fold the field into `types.ts` properly at integration and delete the augmentation.
2. **CCR-2 — `VerdictOutcome.hypeDelta?: number`.** Design §3.1 gives rope theatre a hype
reward but `VerdictOutcome` has no hype channel, so `judge()` cannot express the 'wait'
verdict's payoff. Same augmentation pattern, in `src/rules/judge.ts`. Same request.
3. **CCR-3 — renderer request, for LANE-0/art rather than contracts.** `dollPlan.ts` draws
a logo rect for the TOP slot only, and gives thongs/boots, blazer/bomber, and
sneaker/heel identical geometry. Four dress-code rules are therefore silhouette-invisible
and I narrowed `noLogos` to compensate (`LOGO_VISIBLE_SLOTS` in `dressCode.ts`, one
constant, widen it when the art lands). Requesting per-slot logo marks and distinct
shoe/outer silhouettes in the v0.4 art pass.
**Questions for reviewer:**
1. Is the `declare module` augmentation pattern acceptable as the standing mechanism for
additive contract extensions, or do you want every extension to go through you and land
in `types.ts`? It kept the frozen files clean and I'd use it again, but it is a
precedent and two other lanes will copy it.
2. `src/main.ts` and `src/data/strings/door.ts` — ruling on ownership, please. If either is
off-limits I'll move the strings into `scenes/door/` and hand you a patch for main.ts.
3. Should heat strikes end the night immediately at 3, or accumulate to the summary as
they do now? Design §2 says "3 strikes = licence pulled, run over" — I read that as
run-scoped, so the night still finishes and the summary declares it. Say the word and
it becomes a third fail state.
4. Vibe pinning at 100 (above) — worth retuning now, or leave it until there is a real
playtester rather than a bot?
**Next session should:**
- Take the review findings from the four-lens pass (lane discipline, scene lifecycle,
playability, balance) — I ran it but landed only what I could verify in-session.
- Swap `StampFx` for `ui/Stamp` + SFX once LANE-JUICE lands; hook a beat flourish to
`beat:tick` (currently consumed by nothing).
- Guest-list clipboard and the UV pass-out stamp are specced in design §3.1 and NOT built —
they were the right things to cut to get a full playable night.
- Sobriety line-walk (the third challenge) is still a stretch item; alphabet + riddle ship.
- Phase 2 integration should lift clock/meters/fail-state ownership out of `NightScene`
into the real night-flow machine and replace the temporary summary.
---
### SESSION — LANE-DOOR (review-fix pass) — 2026-07-19 19:05
**Branch/commits:** lane/door @ 30162d3..(this commit)
Ran a four-lens adversarial review over the lane (discipline / scene lifecycle /
playability / balance) and landed the findings I could verify in-session.
**Fixed:**
1. **The game was answering its own question.** `DoorScene` lit up every rule a patron
broke on the dress-code card the moment they stepped up. Optimal play was "read the red
line", which deletes the inspection loop the entire scene exists for — all 8 dress-code
rules were free. The highlight now fires AFTER the ruling, for ~2s, as feedback.
This is the single most important fix in this pass.
2. **Tonight's date appeared nowhere in the game.** The card showed `04/12/2000` and the
HUD showed `11:42 PM`; the player was asked to subtract a birthdate from a date they
were never given. `underage` is the tell that files a heat strike and costs the
licence, and it was pure guesswork. Now stamped on the card beside the DOB and under
the HUD clock. Every boundary case `idCheck.ts` was carefully written for
(`turnsEighteenTonight`, leap-day DOB) is finally reachable by a player.
3. **Determinism, again, worse than the one I already fixed.** In `spawnArrival` the
partner's `generatePatron` call sat behind `if (enqueued)`, and enqueue fails at
`maxQueue` — which is player-controlled. From the first overflow onward every
remaining patron of the night differed. All draws are now unconditional. Same class as
the comeback-stream bug; I fixed it for `couples` and left it in `patrons`.
4. **You could rule straight through the open sobriety modal.** Its backdrop was not
interactive, so ADMIT/NOT TONIGHT stayed live underneath. Now eats clicks.
5. **Tooltips read as broken text**`navy a blazer`, `white a singlet`, `cream a bucket
hat`. Articles were baked into the noun tables and then colour-prefixed. Since the
tooltips are the ONLY detection channel for four rules under placeholder art, the one
system the player has to trust looked broken. Nouns are bare now.
6. `bumbag` and `chain` are drawn on the torso/waist but were described only in the head
zone — hovering the visible item told you about a jacket. Both body zones now name them.
7. `NightScene.endNight` pushed to `state.heatStrikes` directly, bypassing `core/meters.ts`
fifty lines after correctly emitting `heat:strike`. Now emits. CONTRACTS.md §4.
8. Dropped the one `as never` in the diff (`DoorScene`'s `dazza:text` handler) for an
honest type guard — see CCR-4.
9. Test quality: `tests/inspect.test.ts` proved its vocabulary coverage by string-comparing
output against the raw type id, which silently stopped working once a legitimate word
('skirt', 'cap') matched its own id. Replaced with an exact `hasWordFor` check.
**Tests:** 231 passing / 0 failing (+2). Re-verified in-browser after the changes:
competent play still runs to 3:00 AM; deny-everything still riots (aggro 100 @ 10:42 PM);
one patron can still only be ruled once, plus up to 2 regular comebacks.
**New contract change request:**
4. **CCR-4 — `dazza:text` payload should carry the lane's rule type.** `EventMap` types it
`rule?: DressCodeRule`, so a rule this lane emits comes back without the `short` card
label the lane added to it. Currently narrowed with a runtime type guard in
`DoorScene`. Either widen the payload or accept the guard.
**Reviewer findings I did NOT land, with reasons:**
- **The economy is slack and dawdling is dominant.** Measured over 5 seeds: a competent
night pours 7491115 raw vibe into 50 points of headroom (≈94% discarded by the clamp),
vibe pins at 100 by ~10:00 PM and stays there 8186% of the night, and aggro peaks at
5/100 because relief refunds ~10:1. `hype` is a pure ratchet — nothing in `src/` ever
emits a negative hype delta — so stalling is paid twice (vibe multiplier + cash bonus)
and a player who plays *slower* earns 24% more at identical accuracy. Recommended:
`denyViolatorVibe` 6→1, `admitCleanVibe` 3→1, `QUEUE_TUNING.calmPerSec` 0.9→0.25.
I did not apply these: it is a whole-economy retune, the measurements come from a bot
with perfect information and zero deliberation time, and I would rather you tune it
against a human than have me chase a number I can't feel. Flagging it as the top item
for the next pass.
- **The queue never actually backs up.** Player throughput ≈0.83 arrivals/in-game-min
against a curve that peaks at 0.42 — the "10:3012:30 slam" is currently fiction
(measured avg queue 0.10.2, max 35 against 8 visible slots). Fixing it means roughly
doubling the curve to ~170 arrivals a night, which is a big feel change; wants a human
playtest first.
- **`noSongRequesters` (activeFrom 240) and `noHandHolders` (280) are nearly decorative** —
they arrive after 84% and 93% of the night's patrons have been served, and fire ~1.3 and
~0 times respectively. Their `fromMin` values come from `data/strings/dazza.ts`, which
is not mine to edit. Suggest 240→~105 and 280→~135.
- **`noHandHolders` is still unfair even when it fires.** The linked-hands pixel only draws
between adjacent QUEUE sprites; once a patron steps up to the rope there is no partner
drawn and no tooltip mentions it, so at the moment of judgement the tell is invisible.
CCR-1's justification ("rendered as a linked-hands pixel ... so the rule stays one the
player can SEE") overclaims. Either draw the partner at the rope, add it to
`describeZone`, or cut the rule.
- **Intoxication and contraband are worth zero points.** `judge()` reads neither. Denying
a maggot is scored as denying a clean punter (+7 aggro), and a pat-down that finds a
baggie changes nothing. The drunk channel is the most legible signal the game has — sway
tween, bloodshot pixels, a whole sobriety minigame — and it does not score. This is the
biggest *design* gap in the lane and I'd want your ruling on the intended numbers before
inventing them.
- **The clicker is inert.** `clickerShown` is read only for a text colour and one summary
row; `judge` uses the true `insideCount` for capacity. The brief calls the divergence
"the game", so it needs a consequence — presumably the inspector reads the clicker, not
reality, which is Phase-2 inspector work.
- Minor, unlanded: the ID modal's shade dims the desk hologram swatch but not the card's
(the teal/magenta hue gap carries it today, but a subtler fake colour would break);
`peelingLaminate` is cream-on-cream and near-invisible; `photoMismatch` degrades to a 1/6
coin flip for hatted patrons; three player-facing strings are still inline in `judge.ts`
against CONTRACTS.md §7, one of which ("Good story for them one day") reads as narrator
rather than the game's voice.
### SESSION — LANE-FLOOR — 2026-07-19 18:50
**Branch/commits:** lane/floor @ 9805ff8..(this commit) — branched from main, worktree `../not-tonight-floor`
**Done:**
- `FloorDemoScene` (menu key **F** from Parade): 80x45-tile venue (1280x720, 2x screen,
camera-follow), 44-patron crowd sim, flashlight cone, UV mode, all three v0.2
infractions + the stamp check, playable end-to-end.
- Pure logic, all tested: `venueMap` (programmatic layout + anchors + stalls),
`cone` (vision maths), `player` (heavy accel + wall slide), `crowdSim`
(archetype-weighted anchor wandering, clock-driven intoxication drift, beat-driven
dance bob, stall double-occupancy, escort-out), `bangJudge` (nearest-beat-boundary
timing), `escalation` (5x3 cut-off matrix), `patDown` (pocket layout + scoring).
- Phaser layer: `FloorView` (baked tile texture, silhouette/detail split with a
cone GeometryMask, neon glow pools, UV stamp dots) + three overlays
(`CutOffOverlay`, `StallOverlay`, `PatDownOverlay`).
- All floor dialogue in `src/scenes/floor/strings.ts` (see deviation #1).
**How to see it:** `npm run dev` → press **F** at the Parade → WASD patrol, **TAB** UV,
**E** interact, **SPACE** bang, **ESC** cancel, **R** reseed. Walk into the dark until
the torch finds someone. In Claude's preview pane drive frames per CLAUDE.md.
**Tests:** 169 passing / 0 failing (49 pre-existing + 120 new floor tests).
**Verified in-browser, not just green:** cut-off (correct rung ejects, one rung too
soft on a maggot lets them stagger free), escort walks to the exit and logs an
ejection, stall busts on 3 on-beat bangs and *never* busts on 8 off-beat ones,
pat-down bins an item (+2 vibe, right flavour line) and leaks it on timeout (-1.5),
UV shows the stamp glow and marches an unstamped patron out with a Kayden radio line.
**Broke / known-wonky:**
- **Two bugs the test suite could not catch, both found by running it and both fixed:**
1. *Ambient meter collapse.* The bump penalty had a per-agent cooldown but no
sim-wide bound, so leak rate scaled with crowd size — an unattended venue
pinned vibe to 0 and aggro to 100 by ~1AM before the player touched anything.
Added a sim-wide gate (`BUMP_GATE_MS`); an unattended night now drifts
50→27 vibe / 0→34 aggro. Regression test added.
2. *Frozen beat behind modals.* `beat.update()` sat below the overlay early-return
in the scene loop, so opening the stall minigame stopped the beat clock and the
rhythm game could never receive a tick. Clock + beat now run regardless of overlays.
- Dwell times raised (2.5-9s → 7-22s): at the old values ~68% of the crowd was
permanently in transit and the bar/dance floor read as empty.
- Tile palette + darkness alpha retuned; at the original values the venue
multiplied down to near-black and you could not tell a dance floor from a wall.
- Light2D dropped in `FloorView` (documented in-file) — the pipeline swap fights the
cone GeometryMask, and the mask is the actual gameplay. The look is carried by a
darkness sheet + additive cone + neon glow pools instead.
- `GeometryMask` on a container is a WebGL-first path; fine under `Phaser.AUTO`,
would misbehave if anything ever forced the Canvas renderer.
- The 16x16 top-down doll reads as a coloured ring from above (dark hair over
outer-layer shoulders). Correct per Phase 0's `dollPlan`, just noting it.
- Stall interiors are deliberately sealed (`stall` walkable, `stallDoor` not), so
patrons are moved in/out via `StallDef.inside`/`.door` rather than pathed.
**Contract change requests:**
1. **`incident:log` event.** `core` exposes no incident API and `EventMap` is frozen,
so `FloorDemoScene` appends to `NightState.incidents` directly (marked
`// TODO(contract):`). Three lanes writing that array directly is exactly the
shared-mutable-state fight the bus discipline exists to prevent, and Phase 3's
inspector/audit needs a single writer. Requesting
`'incident:log': IncidentRecord` plus a handler in `core/meters.ts`.
2. **Aggro has no decay.** `Meters` only ratchets aggro upward, so it is a one-way
trip to 100 over a long night. Ambient pressure is now bounded (above), but a slow
downward drift on a quiet floor would make the meter a *state* rather than a
countdown. Flagging as a design/contract question, not patching it unilaterally.
**Questions for reviewer:**
1. **Floor strings placement.** CONTRACTS.md §7 says Aussie dialogue lives in
`src/data/strings/`, but `data/` is LANE-0's and lanes commit only in their own
dirs. Parked in `src/scenes/floor/strings.ts`. Move it to
`src/data/strings/floor.ts` on your word — it is a pure file move.
2. **`.gitignore` gap (affects every lane using a worktree).** It lists
`node_modules/` with a trailing slash, which does not match a *symlinked*
`node_modules` — worktrees that symlink it show `?? node_modules` as untracked.
I staged paths explicitly, but a lane running `git add -A` would commit a symlink
pointing at an absolute path on John's machine. One-char fix (drop the slash);
left alone since `.gitignore` is not in my lane.
3. Escalation numbers (clean +4 vibe, scene -3/+5 per rung of overreach) are
deliberately small so aggression never feels rewarded. Confirm the balance intent
before LANE-DOOR mirrors it at the rope.
**Next session should:**
- Phase 2 integration: replace the self-populating spawner with the real admitted-patron
list from LANE-DOOR (the scene already only ever consumes `Patron[]`).
- Swap `StubBeatClock` for LANE-JUICE's `TechnoEngine``StallOverlay` takes
`beatIntervalMs` in its constructor and reads `beat:tick` off the bus, so it should
be a one-line change.
- Fight-brewing infraction (design §3.2) is specced but NOT built — the only v0.2
floor mechanic still missing.
- Replace `HudStub` with LANE-JUICE's real meter HUD.
### SESSION — LANE-JUICE — 2026-07-19 18:55
**Branch/commits:** lane/juice @ c262ac8..2eb3c94
**Done:**
- `audio/TechnoEngine.ts` — synthesized 128 BPM track (kick / off-beat hats /
2-bar 16th bassline / 8-bar pad), look-ahead scheduler (25ms timer, 100ms
horizon) as the beat authority. Emits `beat:tick {beatIndex, audioTimeMs}`
with the **scheduled** time, plus `beat:bar` on downbeats. Drop-in replacement
for `core/StubBeatClock`. Music → lowpass → master; SFX bypass the filter.
- `audio/Sfx.ts` — 10 procedural one-shots + `startRain`/`stopRain`. One shared
noise buffer; voices disconnect on `ended`.
- `audio/scheduling.ts` + `audio/filterCurve.ts` — pure, node-testable beat grid
and cutoff-ramp math (the WebAudio classes are thin wrappers over these).
- `ui/``Stamp`, `Phone`, `DialogueBox`, `Clicker`, `MeterHud`, plus
`style.ts` (chunky widget kit) and `typo.ts` (deterministic drunk typos).
- `ui/JuiceDemoScene.ts` — exercises every widget and SFX; log-mapped cutoff
slider, DOOR/FLOOR toggle routed through the bus, beat pad that flashes when
`ctx.currentTime` reaches the scheduled time (not on event receipt).
**How to see it:** `npm run dev` → http://localhost:5199/#juice (or press **J**;
**P** returns to the parade). Click once to unlock audio, then: drag MUFFLE to
sweep the filter by hand, hit STEP INSIDE for the door→floor sweep, mash the
SOUNDS column, stamp someone with NOT TONIGHT.
**Tests:** 111 passing / 0 failing (was 49 at Phase 0; +62 this lane).
**Verified by ear:** NOTHING. No audio output available in this environment.
Everything below was logic-checked in a real browser via the exposed engine:
- door→floor sweep ramps 250Hz→18kHz over 600ms, monotonic, exponential
(t=0.5 lands ~2.1kHz vs the ~9.1kHz a linear ramp gives).
- under a genuinely hidden/throttled tab: 52 consecutive beats, **zero**
scheduled in the past, 7598ms lead, max 1 beat scheduled per timer wake.
**John/Fable still need to ear-check:** does the kick read as a kick, is the
bassline musical, is `typeTick` quiet enough at the real mix level (it fires per
character), and does the sweep *feel* like a door rather than a filter.
**Broke / known-wonky:**
- `src/main.ts` is edited (2 lines + a ~12-line demo route). **Outside LANE-JUICE
ownership** — see request 2 below. Without it the demo scene is unreachable
and Vite tree-shakes it out entirely.
- `TechnoEngine` has no test file — it needs a real `AudioContext` and the suite
runs in node. Its scheduling/filter math is tested via the pure modules, but
the `resync` arithmetic is only reachable through the class. Extracting
`resyncBeat(grid, nextBeat, nowMs)` into `scheduling.ts` would close that gap.
- `Phone` thread truncates an over-tall newest message rather than scrolling.
- `Stamp` decals are caller-owned now; the demo caps them at 5. The door scene
must destroy them (or pass `parent`) or they accumulate over a night.
**Contract change requests:**
1. **Add `door:phoneTheatre: { durationMs: number }` to `EventMap`.** Emitted by
`ui/Phone` when the player closes the phone, carrying how long they stared at
it while a queue waited — the door scene rewards it with hype. It is a door
event, not an audio one, so it is outside my namespace. **I did not edit
`data/types.ts`.** `ui/juiceEvents.ts` declaration-merges it into `EventMap`
instead: fully typed, zero edits to the frozen file, and folding it into the
real contract later is a copy-paste. Same file also adds `audio:unlocked` and
`beat:bar`, which ARE mine per LANE_JUICE.md but are declared together so the
whole juice surface reads in one place.
2. **Sign-off requested on the `src/main.ts` edit** (scene registration + J/#juice
route, marked `// TODO(integration)`). Happy to revert it if you would rather
wire the demo from the Phase 2 shell yourself.
**Questions for reviewer:**
1. `TechnoEngine.unlock()` anchors beat 0 at `currentTime + 60ms` so beat 0 is as
honest as every other beat. If a consumer assumes beat 0 lands exactly at
`audio:unlocked.atMs`, that is a 60ms discrepancy — is that acceptable, or
should `atMs` carry the anchored origin instead?
2. `MeterHud` counts heat strikes from `heat:strike` events it receives. On a
mid-run scene swap it would restart from 0. Should it take an initial count in
its options, or read `NightState.heatStrikes.length` at construction?
**Next session should:**
- LANE-DOOR: swap the local stamp for `ui/Stamp` (`// TODO(juice)`), and take
`MeterHud` in place of `HudStub`.
- 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.