The roster board: role × venue level select — six shifts, wages on the card, the night opens wherever the shift stands you

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-21 03:10:17 +10:00
parent e008fb442f
commit ec9afaff0f
9 changed files with 309 additions and 13 deletions

View File

@ -1586,3 +1586,40 @@ hint renders at the door ✓ · screenshot.
### Shelf ### Shelf
§6 remaining: roster board (role × venue shift picker — every role verb now §6 remaining: roster board (role × venue shift picker — every role verb now
exists), patron sprite sheets (own session). exists), patron sprite sheets (own session).
## SESSION — FABLE-SOLO-17 · 2026-07-21
**Branch:** main (solo, Fable, ultra) · **Gate:** lint ✓ build ✓ test ✓ (729 tests, 49 files)
**Deployed:** https://monsterrobot.games/not-tonight/ (rsync, curl 200)
### The roster board (design §6 — role × venue = level select, first cut)
- **`data/roster.ts`** (pure, 6 tests): six RoleDefs — glassie $120 / bartender
$160 / doorgirl $180 / bouncer $190 / dj $220 / hos $260 — each pinned to
venues per the ladder (Royal: glassie/bar/door · Voltage: door/bouncer ·
Elevate: hos · ROOM: dj/hos). A role = startLoc (door|floor) + optional
post (taps|decks) + wage + blurb. `defaultRoleFor(venue)` covers dev routes.
- **`RosterScene`** ('Roster', now scene[0]/boot): corkboard, pinned tilted
cards, wage + blurb, click → `Night {role}`. Shows venue/night/seed/week
from the save (display only — NightScene stays the authority).
- **NightScene**: init takes `role`; `ctx.role`; opening `applyLocation`
honours `role.startLoc`; log carries role name + wage.
- **FloorDemoScene**: `ctx.role.post` walks you straight onto the taps/decks
600ms after the world is up (the apron/decks handover toast opens the night).
- **NightSummary**: subtitle shows `shift: bartender $160`; `nightCash()` now
pays the ROSTER wage (was hardcoded 180) + the existing hype/admit/strike
maths. All five next-night seams route through the Roster, so every retry,
promotion and fresh run passes the board.
- **main.ts**: Roster boots; hash-route guard waits on Roster; `#night`/N
still skips the board (default role = door/hos by venue).
### Verified in pane
Fresh boot → Royal board (3 cards, wages, blurbs, week 1/4) ✓ · click
BARTENDER → night opens on the floor, barMode auto-entered at the taps
(player 760,56), log role/wage BARTENDER/$160 ✓ · screenshots. NOTE: the
Door/Floor launch waits on the prop-image loader — in the frozen pane you
must yield between exec calls before the child scenes exist.
### Design-§6 status: COMPLETE (first cuts)
Role ladder ✓ roster board ✓ venues 14 ✓ 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).

89
src/data/roster.ts Normal file
View File

@ -0,0 +1,89 @@
// The roster board (design §6): role × venue = level select. A role is a lens
// + verb-set over the ONE simulation — picking a shift picks where you stand
// when the doors open, not which game you're playing. Wages are what the
// board says they are; the tips are your business.
import type { VenueDef } from './venues';
export type RoleId = 'glassie' | 'bartender' | 'doorgirl' | 'bouncer' | 'dj' | 'hos';
export interface RoleDef {
id: RoleId;
name: string;
/** What the board admits the shift pays. */
wage: number;
/** The board's own words. */
blurb: string;
/** Where you stand when the doors open. */
startLoc: 'door' | 'floor';
/** Walk straight onto a post: the taps or the decks. */
post?: 'taps' | 'decks';
/** Venue ids where this shift is on the board (venue ladder, design §6). */
venues: readonly string[];
}
export const ROLES: readonly RoleDef[] = [
{
id: 'glassie',
name: 'GLASSIE',
wage: 120,
blurb: 'zero authority. all glasses. you will SEE things',
startLoc: 'floor',
venues: ['theRoyal'],
},
{
id: 'bartender',
name: 'BARTENDER',
wage: 160,
blurb: 'the taps, the tabs, the RSA line. nobody cooked gets a pour',
startLoc: 'floor',
post: 'taps',
venues: ['theRoyal', 'voltage'],
},
{
id: 'doorgirl',
name: 'DOOR',
wage: 180,
blurb: 'the rope, the IDs, the whole street watching you decide',
startLoc: 'door',
venues: ['theRoyal', 'voltage'],
},
{
id: 'bouncer',
name: 'BOUNCER',
wage: 190,
blurb: 'the floor, the stalls, the yard. walk it like you mean it',
startLoc: 'floor',
venues: ['voltage'],
},
{
id: 'dj',
name: 'DJ',
wage: 220,
blurb: 'hold the decks. the room is a meter and youre the needle',
startLoc: 'floor',
post: 'decks',
venues: ['room'],
},
{
id: 'hos',
name: 'HEAD OF SECURITY',
wage: 260,
blurb: 'door, floor, radio. everything, at once, all night',
startLoc: 'door',
venues: ['elevate', 'room'],
},
];
export function rolesFor(venueId: string): RoleDef[] {
return ROLES.filter((r) => r.venues.includes(venueId));
}
export function roleById(id: string | undefined): RoleDef | undefined {
return ROLES.find((r) => r.id === id);
}
/** Booting a night without the board (dev routes): the sensible default. */
export function defaultRoleFor(venue: VenueDef): RoleDef {
return rolesFor(venue.id).find((r) => r.startLoc === 'door') ?? ROLES.find((r) => r.id === 'hos')!;
}

View File

@ -6,6 +6,7 @@ import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene'; import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { IncidentReportScene } from './scenes/door/IncidentReportScene'; import { IncidentReportScene } from './scenes/door/IncidentReportScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene'; import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { RosterScene } from './scenes/shared/RosterScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene'; import { JuiceDemoScene } from './ui/JuiceDemoScene';
import { MixDeskScene } from './ui/MixDeskScene'; import { MixDeskScene } from './ui/MixDeskScene';
@ -25,7 +26,10 @@ const game = new Phaser.Game({
}, },
// FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene // FloorDemoScene registers twice: 'Floor' is the night-mode instance NightScene
// launches with a NightContext; 'FloorDemo' stays the standalone demo (key F). // launches with a NightContext; 'FloorDemo' stays the standalone demo (key F).
// RosterScene is scene[0] and auto-starts: the week begins at the board
// (design §6 — role × venue = level select). Dev route N skips it.
scene: [ scene: [
RosterScene,
NightScene, NightScene,
DoorScene, DoorScene,
NightSummaryScene, NightSummaryScene,
@ -50,18 +54,18 @@ const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'J
type RouteKey = keyof typeof ROUTES; type RouteKey = keyof typeof ROUTES;
const showScene = (target: (typeof ROUTES)[RouteKey]): void => { const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) { for (const key of ['Roster', 'Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key); if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
} }
if (!game.scene.isActive(target)) game.scene.start(target); if (!game.scene.isActive(target)) game.scene.start(target);
}; };
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is // RosterScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other. // actually up before swapping, or both scenes end up running on top of each other.
const hash = window.location.hash.slice(1) as RouteKey; const hash = window.location.hash.slice(1) as RouteKey;
if (hash in ROUTES && ROUTES[hash] !== 'Night') { if (hash in ROUTES) {
const route = (): void => { const route = (): void => {
if (!game.scene.isActive('Night')) return; if (!game.scene.isActive('Roster')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route); game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene(ROUTES[hash]); showScene(ROUTES[hash]);
}; };

View File

@ -14,6 +14,7 @@ import type { DeferredHit } from '../../rules/doorTypes';
import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types'; import type { GameState, HeatStrike, NightState, Patron, Verdict, VerdictOutcome } from '../../data/types';
import { nextDazza } from './dazzaSchedule'; import { nextDazza } from './dazzaSchedule';
import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues'; import { TOTAL_VENUES, nightDateFor, venueByIndex, type VenueDef } from '../../data/venues';
import { defaultRoleFor, roleById, type RoleDef } from '../../data/roster';
import { buildRegularCast } from '../../patrons/regularCast'; import { buildRegularCast } from '../../patrons/regularCast';
import { recordDenialBy } from '../../patrons/memory'; import { recordDenialBy } from '../../patrons/memory';
import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector'; import { observe, startWatch, type InspectorWatch, type VenueBreaches } from '../../rules/inspector';
@ -62,6 +63,9 @@ export interface NightLog {
heatStrikes: HeatStrike[]; heatStrikes: HeatStrike[];
endReason: NightEndReason; endReason: NightEndReason;
seed: number; seed: number;
/** The shift taken off the roster board (design §6) and what it pays. */
role: string;
wage: number;
} }
/** Run-level result attached to the summary. */ /** Run-level result attached to the summary. */
@ -98,6 +102,8 @@ export interface NightContext {
seenEncounters: readonly string[]; seenEncounters: readonly string[];
/** This week's venue — capacity/pressure knobs (venue ladder, design §6). */ /** This week's venue — capacity/pressure knobs (venue ladder, design §6). */
venue: VenueDef; venue: VenueDef;
/** Tonight's shift off the roster board — a lens, not a different game. */
role: RoleDef;
/** The run's stable regulars and their grudges (design §4.1). */ /** The run's stable regulars and their grudges (design §4.1). */
regulars: { cast: readonly Patron[]; memory: Readonly<Record<string, import('../../data/types').RegularMemory>> }; 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. */ /** Door/Floor ask to move the player; the night decides and flips the scenes. */
@ -126,6 +132,7 @@ export class NightScene extends Phaser.Scene {
private seed = 4207; private seed = 4207;
private nightIndex = 0; private nightIndex = 0;
private venue!: VenueDef; private venue!: VenueDef;
private role!: RoleDef;
private ended = false; private ended = false;
private deferred: DeferredHit[] = []; private deferred: DeferredHit[] = [];
private readonly firedDazza = new Set<string>(); private readonly firedDazza = new Set<string>();
@ -148,7 +155,7 @@ export class NightScene extends Phaser.Scene {
super('Night'); super('Night');
} }
init(data: { seed?: number; nightIndex?: number; freshRun?: boolean }): void { init(data: { seed?: number; nightIndex?: number; freshRun?: boolean; role?: string }): void {
if (data.freshRun) clearSave(); if (data.freshRun) clearSave();
this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207); this.run = loadGame(typeof data.seed === 'number' ? data.seed : 4207);
if (typeof data.seed === 'number' && this.run.seed !== data.seed) { if (typeof data.seed === 'number' && this.run.seed !== data.seed) {
@ -157,6 +164,7 @@ export class NightScene extends Phaser.Scene {
this.nightIndex = data.nightIndex ?? this.run.nightIndex; this.nightIndex = data.nightIndex ?? this.run.nightIndex;
if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0; if (this.nightIndex >= TOTAL_NIGHTS) this.nightIndex = 0;
this.venue = venueByIndex(this.run.venueIndex); this.venue = venueByIndex(this.run.venueIndex);
this.role = roleById(data.role) ?? defaultRoleFor(this.venue);
// Each night is its own deterministic world derived from the run seed — // Each night is its own deterministic world derived from the run seed —
// and each venue-week its own block of them. // and each venue-week its own block of them.
this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101; this.seed = this.run.seed + this.run.venueIndex * 1009 + this.nightIndex * 101;
@ -214,6 +222,8 @@ export class NightScene extends Phaser.Scene {
heatStrikes: [], heatStrikes: [],
endReason: 'clock', endReason: 'clock',
seed: this.seed, seed: this.seed,
role: this.role.name,
wage: this.role.wage,
}; };
this.offs.push( this.offs.push(
@ -262,6 +272,7 @@ export class NightScene extends Phaser.Scene {
seed: this.seed, seed: this.seed,
nightDate: this.clock.nightDate, nightDate: this.clock.nightDate,
sfx: this.sfx, sfx: this.sfx,
role: this.role,
beatIntervalMs: 60_000 / 128, beatIntervalMs: 60_000 / 128,
scheduleDeferred: (hits) => this.deferred.push(...hits), scheduleDeferred: (hits) => this.deferred.push(...hits),
reportQueue: (length) => { reportQueue: (length) => {
@ -292,7 +303,8 @@ export class NightScene extends Phaser.Scene {
this.scene.launch('Floor', { night: this.ctx }); this.scene.launch('Floor', { night: this.ctx });
// Door and Floor BOTH run all night; the flags below decide which one // Door and Floor BOTH run all night; the flags below decide which one
// the player is inhabiting. Floor starts hidden. // the player is inhabiting. Floor starts hidden.
this.time.delayedCall(0, () => this.applyLocation('door')); // The roster decides where you stand when the doors open (design §6).
this.time.delayedCall(0, () => this.applyLocation(this.role.startLoc));
}; };
if (missing.length === 0) return launch(); if (missing.length === 0) return launch();
for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`); for (const k of missing) this.load.image(`gen:prop:${k}`, `props/${k}.png`);

View File

@ -15,7 +15,7 @@ const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
export function nightCash(log: NightLog, state: NightState): number { export function nightCash(log: NightLog, state: NightState): number {
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly — // Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
// Dazza pays for the room feeling busy, not for you being right. // Dazza pays for the room feeling busy, not for you being right.
const wage = 180; const wage = log.wage;
const hypeBonus = Math.round((log.hypePeak - 1) * 90); const hypeBonus = Math.round((log.hypePeak - 1) * 90);
const doorBonus = log.admitted * 2; const doorBonus = log.admitted * 2;
const strikes = state.heatStrikes.length * 60; const strikes = state.heatStrikes.length * 60;
@ -64,7 +64,7 @@ export class NightSummaryScene extends Phaser.Scene {
46, 46,
failed failed
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early` ? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''}`, : `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`,
{ {
fontFamily: MONO, fontFamily: MONO,
fontSize: '8px', fontSize: '8px',
@ -106,19 +106,19 @@ export class NightSummaryScene extends Phaser.Scene {
if (!r) { if (!r) {
return { return {
prompt: SUMMARY_UI.replay, prompt: SUMMARY_UI.replay,
next: () => this.scene.start('Night', { seed: this.log.seed + 1 }), next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }),
}; };
} }
if (r.runOver) { if (r.runOver) {
return { return {
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you', prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 13 }), next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }),
}; };
} }
if (r.seasonDone) { if (r.seasonDone) {
return { return {
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run', prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
next: () => this.scene.start('Night', { freshRun: true, seed: this.log.seed + 7 }), next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }),
}; };
} }
if (r.weekDone && r.promotedTo) { if (r.weekDone && r.promotedTo) {
@ -126,7 +126,7 @@ export class NightSummaryScene extends Phaser.Scene {
return { return {
// Dazza's pitch sells the next rung — the ladder is the reward. // Dazza's pitch sells the next rung — the ladder is the reward.
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`, prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
next: () => this.scene.start('Night', {}), next: () => this.scene.start('Roster', {}),
}; };
} }
const failedNight = this.log.endReason !== 'clock'; const failedNight = this.log.endReason !== 'clock';
@ -135,7 +135,7 @@ export class NightSummaryScene extends Phaser.Scene {
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night'); : (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
return { return {
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`, prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
next: () => this.scene.start('Night', {}), next: () => this.scene.start('Roster', {}),
}; };
} }

View File

@ -272,6 +272,13 @@ export class FloorDemoScene extends Phaser.Scene {
if (ctx.venue.radioCommand) { if (ctx.venue.radioCommand) {
ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify); ctx.floorOrder = (kind, notify) => this.execStaffOrder(kind, notify);
} }
// The roster put you on a post: walk straight onto it once the world is up.
if (ctx.role.post) {
this.time.delayedCall(600, () => {
if (ctx.role.post === 'taps' && !this.barMode) this.toggleTaps();
else if (ctx.role.post === 'decks' && !this.djMode) this.toggleDecks();
});
}
this.bus = ctx.bus; this.bus = ctx.bus;
this.rng = ctx.rng; this.rng = ctx.rng;
this.clock = ctx.clock; this.clock = ctx.clock;

View File

@ -0,0 +1,99 @@
import Phaser from 'phaser';
import { freshGameState, loadGame } from '../../core/save';
import { TOTAL_NIGHTS } from '../door/NightScene';
import { venueByIndex } from '../../data/venues';
import { rolesFor } from '../../data/roster';
// The roster board (design §6): role × venue = level select. A corkboard in
// the back office; tonight's shifts are pinned to it; you take one. The night
// itself is the same simulation whichever card you pull — the card is where
// you STAND when the doors open, and what the board admits it pays.
const W = 640;
const H = 360;
const MONO = 'monospace';
const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
const CARD_W = 168;
const CARD_H = 150;
interface PassThrough {
seed?: number;
nightIndex?: number;
freshRun?: boolean;
}
export class RosterScene extends Phaser.Scene {
private carried: PassThrough = {};
constructor() {
super('Roster');
}
init(data: PassThrough): void {
this.carried = data ?? {};
}
create(): void {
// Display only — NightScene remains the authority on run state (it will
// clearSave on freshRun itself; we just show the board that run implies).
const seed = this.carried.seed ?? 4207;
const run = this.carried.freshRun ? freshGameState(seed) : loadGame(seed);
let nightIndex = this.carried.nightIndex ?? run.nightIndex;
if (nightIndex >= TOTAL_NIGHTS) nightIndex = 0;
const venue = venueByIndex(run.venueIndex);
const roles = rolesFor(venue.id);
// the back office: unpainted, honest
this.add.rectangle(W / 2, H / 2, W, H, 0x14100c);
this.add.rectangle(W / 2, H / 2 - 8, 560, 280, 0x3a2a18).setStrokeStyle(3, 0x241a10);
this.add
.text(W / 2, 42, `${venue.name}${NIGHT_NAMES[nightIndex] ?? ''}`, {
fontFamily: MONO, fontSize: '16px', color: '#f0e4d0',
})
.setOrigin(0.5);
this.add
.text(W / 2, 62, 'THE ROSTER · take a shift', {
fontFamily: MONO, fontSize: '8px', color: '#a89878',
})
.setOrigin(0.5);
const totalW = roles.length * CARD_W + (roles.length - 1) * 16;
const x0 = (W - totalW) / 2 + CARD_W / 2;
roles.forEach((role, i) => {
const x = x0 + i * (CARD_W + 16);
const y = H / 2 + 22;
const tilt = ((i * 7) % 5) - 2; // pinned by someone in a hurry
const card = this.add.container(x, y).setAngle(tilt);
const bg = this.add.rectangle(0, 0, CARD_W, CARD_H, 0xe8dcc0).setStrokeStyle(1, 0x9a8a68);
card.add(bg);
card.add(this.add.circle(0, -CARD_H / 2 + 7, 3, 0xa03028)); // the pin
card.add(
this.add.text(0, -CARD_H / 2 + 24, role.name, {
fontFamily: MONO, fontSize: '11px', color: '#241a10', align: 'center', wordWrap: { width: CARD_W - 16 },
}).setOrigin(0.5, 0),
);
card.add(
this.add.text(0, -CARD_H / 2 + 58, `$${role.wage} the night`, {
fontFamily: MONO, fontSize: '9px', color: '#5a4a30',
}).setOrigin(0.5, 0),
);
card.add(
this.add.text(0, -CARD_H / 2 + 80, role.blurb, {
fontFamily: MONO, fontSize: '7px', color: '#40342a', align: 'center', wordWrap: { width: CARD_W - 20 },
}).setOrigin(0.5, 0),
);
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => card.setScale(1.04));
bg.on('pointerout', () => card.setScale(1));
bg.on('pointerdown', () => this.scene.start('Night', { ...this.carried, role: role.id }));
});
this.add
.text(W / 2, H - 20, `seed ${run.seed} · week ${run.venueIndex + 1}/4`, {
fontFamily: MONO, fontSize: '7px', color: '#6a5a44',
})
.setOrigin(0.5);
}
}

View File

@ -22,6 +22,8 @@ const log = (over: Partial<NightLog> = {}): NightLog => ({
heatStrikes: [], heatStrikes: [],
endReason: 'clock', endReason: 'clock',
seed: 1, seed: 1,
role: 'DOOR',
wage: 180,
...over, ...over,
}); });

46
tests/roster.test.ts Normal file
View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { ROLES, defaultRoleFor, roleById, rolesFor } from '../src/data/roster';
import { VENUES } from '../src/data/venues';
describe('the roster board', () => {
it('every venue has at least one shift pinned to it', () => {
for (const v of VENUES) {
expect(rolesFor(v.id).length, `${v.id} board is empty`).toBeGreaterThan(0);
}
});
it('every role is pinned to a real venue', () => {
const ids = new Set(VENUES.map((v) => v.id));
for (const r of ROLES) {
expect(r.venues.length).toBeGreaterThan(0);
for (const vid of r.venues) expect(ids.has(vid), `${r.id} references '${vid}'`).toBe(true);
}
});
it('the ladder pays upward — the boss venues pay the most', () => {
for (const r of ROLES) expect(r.wage).toBeGreaterThan(0);
const hos = roleById('hos')!;
for (const r of ROLES) expect(hos.wage).toBeGreaterThanOrEqual(r.wage);
});
it('posts belong to floor starts — you cannot hold the taps from the door', () => {
for (const r of ROLES) {
if (r.post) expect(r.startLoc).toBe('floor');
}
});
it('defaultRoleFor always resolves (dev routes skip the board)', () => {
for (const v of VENUES) {
const d = defaultRoleFor(v);
expect(d).toBeDefined();
// the sensible default mans the door, or is the HoS at boss venues
expect(d.startLoc).toBe('door');
}
});
it('roleById round-trips and rejects nonsense', () => {
for (const r of ROLES) expect(roleById(r.id)?.id).toBe(r.id);
expect(roleById('mascot')).toBeUndefined();
expect(roleById(undefined)).toBeUndefined();
});
});