From e008fb442ff7d06b88e6caf277f02f5f29f5e8f6 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 21 Jul 2026 02:49:14 +1000 Subject: [PATCH] =?UTF-8?q?Radio=20command:=20the=20Head=20of=20Security?= =?UTF-8?q?=20verb=20=E2=80=94=20Bump=20sorts=20the=20mess,=20Shan=20water?= =?UTF-8?q?s=20the=20worst,=20Kayden=20holds=20the=20rope=20(Elevate+,=20c?= =?UTF-8?q?ompetence=20not=20guaranteed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 34 +++++++++ src/data/strings/floor.ts | 17 +++++ src/data/venues.ts | 5 ++ src/rules/staff.ts | 46 ++++++++++++ src/scenes/door/DoorScene.ts | 48 +++++++++++-- src/scenes/door/NightScene.ts | 8 +++ src/scenes/floor/FloorDemoScene.ts | 109 ++++++++++++++++++++++++++++- tests/staff.test.ts | 55 +++++++++++++++ 8 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 src/rules/staff.ts create mode 100644 tests/staff.test.ts diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 3e8b5ee..b62a248 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1552,3 +1552,37 @@ the door, pool corner dressed on the floor. Screenshots taken. ### Shelf Remaining §6: Head of Security finale arc, roster board. Ambition-tier patron sprite sheets (ASSETS.md §2) still wants its own session. + +## SESSION — FABLE-SOLO-16 · 2026-07-21 + +**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (723 tests, 47 files) +**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200) + +### Radio command — the Head of Security finale verb (design §6, first cut) +"You used to speak into the radio; now you're the voice on the other end." +Elevate + ROOM only (`VenueDef.radioCommand`). + +- **`rules/staff.ts`** (pure, 5 tests): cooldowns, 3–7s order lag (staff cross + a real floor), competence odds — Bump mops-and-signs 65% (else signs it and + finds a phone in the puddle), Shan waters clean 80% (else leads with + "YOU'VE HAD ENOUGH", aggro +2). Kayden holds the rope 60s, 90s cooldown. +- **Cross-scene wiring**: mutable registration fields on NightContext + (`floorOrder`, `kaydenHold`) — NOT bus events; EventMap is frozen contract. + Floor registers its executor at bindNight; Door registers Kayden's hold. + Completion lines travel back via a `notify` callback, so the toast lands in + whichever scene gave the order. +- **Verbs**: door keys 1/2 (Bump: sort the oldest puddle · Shan: water the + worst upright drunk ≥ loose); floor keys 1/2/3 (same + Kayden rope-hold — + he does nothing, magnificently; his ruling loop pauses). Hint lines on both + scenes; all outcomes are `radioOrder` incidents. +- Player racing the order is handled: if the puddle/drunk is gone when the + lag lands, the order dissolves silently. + +### Verified in pane (Elevate save, frame-stepped, direct calls) +Acks/cooldown toasts ✓ · Bump mopped after lag (puddle gone, incident, door +toast) ✓ · Shan watered 0.9→0.76 ✓ · Kayden 0 rulings during a 10s hold ✓ · +hint renders at the door ✓ · screenshot. + +### Shelf +§6 remaining: roster board (role × venue shift picker — every role verb now +exists), patron sprite sheets (own session). diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index e24b4b1..045b9b7 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -128,6 +128,23 @@ export const HELP_TEXT = 'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed'; +/** Radio command (design §6): the staff on the other end of the walkie. */ +export const STAFF_RADIO = { + hint: 'RADIO — 1 bump (mess) · 2 shan (bar) · 3 kayden (rope)', + hintDoor: 'RADIO — 1 bump (mess) · 2 shan (bar)', + bumpAck: '*click* yeah boss. seen it. on it. *click*', + bumpDone: 'bump mopped it and signed it. in that order, worryingly.', + bumpDistracted: 'bump signed it, then found a phone in the puddle. investigation ongoing.', + bumpNothing: '*click* nothing to sort boss. floor is immaculate. u ok? *click*', + shanAck: '*click* shan says say less *click*', + shanDone: 'shan got a water into them without a single negotiation. artist.', + shanBotch: 'shan led with "YOUVE HAD ENOUGH." they had not had enough. now everyone is upset.', + shanNothing: '*click* everyones upright boss. genuinely no idea how *click*', + cooldown: 'the radio crackles back: mid-task. give it a minute.', + kaydenAck: '*click* kayden copies. rope shut. kayden salutes. nobody can see him saluting. *click*', + kaydenCooldown: 'kayden is still holding the rope from last time. white-knuckled. honoured.', +} as const; + /** The smoking-yard joint bust (docs/SCENARIOS.md). Three verbs, none clean. */ export const JOINT_UI = { title: 'SOMETHING SMELLS HERBAL', diff --git a/src/data/venues.ts b/src/data/venues.ts index 69ad89e..f696105 100644 --- a/src/data/venues.ts +++ b/src/data/venues.ts @@ -22,6 +22,8 @@ export interface VenueDef { * identity. Omitted keys keep their default weight. */ archetypeWeights?: Partial>; + /** Head of Security tier (design §6): radio command of NPC staff. */ + radioCommand?: boolean; /** ROOM (design §6): the dress code re-randomises every 15 in-game minutes. */ shuffleDressCode?: boolean; /** ROOM (design §6): rope dialogue renders as lip-read guesses. */ @@ -59,6 +61,8 @@ export const VENUES: readonly VenueDef[] = [ arrivalScale: 1.1, ruleLimit: 8, inspector: true, + // Elevate is the Head of Security tier: the radio starts taking orders. + radioCommand: true, // Rooftop VIP politics: every second person is on a list, knows the owner, // or is filming. Regulars don't come here; they can't get in either. archetypeWeights: { influencer: 18, ownersMate: 7, financeBro: 14, punter: 38, regular: 3 }, @@ -73,6 +77,7 @@ export const VENUES: readonly VenueDef[] = [ // rewriting itself (shuffleDressCode). His other texts still land. ruleLimit: 0, inspector: true, + radioCommand: true, shuffleDressCode: true, lipRead: true, // Underground techno: heads and chancers. Everyone claims VIP; almost diff --git a/src/rules/staff.ts b/src/rules/staff.ts new file mode 100644 index 0000000..dce6e5c --- /dev/null +++ b/src/rules/staff.ts @@ -0,0 +1,46 @@ +// Radio command (design §6, the Head of Security finale): NPC staff work the +// roles you aren't, and the radio is how you reach them. You used to speak +// into the radio; now you're the voice on the other end. Competence varies — +// that IS the staff. Pure numbers here; the scenes own the walkie theatre. + +/** Orders the floor staff take. 'mess' is Bump the glassie, 'water' is Shan. */ +export type FloorOrderKind = 'mess' | 'water'; + +export type OrderAck = 'sent' | 'cooldown' | 'nothing'; + +export const RADIO_COOLDOWN_MS: Readonly> = { + mess: 45_000, + water: 60_000, +}; + +/** Staff cross a real floor — an order lands, then happens. */ +export const ORDER_LAG_MS: readonly [number, number] = [3000, 7000]; + +/** Bump mops AND signs (in that order, worryingly) this often; else he signs + * it and finds a phone in the puddle. */ +export const BUMP_MOP_CHANCE = 0.65; +/** Shan gets the water in without a negotiation this often; else she leads + * with the phrase "you've had enough". */ +export const SHAN_CLEAN_CHANCE = 0.8; +/** A firm two cups. */ +export const SHAN_WATER_STEP = 0.14; +export const SHAN_BOTCH_AGGRO = 2; +export const ORDER_DONE_VIBE = 1; +/** Shan only bothers with loose and worse. */ +export const SHAN_MIN_INTOX = 0.45; + +/** Kayden holds the rope this long, then resumes ruling. Badly. */ +export const KAYDEN_HOLD_MS = 60_000; +export const KAYDEN_HOLD_COOLDOWN_MS = 90_000; + +export function rollBumpMops(rng: { chance(p: number): boolean }): boolean { + return rng.chance(BUMP_MOP_CHANCE); +} + +export function rollShanClean(rng: { chance(p: number): boolean }): boolean { + return rng.chance(SHAN_CLEAN_CHANCE); +} + +export function orderLag(rng: { int(min: number, max: number): number }): number { + return rng.int(ORDER_LAG_MS[0], ORDER_LAG_MS[1]); +} diff --git a/src/scenes/door/DoorScene.ts b/src/scenes/door/DoorScene.ts index c105a80..61fc346 100644 --- a/src/scenes/door/DoorScene.ts +++ b/src/scenes/door/DoorScene.ts @@ -21,6 +21,8 @@ import { RULE_RETRACTIONS, } from '../../data/strings/door'; import { CARPET_TUNING, QueueManager, entranceBonus } from './QueueManager'; +import { KAYDEN_HOLD_COOLDOWN_MS, KAYDEN_HOLD_MS } from '../../rules/staff'; +import { STAFF_RADIO } from '../../data/strings/floor'; import { PatronUpView } from './PatronUpView'; import { IdCardView } from './IdCardView'; import { PhoneWidget } from './PhoneWidget'; @@ -98,6 +100,9 @@ export class DoorScene extends Phaser.Scene { /** Is the player at the rope? When false, Kayden works the door. Badly. */ private present = true; private kaydenAccMs = 0; + /** Radio command: Kayden holds the rope until this elapsedMs, ready again after the other. */ + private kaydenHoldUntilMs = 0; + private kaydenHoldReadyMs = 0; private readonly retiredRules = new Set(); /** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */ private readonly seenCards = new Map(); @@ -146,6 +151,26 @@ export class DoorScene extends Phaser.Scene { this.buildDesk(); this.buildHud(); + // Radio command (design §6, Head of Security tier): from the door you + // order the FLOOR staff; Kayden's hold only matters from the floor, so + // he registers here and answers there. + if (this.night.venue.radioCommand) { + this.night.kaydenHold = () => { + if (this.elapsedMs < this.kaydenHoldReadyMs) return 'cooldown'; + this.kaydenHoldUntilMs = this.elapsedMs + KAYDEN_HOLD_MS; + this.kaydenHoldReadyMs = this.elapsedMs + KAYDEN_HOLD_COOLDOWN_MS; + return 'sent'; + }; + this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => { + if (!this.present || this.sobriety.isOpen) return; + if (ev.key === '1') this.radioFloorOrder('mess'); + else if (ev.key === '2') this.radioFloorOrder('water'); + }); + this.add + .text(6, 246, STAFF_RADIO.hintDoor, { fontFamily: MONO, fontSize: '7px', color: '#6a6a82' }) + .setDepth(30); + } + this.offs = [ this.night.bus.on('clock:tick', ({ clockMin }) => { this.queue.onClockMinute(clockMin); @@ -504,6 +529,16 @@ export class DoorScene extends Phaser.Scene { }); } + /** Door-side radio: order the floor staff without leaving the rope. */ + private radioFloorOrder(kind: 'mess' | 'water'): void { + const ack = this.night.floorOrder?.(kind, (msg) => { + if (this.present) this.showToast(msg); + }); + if (ack === 'sent') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck); + else if (ack === 'cooldown') this.showToast(STAFF_RADIO.cooldown); + else if (ack === 'nothing') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing); + } + // ---- the red carpet ---------------------------------------------------- /** The patron at the rope takes position between the brass posts. */ @@ -1012,10 +1047,15 @@ export class DoorScene extends Phaser.Scene { // his mistakes cost the same vibe/heat yours do. (His stamp accuracy is // NightScene's problem; his verdicts are made here on the real queue.) if (!this.present || this.onKebabRun) { - this.kaydenAccMs += deltaMs; - while (this.kaydenAccMs >= KAYDEN_RULE_MS) { - this.kaydenAccMs -= KAYDEN_RULE_MS; - this.kaydenRule(); + // "Hold the rope" (radio command): Kayden does nothing, magnificently. + if (this.elapsedMs < this.kaydenHoldUntilMs) { + this.kaydenAccMs = 0; + } else { + this.kaydenAccMs += deltaMs; + while (this.kaydenAccMs >= KAYDEN_RULE_MS) { + this.kaydenAccMs -= KAYDEN_RULE_MS; + this.kaydenRule(); + } } } diff --git a/src/scenes/door/NightScene.ts b/src/scenes/door/NightScene.ts index 4c557b0..364a833 100644 --- a/src/scenes/door/NightScene.ts +++ b/src/scenes/door/NightScene.ts @@ -102,6 +102,14 @@ export interface NightContext { regulars: { cast: readonly Patron[]; memory: Readonly> }; /** Door/Floor ask to move the player; the night decides and flips the scenes. */ requestLocation(loc: 'door' | 'floor'): void; + /** + * Radio command (design §6, Head of Security tier). The FLOOR registers its + * staff-order executor here at bind time; the DOOR registers Kayden's hold. + * Registered as mutable fields (not bus events — EventMap is a frozen + * contract) so either scene can call the other without knowing it. + */ + floorOrder?: (kind: import('../../rules/staff').FloorOrderKind, notify: (msg: string) => void) => import('../../rules/staff').OrderAck; + kaydenHold?: () => 'sent' | 'cooldown'; } export class NightScene extends Phaser.Scene { diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 420e2d7..9986422 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -43,13 +43,17 @@ import { type BarCall, type BarOutcome, type Tab, } from './barShift'; import { PourOverlay } from './overlays/PourOverlay'; +import { + ORDER_DONE_VIBE, RADIO_COOLDOWN_MS, SHAN_BOTCH_AGGRO, SHAN_MIN_INTOX, SHAN_WATER_STEP, + orderLag, rollBumpMops, rollShanClean, type FloorOrderKind, type OrderAck, +} from '../../rules/staff'; import { getActiveEngine } from '../../audio/TechnoEngine'; import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie'; import { tileToWorld } from './venueMap'; import { AID_UI, BAR_ORDER_LINES, BAR_RESULTS, BAR_TAB_UI, BAR_UI, DJ_DROP_RESULTS, DJ_REQUESTS, DJ_REQUEST_UI, DJ_TIP_UI, DJ_UI, POUR_UI, - HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, + HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, STAFF_RADIO, PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI, } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; @@ -153,6 +157,8 @@ export class FloorDemoScene extends Phaser.Scene { private djTips = 0; /** NAH'd requesters on their ATM pilgrimage, due back with cash. */ private djReturns: Array<{ scriptId: string; tip: number; dueMs: number }> = []; + /** Radio command: elapsedMs at which each floor order is usable again. */ + private readonly staffReadyAt = new Map(); private djBuild: { armedAtMs: number } | null = null; private lastBeat = { index: 0, atMs: 0 }; private beatMs = 500; @@ -243,7 +249,9 @@ export class FloorDemoScene extends Phaser.Scene { this.promptText = this.fixedText(4, VIEW_H - 24, '#9fe8a0'); this.statusText = this.fixedText(4, VIEW_H - 12, '#556'); this.radioText = this.fixedText(4, 26, '#d8a020'); - this.fixedText(4, 14, '#445').setText(HELP_TEXT); + this.fixedText(4, 14, '#445').setText( + this.nightCtx?.venue.radioCommand ? `${HELP_TEXT} · ${STAFF_RADIO.hint}` : HELP_TEXT, + ); this.keys = this.input.keyboard!.addKeys( 'W,A,S,D,UP,DOWN,LEFT,RIGHT,E,TAB,SPACE,ESC,R', @@ -260,6 +268,10 @@ export class FloorDemoScene extends Phaser.Scene { /** Night mode: adopt the night's world instead of building our own. */ private bindNight(ctx: NightContext): void { this.seed = ctx.seed; + // Radio command (design §6): the door orders floor staff through here. + if (ctx.venue.radioCommand) { + ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify); + } this.bus = ctx.bus; this.rng = ctx.rng; this.clock = ctx.clock; @@ -294,6 +306,7 @@ export class FloorDemoScene extends Phaser.Scene { this.jointDealt.clear(); this.barMode = false; this.barOrderAt.clear(); + this.staffReadyAt.clear(); this.tabs.clear(); this.djTips = 0; this.djReturns = []; @@ -378,6 +391,7 @@ export class FloorDemoScene extends Phaser.Scene { this.jointDealt.clear(); this.barMode = false; this.barOrderAt.clear(); + this.staffReadyAt.clear(); this.tabs.clear(); this.djTips = 0; this.djReturns = []; @@ -418,6 +432,13 @@ export class FloorDemoScene extends Phaser.Scene { return; } if (key === 'R' && !this.nightCtx) return this.resetRun(this.seed + 1); + // Radio command (design §6): hands-free only — the decks and the taps + // already ate their keys above. + if (this.nightCtx?.venue.radioCommand) { + if (key === '1') return this.radioFloor('mess'); + if (key === '2') return this.radioFloor('water'); + if (key === '3') return this.radioKayden(); + } if (key === 'E') return this.interact(); } @@ -1307,6 +1328,90 @@ export class FloorDemoScene extends Phaser.Scene { ); } + // ---- radio command (design §6, Head of Security) ----------------------- + + /** Floor-side radio keys: ack + result toasts land here. */ + private radioFloor(kind: FloorOrderKind): void { + const ack = this.execStaffOrder(kind, (msg) => { + if (this.present) this.toast(msg); + }); + if (ack === 'sent') this.toast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck); + else if (ack === 'cooldown') this.toast(STAFF_RADIO.cooldown); + else this.toast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing); + } + + private radioKayden(): void { + const ack = this.nightCtx?.kaydenHold?.(); + if (ack === 'sent') this.toast(STAFF_RADIO.kaydenAck); + else if (ack === 'cooldown') this.toast(STAFF_RADIO.kaydenCooldown); + } + + /** + * An order goes out over the radio; a staffer crosses a real floor to do it. + * `notify` carries the completion line back to whichever scene asked. + */ + private execStaffOrder(kind: FloorOrderKind, notify: (msg: string) => void): OrderAck { + if (this.elapsedMs < (this.staffReadyAt.get(kind) ?? 0)) return 'cooldown'; + const rs = this.rng.stream('staff'); + + if (kind === 'mess') { + const pud = this.puddles[0]; + if (!pud) return 'nothing'; + this.staffReadyAt.set(kind, this.elapsedMs + RADIO_COOLDOWN_MS.mess); + const targetId = pud.id; + this.time.delayedCall(orderLag(rs), () => { + const p = this.puddles.find((q) => q.id === targetId); + if (!p) return; // the player got there first; bump keeps the credit anyway + if (rollBumpMops(rs)) { + p.gfx.destroy(); + this.puddles = this.puddles.filter((q) => q.id !== targetId); + this.bus.emit('meters:delta', { vibe: ORDER_DONE_VIBE }); + this.logIncident('radioOrder', undefined, 'Bump: puddle mopped and signed, in that order.'); + notify(STAFF_RADIO.bumpDone); + } else if (!p.signed) { + p.signed = true; + if (this.textures.exists('gen:prop:wetFloor')) { + p.gfx.add(this.add.image(0, -8, 'gen:prop:wetFloor').setDisplaySize(14, 18)); + } else { + p.gfx.add(this.add.triangle(0, -8, 0, 12, 6, 0, 12, 12, 0xd8c23a, 0.9)); + } + this.logIncident('radioOrder', undefined, 'Bump: signed the puddle, then got distracted.'); + notify(STAFF_RADIO.bumpDistracted); + } else { + notify(STAFF_RADIO.bumpDistracted); + } + }); + return 'sent'; + } + + // Shan: the worst upright drunk gets a firm two cups. + let worst: Agent | null = null; + for (const a of this.crowd.agents) { + if (a.gone || a.handled || a.activity === 'escorted' || a.activity === 'inStall') continue; + if (a.patron.intoxication < SHAN_MIN_INTOX) continue; + if (!worst || a.patron.intoxication > worst.patron.intoxication) worst = a; + } + if (!worst) return 'nothing'; + this.staffReadyAt.set(kind, this.elapsedMs + RADIO_COOLDOWN_MS.water); + const target = worst; + this.time.delayedCall(orderLag(rs), () => { + if (target.gone || target.handled) return; + if (rollShanClean(rs)) { + target.patron.intoxication = Math.max(0, target.patron.intoxication - SHAN_WATER_STEP); + this.maggotSeen.delete(target.patron.id); + this.cutOffDealt.set(target.patron.id, drunkStage(target.patron.intoxication)); + this.bus.emit('meters:delta', { vibe: ORDER_DONE_VIBE }); + this.logIncident('radioOrder', target.patron.id, 'Shan: water in, no negotiation.'); + notify(STAFF_RADIO.shanDone); + } else { + this.bus.emit('meters:delta', { aggro: SHAN_BOTCH_AGGRO }); + this.logIncident('radioOrder', target.patron.id, 'Shan led with "you\'ve had enough". Scenes.'); + notify(STAFF_RADIO.shanBotch); + } + }); + return 'sent'; + } + /** Build fizzle + booth requests. Only while the player is on the decks. */ private tickDecks(dt: number): void { if (!this.djMode) return; diff --git a/tests/staff.test.ts b/tests/staff.test.ts new file mode 100644 index 0000000..7d5f03e --- /dev/null +++ b/tests/staff.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + BUMP_MOP_CHANCE, + KAYDEN_HOLD_COOLDOWN_MS, + KAYDEN_HOLD_MS, + ORDER_LAG_MS, + RADIO_COOLDOWN_MS, + SHAN_CLEAN_CHANCE, + SHAN_MIN_INTOX, + orderLag, + rollBumpMops, + rollShanClean, +} from '../src/rules/staff'; +import { drunkStage } from '../src/rules/drunk'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { VENUES } from '../src/data/venues'; + +describe('radio command', () => { + it('is a Head of Security-tier verb: Elevate and ROOM only', () => { + expect(VENUES.map((v) => v.radioCommand === true)).toEqual([false, false, true, true]); + }); + + it('staff competence is real but imperfect — that IS the staff', () => { + const rng = new SeededRNG(3).stream('s'); + let bump = 0; + let shan = 0; + for (let i = 0; i < 2000; i++) { + if (rollBumpMops(rng)) bump++; + if (rollShanClean(rng)) shan++; + } + expect(bump / 2000).toBeGreaterThan(BUMP_MOP_CHANCE - 0.05); + expect(bump / 2000).toBeLessThan(BUMP_MOP_CHANCE + 0.05); + expect(shan / 2000).toBeGreaterThan(SHAN_CLEAN_CHANCE - 0.05); + expect(shan / 2000).toBeLessThan(SHAN_CLEAN_CHANCE + 0.05); + }); + + it('orders take a real walk and cool down longer than they take', () => { + const rng = new SeededRNG(4).stream('s'); + for (let i = 0; i < 100; i++) { + const lag = orderLag(rng); + expect(lag).toBeGreaterThanOrEqual(ORDER_LAG_MS[0]); + expect(lag).toBeLessThanOrEqual(ORDER_LAG_MS[1]); + } + expect(RADIO_COOLDOWN_MS.mess).toBeGreaterThan(ORDER_LAG_MS[1]); + expect(RADIO_COOLDOWN_MS.water).toBeGreaterThan(ORDER_LAG_MS[1]); + }); + + it("Shan's floor starts at loose — sober people are not a radio matter", () => { + expect(drunkStage(SHAN_MIN_INTOX)).toBe('loose'); + }); + + it('the rope hold ends before it can be re-ordered', () => { + expect(KAYDEN_HOLD_COOLDOWN_MS).toBeGreaterThan(KAYDEN_HOLD_MS); + }); +});