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:
parent
e008fb442f
commit
ec9afaff0f
@ -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 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).
|
||||||
|
|||||||
89
src/data/roster.ts
Normal file
89
src/data/roster.ts
Normal 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')!;
|
||||||
|
}
|
||||||
12
src/main.ts
12
src/main.ts
@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -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', {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
99
src/scenes/shared/RosterScene.ts
Normal file
99
src/scenes/shared/RosterScene.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
46
tests/roster.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user