Radio command: the Head of Security verb — Bump sorts the mess, Shan waters the worst, Kayden holds the rope (Elevate+, competence not guaranteed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 02:49:14 +10:00
parent 5334a30150
commit e008fb442f
8 changed files with 316 additions and 6 deletions

View File

@ -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, 37s 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).

View File

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

View File

@ -22,6 +22,8 @@ export interface VenueDef {
* identity. Omitted keys keep their default weight.
*/
archetypeWeights?: Partial<Record<import('./types').Archetype, number>>;
/** 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

46
src/rules/staff.ts Normal file
View File

@ -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<Record<FloorOrderKind, number>> = {
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]);
}

View File

@ -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<string>();
/** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
private readonly seenCards = new Map<string, string>();
@ -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,12 +1047,17 @@ 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) {
// "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();
}
}
}
this.tickKebab();
this.tickKebabRun(deltaMs);

View File

@ -102,6 +102,14 @@ export interface NightContext {
regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> };
/** 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 {

View File

@ -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<FloorOrderKind, number>();
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;

55
tests/staff.test.ts Normal file
View File

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