The floor score: lights on at close — sweep the notes, the shrapnel, the untouched kebab; the baggie is a choice and the pay line knows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec9afaff0f
commit
d4f802407b
@ -1623,3 +1623,35 @@ must yield between exec calls before the child scenes exist.
|
||||
Role ladder ✓ roster board ✓ venues 1–4 ✓ radio command ✓ DJ/bartender/
|
||||
glassie verbs ✓. Remaining ambition: per-role restricted verb-sets (glassie
|
||||
zero-authority), story beats on roles, patron sprite sheets (own session).
|
||||
|
||||
## SESSION — FABLE-SOLO-18 · 2026-07-21
|
||||
|
||||
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (734 tests, 51 files)
|
||||
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
|
||||
|
||||
### The floor score (John's ask: lights on at close, find what dropped)
|
||||
- **`rules/floorScore.ts`** (pure, 5 tests): `rollDrops(stream, admitted)` —
|
||||
6 + admits/6 drops (cap 14): notes in anxious denominations ($5–$50), booth
|
||||
shrapnel, 2–3 worthless curios with load-bearing labels ("an untouched
|
||||
kebab. UNTOUCHED."), ≤1 baggie (35%), ≤1 phone (40%).
|
||||
- **Sweep mode** (FloorDemoScene): NightScene's `endNight('clock')` now routes
|
||||
through `ctx.beginSweep` before the summary — lights-on wash over the whole
|
||||
map, crowd cleared, drops scattered on walkable tiles (stream `'sweep'`,
|
||||
pulsing glints — green for the baggie). WASD + E collects; prompt names the
|
||||
nearest find; the baggie freezes your feet for a B-bin / SPACE-pocket call
|
||||
(`baggieBinned`/`baggiePocketed` incidents — report bait, the night's over
|
||||
so the ledger is the only place it can bite); the phone goes to lost
|
||||
property. E at the entry walks out; licence-pull and riot ends skip the
|
||||
sweep entirely (nobody's sweeping).
|
||||
- **Pay**: `NightLog.floorCash` → summary row "floor score $N" (gold) and
|
||||
`nightCash()` adds it AFTER the zero-floor clamp — a catastrophic night
|
||||
still pays out the floor. Sweep incidents land in the report form.
|
||||
|
||||
### Verified in pane
|
||||
endNight('clock') → sweep: 6 drops (baggie/2 curios/2 notes/coins) ✓ ·
|
||||
collected $19, baggie pocketed with incident ✓ · walk-out at the entry anchor
|
||||
→ NightSummary with floor score $19 + PAY includes it ✓ · screenshots.
|
||||
|
||||
### Note
|
||||
`entry` anchor is (72,360) — tile (4,22)-ish, NOT the exit tiles at x=1; the
|
||||
sweep walk-out and the night-mode exit both key off `VENUE.anchors.entry[0]`.
|
||||
|
||||
@ -128,6 +128,20 @@ export const HELP_TEXT =
|
||||
'WASD move · TAB uv · E interact · SPACE bang · ESC cancel · R reseed';
|
||||
|
||||
|
||||
/** The floor score: lights on at close, the floor pays out. */
|
||||
export const SWEEP_UI = {
|
||||
open: 'LIGHTS ON. the floor will now tell you what kind of night it was.',
|
||||
pickup: 'E — pick it up',
|
||||
leave: 'E — call it a night',
|
||||
lastOne: "that's the lot. the door is that way.",
|
||||
baggiePrompt: 'zip-lock bag: B bin it · SPACE pocket it',
|
||||
baggieBinned: 'into the amnesty bin. the bin does not thank you. bins never do.',
|
||||
baggiePocketed: 'it goes in your pocket. for the report. obviously. for the report.',
|
||||
phoneFound: 'straight into lost property. MUM DO NOT ANSWER can rest.',
|
||||
/** {cash} replaced at walk-out. */
|
||||
done: 'floor swept. ${cash} better off. the room, finally, is just a room.',
|
||||
} as const;
|
||||
|
||||
/** 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)',
|
||||
|
||||
68
src/rules/floorScore.ts
Normal file
68
src/rules/floorScore.ts
Normal file
@ -0,0 +1,68 @@
|
||||
// The floor score (John's ask, 2026-07-21): the venue closes, the lights come
|
||||
// ON, and the floor tells you what kind of night it was. Money, contraband,
|
||||
// and objects with no explanation. Pure rolls here; the scene owns the sweep.
|
||||
|
||||
import type { RngStream } from '../core/SeededRNG';
|
||||
|
||||
export type DropKind = 'note' | 'coins' | 'baggie' | 'phone' | 'curio';
|
||||
|
||||
export interface Drop {
|
||||
kind: DropKind;
|
||||
/** Dollars. Curios and phones are worth nothing and everything. */
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** How strewn the floor is: a base mess plus one drop per this many admits. */
|
||||
export const DROPS_BASE = 6;
|
||||
export const DROPS_PER_ADMITS = 6;
|
||||
export const DROPS_MAX = 14;
|
||||
|
||||
export const NOTE_VALUES: readonly number[] = [5, 5, 10, 10, 20, 50];
|
||||
/** One baggie, sometimes. The moral beat of the sweep. */
|
||||
export const BAGGIE_CHANCE = 0.35;
|
||||
/** Someone's whole life, at 3%, face down near the booths. */
|
||||
export const PHONE_CHANCE = 0.4;
|
||||
|
||||
export const CURIOS: readonly string[] = [
|
||||
'a single white sneaker. vintage, technically',
|
||||
'one hoop earring, lying there victorious',
|
||||
'an untouched kebab. UNTOUCHED.',
|
||||
'a novelty lighter shaped like a slightly smaller lighter',
|
||||
'seventeen bobby pins in one tight formation',
|
||||
"someone's nan's reading glasses",
|
||||
'a laminated business card. sovereign.',
|
||||
'a wristband from a venue that shut in 2019',
|
||||
];
|
||||
|
||||
export const BAGGIE_LABEL = 'a zip-lock bag of definitely-not-yours';
|
||||
export const PHONE_LABEL = "someone's phone. 3% battery. 41 missed calls from MUM DO NOT ANSWER";
|
||||
|
||||
/**
|
||||
* What the lights find. Deterministic per stream: mostly cash in anxious
|
||||
* denominations, a couple of objects with no explanation, at most one baggie
|
||||
* and one phone.
|
||||
*/
|
||||
export function rollDrops(rng: RngStream, admitted: number): Drop[] {
|
||||
const count = Math.min(DROPS_MAX, DROPS_BASE + Math.floor(admitted / DROPS_PER_ADMITS));
|
||||
const drops: Drop[] = [];
|
||||
|
||||
if (rng.chance(BAGGIE_CHANCE)) drops.push({ kind: 'baggie', value: 0, label: BAGGIE_LABEL });
|
||||
if (rng.chance(PHONE_CHANCE)) drops.push({ kind: 'phone', value: 0, label: PHONE_LABEL });
|
||||
|
||||
const curios = rng.int(2, 3);
|
||||
for (let i = 0; i < curios && drops.length < count; i++) {
|
||||
drops.push({ kind: 'curio', value: 0, label: CURIOS[rng.int(0, CURIOS.length - 1)] ?? CURIOS[0]! });
|
||||
}
|
||||
|
||||
while (drops.length < count) {
|
||||
if (rng.chance(0.3)) {
|
||||
const v = rng.int(2, 9);
|
||||
drops.push({ kind: 'coins', value: v, label: `$${v} in shrapnel from under the booth seats` });
|
||||
} else {
|
||||
const v = NOTE_VALUES[rng.int(0, NOTE_VALUES.length - 1)] ?? 5;
|
||||
drops.push({ kind: 'note', value: v, label: `a folded $${v} — the floor pays better than the till` });
|
||||
}
|
||||
}
|
||||
return drops;
|
||||
}
|
||||
@ -66,6 +66,8 @@ export interface NightLog {
|
||||
/** The shift taken off the roster board (design §6) and what it pays. */
|
||||
role: string;
|
||||
wage: number;
|
||||
/** What the lights-on sweep found in cash. */
|
||||
floorCash: number;
|
||||
}
|
||||
|
||||
/** Run-level result attached to the summary. */
|
||||
@ -116,6 +118,9 @@ export interface NightContext {
|
||||
*/
|
||||
floorOrder?: (kind: import('../../rules/staff').FloorOrderKind, notify: (msg: string) => void) => import('../../rules/staff').OrderAck;
|
||||
kaydenHold?: () => 'sent' | 'cooldown';
|
||||
/** The floor score: lights on at close, sweep what the night dropped.
|
||||
* Registered by the floor; the night calls it before the summary. */
|
||||
beginSweep?: (done: (score: { cash: number }) => void) => void;
|
||||
}
|
||||
|
||||
export class NightScene extends Phaser.Scene {
|
||||
@ -224,6 +229,7 @@ export class NightScene extends Phaser.Scene {
|
||||
seed: this.seed,
|
||||
role: this.role.name,
|
||||
wage: this.role.wage,
|
||||
floorCash: 0,
|
||||
};
|
||||
|
||||
this.offs.push(
|
||||
@ -541,9 +547,22 @@ export class NightScene extends Phaser.Scene {
|
||||
};
|
||||
|
||||
this.time.delayedCall(reason === 'clock' ? 600 : 1400, () => {
|
||||
this.scene.stop('Door');
|
||||
this.scene.stop('Floor');
|
||||
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
|
||||
const proceed = (): void => {
|
||||
this.scene.stop('Door');
|
||||
this.scene.stop('Floor');
|
||||
this.scene.start('NightSummary', { log: this.log, state: this.state, run: runInfo });
|
||||
};
|
||||
// Made it to close: the lights come on and the floor pays out first.
|
||||
// A pulled licence or a dead room skips the sweep — nobody's sweeping.
|
||||
if (reason === 'clock' && this.ctx.beginSweep) {
|
||||
this.applyLocation('floor');
|
||||
this.ctx.beginSweep((score) => {
|
||||
this.log.floorCash = score.cash;
|
||||
proceed();
|
||||
});
|
||||
} else {
|
||||
proceed();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ export function nightCash(log: NightLog, state: NightState): number {
|
||||
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
|
||||
const doorBonus = log.admitted * 2;
|
||||
const strikes = state.heatStrikes.length * 60;
|
||||
return Math.max(0, wage + hypeBonus + doorBonus - strikes);
|
||||
return Math.max(0, wage + hypeBonus + doorBonus - strikes) + log.floorCash;
|
||||
}
|
||||
|
||||
export function signoffFor(vibe: number, strikes: number): string {
|
||||
@ -187,6 +187,7 @@ export class NightSummaryScene extends Phaser.Scene {
|
||||
`${this.state.heatStrikes.length}/3`,
|
||||
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
|
||||
],
|
||||
['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : undefined],
|
||||
];
|
||||
|
||||
rows.forEach(([label, value, colour], i) => {
|
||||
|
||||
@ -47,13 +47,14 @@ 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 { rollDrops, type Drop } from '../../rules/floorScore';
|
||||
import { getActiveEngine } from '../../audio/TechnoEngine';
|
||||
import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie';
|
||||
import { tileToWorld } from './venueMap';
|
||||
import { MAP_H, MAP_W, isWalkable, 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, STAFF_RADIO,
|
||||
HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_STAMP_RADIO, STAFF_RADIO, SWEEP_UI,
|
||||
PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI,
|
||||
} from '../../data/strings/floor';
|
||||
import { MeterHud } from '../../ui/MeterHud';
|
||||
@ -159,6 +160,12 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
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>();
|
||||
/** The floor score: lights on, crowd gone, the sweep. */
|
||||
private sweeping = false;
|
||||
private sweepCash = 0;
|
||||
private sweepDone: ((score: { cash: number }) => void) | null = null;
|
||||
private sweepDrops: Array<{ drop: Drop; x: number; y: number; gfx: Phaser.GameObjects.Container }> = [];
|
||||
private baggiePending: Drop | null = null;
|
||||
private djBuild: { armedAtMs: number } | null = null;
|
||||
private lastBeat = { index: 0, atMs: 0 };
|
||||
private beatMs = 500;
|
||||
@ -272,6 +279,8 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (ctx.venue.radioCommand) {
|
||||
ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify);
|
||||
}
|
||||
// The floor score: the night calls this at close, before the summary.
|
||||
ctx.beginSweep = (done) => this.enterSweep(done);
|
||||
// The roster put you on a post: walk straight onto it once the world is up.
|
||||
if (ctx.role.post) {
|
||||
this.time.delayedCall(600, () => {
|
||||
@ -421,6 +430,16 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (this.patDown.isOpen) return this.patDown.handleKey(key);
|
||||
if (this.choice.isOpen) return this.choice.handleKey(key);
|
||||
|
||||
if (this.sweeping) {
|
||||
if (this.baggiePending) {
|
||||
if (key === 'B') return this.resolveBaggie(false);
|
||||
if (key === 'SPACE') return this.resolveBaggie(true);
|
||||
return; // the bag deserves your full attention
|
||||
}
|
||||
if (key === 'E') return this.sweepInteract();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.djMode) {
|
||||
if (key === 'B') return this.djStartBuild();
|
||||
if (key === 'SPACE') return this.djDrop();
|
||||
@ -471,6 +490,10 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
if (!this.view) return; // world still waiting on the prop manifest
|
||||
const dt = Math.min(deltaMs, 50);
|
||||
|
||||
// Lights on: the sweep replaces the whole night loop. The night is over —
|
||||
// no clock, no crowd, no meters. Just you, the floor, and what it dropped.
|
||||
if (this.sweeping) return this.tickSweep(dt);
|
||||
|
||||
// These run REGARDLESS of overlays. The music doesn't stop because you
|
||||
// opened a cubicle door — and the stall minigame judges against beat:tick,
|
||||
// so freezing the beat clock behind a modal makes it literally unplayable.
|
||||
@ -1335,6 +1358,147 @@ export class FloorDemoScene extends Phaser.Scene {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- the floor score (lights on at close) ------------------------------
|
||||
|
||||
/** The night is over; the lights come on; the floor pays out. */
|
||||
private enterSweep(done: (score: { cash: number }) => void): void {
|
||||
this.sweeping = true;
|
||||
this.sweepDone = done;
|
||||
this.sweepCash = 0;
|
||||
this.baggiePending = null;
|
||||
this.present = true;
|
||||
this.barMode = false;
|
||||
this.djMode = false;
|
||||
this.escorting = null;
|
||||
this.rack = null;
|
||||
this.mop = null;
|
||||
this.uvMode = false;
|
||||
// Everyone out. The crowd sim keeps the bodies; the lights don't.
|
||||
for (const a of this.crowd.agents) a.gone = true;
|
||||
this.view.syncAgents(this.crowd.agents);
|
||||
this.view.setFight(null, 0, 0);
|
||||
this.view.setDowned(null);
|
||||
|
||||
// The lights: a wash over the whole room. Ugly on purpose — closing
|
||||
// lights are never flattering.
|
||||
this.add
|
||||
.rectangle((MAP_W * 16) / 2, (MAP_H * 16) / 2, MAP_W * 16, MAP_H * 16, 0xfff2d8, 0.16)
|
||||
.setDepth(38);
|
||||
|
||||
// What the night dropped, scattered anywhere feet could reach.
|
||||
const rs = this.rng.stream('sweep');
|
||||
const drops = rollDrops(rs, this.crowd.agents.length);
|
||||
this.sweepDrops = drops.map((drop) => {
|
||||
let tx = 40;
|
||||
let ty = 22;
|
||||
for (let tries = 0; tries < 60; tries++) {
|
||||
tx = rs.int(2, MAP_W - 3);
|
||||
ty = rs.int(2, MAP_H - 3);
|
||||
if (isWalkable(VENUE, tx, ty)) break;
|
||||
}
|
||||
const { x, y } = tileToWorld(tx, ty);
|
||||
const gfx = this.add.container(x, y).setDepth(39);
|
||||
gfx.add(this.add.circle(0, 0, 4, 0x000000, 0.25).setScale(1, 0.5));
|
||||
gfx.add(this.add.circle(0, -2, 3, drop.kind === 'baggie' ? 0xa0e0a0 : 0xffe080));
|
||||
this.tweens.add({ targets: gfx, scale: { from: 1, to: 1.25 }, duration: 600, yoyo: true, repeat: -1 });
|
||||
return { drop, x, y, gfx };
|
||||
});
|
||||
|
||||
this.toast(SWEEP_UI.open);
|
||||
this.promptText.setText(SWEEP_UI.open);
|
||||
this.logIncident('lightsOn', undefined, `Close. ${drops.length} things on the floor.`);
|
||||
}
|
||||
|
||||
private tickSweep(dt: number): void {
|
||||
if (!this.baggiePending) {
|
||||
const input = this.readInput();
|
||||
this.player = stepPlayer(this.player, input, dt, VENUE);
|
||||
}
|
||||
this.cameras.main.centerOn(this.player.x, this.player.y);
|
||||
this.view.updateLights(dt);
|
||||
this.view.updatePlayer(this.player.x, this.player.y, this.player.facing);
|
||||
|
||||
// Prompt: the bag first, then the nearest find, then the way out.
|
||||
if (this.baggiePending) {
|
||||
this.promptText.setText(SWEEP_UI.baggiePrompt);
|
||||
return;
|
||||
}
|
||||
const near = this.nearestDrop();
|
||||
if (near) {
|
||||
this.promptText.setText(`${SWEEP_UI.pickup} — ${near.drop.label}`);
|
||||
return;
|
||||
}
|
||||
const entry = VENUE.anchors.entry[0];
|
||||
const atExit =
|
||||
entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE;
|
||||
this.promptText.setText(atExit ? SWEEP_UI.leave : this.sweepDrops.length === 0 ? SWEEP_UI.lastOne : '');
|
||||
}
|
||||
|
||||
private nearestDrop(): (typeof this.sweepDrops)[number] | null {
|
||||
let best: (typeof this.sweepDrops)[number] | null = null;
|
||||
let bestD = INTERACT_RANGE;
|
||||
for (const d of this.sweepDrops) {
|
||||
const dist = Phaser.Math.Distance.Between(this.player.x, this.player.y, d.x, d.y);
|
||||
if (dist < bestD) {
|
||||
bestD = dist;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private sweepInteract(): void {
|
||||
const near = this.nearestDrop();
|
||||
if (near) return this.collectDrop(near);
|
||||
const entry = VENUE.anchors.entry[0];
|
||||
if (entry && Phaser.Math.Distance.Between(this.player.x, this.player.y, entry.x, entry.y) <= INTERACT_RANGE) {
|
||||
this.finishSweep();
|
||||
}
|
||||
}
|
||||
|
||||
private collectDrop(d: (typeof this.sweepDrops)[number]): void {
|
||||
d.gfx.destroy();
|
||||
this.sweepDrops = this.sweepDrops.filter((x) => x !== d);
|
||||
const drop = d.drop;
|
||||
if (drop.kind === 'baggie') {
|
||||
this.baggiePending = drop;
|
||||
this.toast(drop.label);
|
||||
return;
|
||||
}
|
||||
if (drop.kind === 'phone') {
|
||||
this.logIncident('lostProperty', undefined, 'A phone off the floor, into lost property.');
|
||||
this.toast(SWEEP_UI.phoneFound);
|
||||
} else {
|
||||
this.sweepCash += drop.value;
|
||||
this.toast(drop.label);
|
||||
this.nightCtx?.sfx?.play('clickerClunk');
|
||||
}
|
||||
if (this.sweepDrops.length === 0) this.time.delayedCall(1200, () => this.toast(SWEEP_UI.lastOne));
|
||||
}
|
||||
|
||||
private resolveBaggie(pocket: boolean): void {
|
||||
this.baggiePending = null;
|
||||
if (pocket) {
|
||||
this.logIncident('baggiePocketed', undefined, 'Contraband off the floor. Retained by security.');
|
||||
this.toast(SWEEP_UI.baggiePocketed);
|
||||
} else {
|
||||
this.logIncident('baggieBinned', undefined, 'Contraband off the floor, into the amnesty bin.');
|
||||
this.toast(SWEEP_UI.baggieBinned);
|
||||
}
|
||||
}
|
||||
|
||||
private finishSweep(): void {
|
||||
if (!this.sweeping) return;
|
||||
this.sweeping = false;
|
||||
if (this.sweepCash > 0) {
|
||||
this.logIncident('floorScore', undefined, `Swept the floor: $${this.sweepCash} found.`);
|
||||
}
|
||||
this.toast(SWEEP_UI.done.replace('{cash}', String(this.sweepCash)));
|
||||
const cb = this.sweepDone;
|
||||
this.sweepDone = null;
|
||||
this.time.delayedCall(900, () => cb?.({ cash: this.sweepCash }));
|
||||
}
|
||||
|
||||
// ---- radio command (design §6, Head of Security) -----------------------
|
||||
|
||||
/** Floor-side radio keys: ack + result toasts land here. */
|
||||
|
||||
47
tests/floorScore.test.ts
Normal file
47
tests/floorScore.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DROPS_BASE,
|
||||
DROPS_MAX,
|
||||
NOTE_VALUES,
|
||||
rollDrops,
|
||||
} from '../src/rules/floorScore';
|
||||
import { SeededRNG } from '../src/core/SeededRNG';
|
||||
|
||||
describe('the floor score', () => {
|
||||
it('a busier night is a messier floor, up to a limit', () => {
|
||||
const rng = new SeededRNG(1);
|
||||
expect(rollDrops(rng.stream('a'), 0).length).toBe(DROPS_BASE);
|
||||
expect(rollDrops(rng.stream('b'), 24).length).toBe(DROPS_BASE + 4);
|
||||
expect(rollDrops(rng.stream('c'), 500).length).toBe(DROPS_MAX);
|
||||
});
|
||||
|
||||
it('is deterministic per stream — the same night drops the same floor', () => {
|
||||
const a = rollDrops(new SeededRNG(7).stream('sweep'), 40);
|
||||
const b = rollDrops(new SeededRNG(7).stream('sweep'), 40);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('cash is real denominations; curios, phones and baggies are worth $0', () => {
|
||||
for (let seed = 1; seed <= 20; seed++) {
|
||||
for (const d of rollDrops(new SeededRNG(seed).stream('s'), 60)) {
|
||||
if (d.kind === 'note') expect(NOTE_VALUES).toContain(d.value);
|
||||
else if (d.kind === 'coins') expect(d.value).toBeGreaterThan(0);
|
||||
else expect(d.value).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('at most one baggie and one phone per night', () => {
|
||||
for (let seed = 1; seed <= 30; seed++) {
|
||||
const drops = rollDrops(new SeededRNG(seed).stream('s'), 60);
|
||||
expect(drops.filter((d) => d.kind === 'baggie').length).toBeLessThanOrEqual(1);
|
||||
expect(drops.filter((d) => d.kind === 'phone').length).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('every drop has words — the label IS the reward for curios', () => {
|
||||
for (const d of rollDrops(new SeededRNG(11).stream('s'), 30)) {
|
||||
expect(d.label.length).toBeGreaterThan(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -24,6 +24,7 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
|
||||
seed: 1,
|
||||
role: 'DOOR',
|
||||
wage: 180,
|
||||
floorCash: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user