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:
parent
5334a30150
commit
e008fb442f
@ -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).
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
46
src/rules/staff.ts
Normal 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]);
|
||||
}
|
||||
@ -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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
55
tests/staff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user