From 195325f8f1b0d445229dc77dd827fbdc2233be0b Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 26 Jul 2026 20:02:54 +1000 Subject: [PATCH] Role permissions: the glassie can see everything and touch nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authority (eject/pat-down/cut-off/busts) and stations (taps/decks) are now per role. Gated verbs become 'radio it in' — reusing the staff competence roll, so ~2 in 3 calls bring somebody over and the rest just hang in the air. Doorgirl keeps floor authority on purpose: that loop is the shipped game. Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 41 ++++++++++++++ src/data/roster.ts | 42 ++++++++++++++ src/data/strings/floor.ts | 22 ++++++++ src/scenes/floor/FloorDemoScene.ts | 88 +++++++++++++++++++++++++++++- tests/roster.test.ts | 60 +++++++++++++++++++- 5 files changed, 249 insertions(+), 4 deletions(-) diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 37b2f14..045eede 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -1949,3 +1949,44 @@ silently never happened. The sim was under-reporting strikes. 3 AM audit added. ### Verified in pane Bartender shift: clean pour hype 1.2917 → 1.3217 (+0.03), overflow → 1.3717 (+0.05), `inspectorWatching()` live. Report form from SOLO-20 re-checked. + +## SESSION — FABLE-SOLO-22 · 2026-07-22 + +**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (770 tests, 50 files) +**Deployed** + verified. + +### Role verb permissions — the roster finally means something +Until now the board changed where you stood and what you were paid, and nothing +else: a GLASSIE had every verb a Head of Security had. Design §6 makes the +glassie's signature tension "ZERO AUTHORITY. You see the Maggot but can only +radio it in — and watch who responds", so that is now literally the game. + +- **`RoleDef.authority`** (enforcement: eject / pat-down / cut-off / stall bust / + yard bust / phone confiscation) and **`RoleDef.stations`** (`taps`, `decks`). + Helpers `canEnforce` / `canWork`, verb list `AUTHORITY_VERBS`. + - authority: doorgirl, bouncer, hos · no authority: glassie, bartender, dj + - taps: bartender + hos · decks: dj + hos · security roles work people, not gear +- **The doorgirl KEEPS floor authority deliberately.** Working the rope and + stepping inside to sort the floor is the loop that already shipped and is + live; gating her out would have broken the core game to satisfy a table. +- **One gate, not six**: `FloorDemoScene.applyRolePermissions()` wraps + findTarget's result, so "what is nearest" stays geometry and permissions stay + about the job. Service verbs are untouched — glasses, mopping, the water + station (carrying a cup keeps 'drunk' interactive), and the fainter, because + nobody needs a licence to help someone breathing badly. +- **`report` verb**: the gated interaction becomes "E — radio it in (someone who + has had a night)". Reuses the staff competence roll — ~65% somebody wanders + over and deals with it (vibe +1, patron handled and sent home), otherwise the + radio just eats it. One call per target (no spam), and if it resolves itself + first you get told that instead. Incidents: `radioedIn`. + +### Verified in pane +glassie: taps refused, maggot → report prompt, call logged once under spam, +responder handled + sent home, full incident trail, screenshot · bouncer: +authority verb intact, taps refused · **doorgirl (default/dev-route): unchanged +— 'drunk' and 'contraband' both still hers**, raw target === gated target. + +### Note for whoever tests the floor in the pane +`player.facing` is RADIANS, not a compass string. Setting it to 'right' poisons +inCone's angle maths and every cone target silently vanishes — cost me a false +"pat-down is broken" scare. Use 0 for due right. diff --git a/src/data/roster.ts b/src/data/roster.ts index 8c28a6d..42fca0d 100644 --- a/src/data/roster.ts +++ b/src/data/roster.ts @@ -20,6 +20,36 @@ export interface RoleDef { post?: 'taps' | 'decks'; /** Venue ids where this shift is on the board (venue ladder, design §6). */ venues: readonly string[]; + /** + * Floor ENFORCEMENT: ejections, pat-downs, cut-offs, stall and yard busts, + * taking a phone off someone. The security roles have it; the service roles + * do not, and design §6 makes that the glassie's whole signature tension — + * "ZERO AUTHORITY. You see the Maggot but can only radio it in, and watch + * who responds." Without it a glassie shift is the same game for less money. + * + * The doorgirl KEEPS it: working the rope and stepping inside to sort the + * floor is the loop the game already shipped, and she is security too. + */ + authority: boolean; + /** Stations this role may take. You do not wander onto the decks mid-shift. */ + stations: readonly Station[]; +} + +export type Station = 'taps' | 'decks'; + +/** Verbs the floor gates on `authority`. Service roles report them instead. */ +export type AuthorityVerb = 'drunk' | 'contraband' | 'noStamp' | 'stall' | 'joint' | 'filming'; + +export const AUTHORITY_VERBS: readonly AuthorityVerb[] = [ + 'drunk', 'contraband', 'noStamp', 'stall', 'joint', 'filming', +]; + +export function canEnforce(role: Pick): boolean { + return role.authority; +} + +export function canWork(role: Pick, station: Station): boolean { + return role.stations.includes(station); } export const ROLES: readonly RoleDef[] = [ @@ -29,6 +59,8 @@ export const ROLES: readonly RoleDef[] = [ wage: 120, blurb: 'zero authority. all glasses. you will SEE things', startLoc: 'floor', + authority: false, + stations: [], venues: ['theRoyal'], }, { @@ -38,6 +70,8 @@ export const ROLES: readonly RoleDef[] = [ blurb: 'the taps, the tabs, the RSA line. nobody cooked gets a pour', startLoc: 'floor', post: 'taps', + authority: false, + stations: ['taps'], venues: ['theRoyal', 'voltage'], }, { @@ -46,6 +80,8 @@ export const ROLES: readonly RoleDef[] = [ wage: 180, blurb: 'the rope, the IDs, the whole street watching you decide', startLoc: 'door', + authority: true, + stations: [], venues: ['theRoyal', 'voltage'], }, { @@ -54,6 +90,8 @@ export const ROLES: readonly RoleDef[] = [ wage: 190, blurb: 'the floor, the stalls, the yard. walk it like you mean it', startLoc: 'floor', + authority: true, + stations: [], venues: ['voltage'], }, { @@ -63,6 +101,8 @@ export const ROLES: readonly RoleDef[] = [ blurb: 'hold the decks. the room is a meter and youre the needle', startLoc: 'floor', post: 'decks', + authority: false, + stations: ['decks'], venues: ['room'], }, { @@ -71,6 +111,8 @@ export const ROLES: readonly RoleDef[] = [ wage: 260, blurb: 'door, floor, radio. everything, at once, all night', startLoc: 'door', + authority: true, + stations: ['taps', 'decks'], venues: ['elevate', 'room'], }, ]; diff --git a/src/data/strings/floor.ts b/src/data/strings/floor.ts index 309a9fc..5122f6f 100644 --- a/src/data/strings/floor.ts +++ b/src/data/strings/floor.ts @@ -142,6 +142,28 @@ export const SWEEP_UI = { done: 'floor swept. ${cash} better off. the room, finally, is just a room.', } as const; +/** + * Zero authority (design §6, the glassie's signature tension). You can see it + * perfectly well. You cannot do anything about it except say so out loud and + * find out whether the radio is a chain of command or a suggestion box. + */ +export const NO_AUTHORITY = { + /** {what} is filled with the thing you are not allowed to deal with. */ + prompt: 'E — radio it in ({what})', + drunk: 'someone who has had a night', + contraband: 'something in a pocket', + noStamp: 'no stamp on that hand', + stall: 'two pairs of feet, one cubicle', + joint: 'a smell in the yard', + filming: 'a phone up, filming', + sent: '*click* "yeah, no worries, someone will get to that." *click*', + cooldown: 'you already called that in. it is on the list. there is a list.', + responded: 'somebody wanders over and deals with it. they do not look at you.', + ignored: 'nobody comes. the radio does not even crackle. you go back to the glasses.', + /** When the thing you reported sorts itself out before anyone arrives. */ + moot: 'it resolved itself, the way these things do when nobody is coming.', +} 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)', diff --git a/src/scenes/floor/FloorDemoScene.ts b/src/scenes/floor/FloorDemoScene.ts index 7646594..a5cc68e 100644 --- a/src/scenes/floor/FloorDemoScene.ts +++ b/src/scenes/floor/FloorDemoScene.ts @@ -48,13 +48,15 @@ import { orderLag, rollBumpMops, rollShanClean, type FloorOrderKind, type OrderAck, } from '../../rules/staff'; import { rollDrops, type Drop } from '../../rules/floorScore'; +import { AUTHORITY_VERBS, canEnforce, canWork, type AuthorityVerb } from '../../data/roster'; import { getActiveEngine } from '../../audio/TechnoEngine'; import { RACK_RESPAWN_MIN, freshRack, stepRack, turnSharpness, type RackState } from './glassie'; 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, SWEEP_UI, + HELP_TEXT, JOINT_RESULTS, JOINT_STING, JOINT_UI, NO_AUTHORITY, NO_STAMP_RADIO, + STAFF_RADIO, SWEEP_UI, PHONE_POSTED, PHONE_UI, VOMIT_UI, WATER_UI, } from '../../data/strings/floor'; import { MeterHud } from '../../ui/MeterHud'; @@ -100,13 +102,15 @@ const STAGE_RANK: Readonly> = { /** What pressing E on the current target would do. */ type InteractKind = | 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'exit' | 'joint' | 'rack' | 'dishwasher' - | 'puddle' | 'water' | 'filming' | 'fainter' | 'decks' | 'taps'; + | 'puddle' | 'water' | 'filming' | 'fainter' | 'decks' | 'taps' | 'report'; interface InteractTarget { kind: InteractKind; agent?: Agent; stallId?: string; prompt: string; + /** For 'report': the verb this role is not allowed to perform itself. */ + reporting?: AuthorityVerb; } /** @@ -171,6 +175,8 @@ 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(); + /** Zero authority: things already called in, so you cannot spam the radio. */ + private readonly reported = new Set(); /** The floor score: lights on, crowd gone, the sweep. */ private sweeping = false; private sweepCash = 0; @@ -377,6 +383,7 @@ export class FloorDemoScene extends Phaser.Scene { this.barMode = false; this.barOrderAt.clear(); this.staffReadyAt.clear(); + this.reported.clear(); this.tabs.clear(); this.djTips = 0; this.djReturns = []; @@ -462,6 +469,7 @@ export class FloorDemoScene extends Phaser.Scene { this.barMode = false; this.barOrderAt.clear(); this.staffReadyAt.clear(); + this.reported.clear(); this.tabs.clear(); this.djTips = 0; this.djReturns = []; @@ -670,7 +678,7 @@ export class FloorDemoScene extends Phaser.Scene { } this.tickCalm(dt); - this.target = this.escorting ? null : this.findTarget(); + this.target = this.escorting ? null : this.applyRolePermissions(this.findTarget()); this.cameras.main.centerOn(this.player.x, this.player.y); this.view.updateLights(dt); @@ -891,6 +899,37 @@ export class FloorDemoScene extends Phaser.Scene { return null; } + /** + * The roster decides what you are ALLOWED to do about what you can see + * (design §6). One gate at the end rather than six inside findTarget, so the + * "what is nearest" logic stays about geometry and this stays about the job. + * + * A service role keeps every service verb — glasses, mopping, water, the + * fainter (nobody needs a licence to help someone breathing badly) — and + * loses only enforcement, which becomes something you say into a radio. + */ + private applyRolePermissions(t: InteractTarget | null): InteractTarget | null { + const role = this.nightCtx?.role; + if (!t || !role) return t; // demo mode has no roster and no restrictions + if (t.kind === 'taps' || t.kind === 'decks') { + // Already holding it: stepping off is always allowed. + if (this.barMode || this.djMode) return t; + return canWork(role, t.kind === 'taps' ? 'taps' : 'decks') ? t : null; + } + // Carrying a cup makes 'drunk' the water station, which is a service duty. + if (t.kind === 'drunk' && this.carryingCup) return t; + if (!AUTHORITY_VERBS.includes(t.kind as AuthorityVerb)) return t; + if (canEnforce(role)) return t; + const what = NO_AUTHORITY[t.kind as AuthorityVerb]; + const reported: InteractTarget = { + kind: 'report', + prompt: NO_AUTHORITY.prompt.replace('{what}', what), + reporting: t.kind as AuthorityVerb, + }; + if (t.agent) reported.agent = t.agent; + return reported; + } + // ---- interactions ------------------------------------------------------ private interact(): void { @@ -902,6 +941,7 @@ export class FloorDemoScene extends Phaser.Scene { if (t.kind === 'dishwasher') return this.deliverRack(); if (t.kind === 'decks') return this.toggleDecks(); if (t.kind === 'taps') return this.toggleTaps(); + if (t.kind === 'report') return this.radioItIn(t); if (t.kind === 'fainter') return this.openAid(); if (t.kind === 'puddle') return this.interactPuddle(); if (t.kind === 'water') { @@ -1584,6 +1624,48 @@ export class FloorDemoScene extends Phaser.Scene { this.time.delayedCall(900, () => cb?.({ cash: this.sweepCash })); } + // ---- zero authority (design §6, the glassie) --------------------------- + + /** + * You saw it, you said so, and now it is somebody else's call. The whole + * verb is the wait: sometimes a bouncer wanders over, sometimes the radio + * just eats it, and either way the thing you reported keeps happening in + * the meantime. That is the entry-level job. + */ + private radioItIn(t: InteractTarget): void { + const id = t.agent?.patron.id ?? `${t.reporting}`; + if (this.reported.has(id)) { + this.toast(NO_AUTHORITY.cooldown); + return; + } + this.reported.add(id); + this.toast(NO_AUTHORITY.sent); + this.logIncident('radioedIn', t.agent?.patron.id, `Reported: ${t.reporting}. Not my call to make.`); + const rs = this.rng.stream('noAuthority'); + const responds = rollBumpMops(rs); // same competence roll the staff use + this.time.delayedCall(orderLag(rs) * 2, () => { + const agent = t.agent; + if (agent && (agent.gone || agent.handled)) { + if (this.present) this.toast(NO_AUTHORITY.moot); + return; + } + if (!responds) { + if (this.present) this.toast(NO_AUTHORITY.ignored); + return; + } + // Somebody with a licence to touch people deals with it. + if (agent) { + agent.handled = true; + this.maggotSeen.delete(agent.patron.id); + this.cutOffDealt.set(agent.patron.id, drunkStage(agent.patron.intoxication)); + this.crowd.sendHome(agent); + } + this.bus.emit('meters:delta', { vibe: 1 }); + this.logIncident('radioedIn', agent?.patron.id, 'Somebody else dealt with what I called in.'); + if (this.present) this.toast(NO_AUTHORITY.responded); + }); + } + // ---- radio command (design §6, Head of Security) ----------------------- /** Floor-side radio keys: ack + result toasts land here. */ diff --git a/tests/roster.test.ts b/tests/roster.test.ts index acfe0b4..e558e45 100644 --- a/tests/roster.test.ts +++ b/tests/roster.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { ROLES, defaultRoleFor, roleById, rolesFor } from '../src/data/roster'; +import { AUTHORITY_VERBS, ROLES, canEnforce, canWork, defaultRoleFor, roleById, rolesFor } from '../src/data/roster'; +import { NO_AUTHORITY } from '../src/data/strings/floor'; import { VENUES } from '../src/data/venues'; describe('the roster board', () => { @@ -44,3 +45,60 @@ describe('the roster board', () => { expect(roleById(undefined)).toBeUndefined(); }); }); + +describe('what the shift lets you DO (design §6 role permissions)', () => { + it('security enforces; service reports', () => { + // The doorgirl keeps floor authority on purpose: working the rope and + // stepping inside to sort the floor is the loop the game already shipped. + expect(roleById('doorgirl')!.authority).toBe(true); + expect(roleById('bouncer')!.authority).toBe(true); + expect(roleById('hos')!.authority).toBe(true); + expect(roleById('glassie')!.authority).toBe(false); + expect(roleById('bartender')!.authority).toBe(false); + expect(roleById('dj')!.authority).toBe(false); + }); + + it('the glassie is the zero-authority fantasy — sees everything, may touch nothing', () => { + const glassie = roleById('glassie')!; + expect(canEnforce(glassie)).toBe(false); + for (const station of ['taps', 'decks'] as const) { + expect(canWork(glassie, station)).toBe(false); + } + // ...and is still paid the least, which is the joke and also the ladder. + for (const r of ROLES) expect(glassie.wage).toBeLessThanOrEqual(r.wage); + }); + + it('a station belongs to the role that owns it, plus the boss', () => { + expect(canWork(roleById('bartender')!, 'taps')).toBe(true); + expect(canWork(roleById('bartender')!, 'decks')).toBe(false); + expect(canWork(roleById('dj')!, 'decks')).toBe(true); + expect(canWork(roleById('dj')!, 'taps')).toBe(false); + for (const s of ['taps', 'decks'] as const) expect(canWork(roleById('hos')!, s)).toBe(true); + // Security roles work people, not equipment. + for (const s of ['taps', 'decks'] as const) { + expect(canWork(roleById('doorgirl')!, s)).toBe(false); + expect(canWork(roleById('bouncer')!, s)).toBe(false); + } + }); + + it('a role that starts ON a post is allowed to work it', () => { + // Otherwise the night opens by walking you onto a station you cannot use. + for (const r of ROLES) { + if (r.post) expect(canWork(r, r.post), `${r.id} starts on ${r.post}`).toBe(true); + } + }); + + it('every authority verb is a real floor interaction', () => { + // Guards a typo silently un-gating a verb: these strings are matched + // against InteractTarget.kind in FloorDemoScene.applyRolePermissions. + expect([...AUTHORITY_VERBS].sort()).toEqual( + ['contraband', 'drunk', 'filming', 'joint', 'noStamp', 'stall'], + ); + }); + + it('and every authority verb has words for reporting it instead', () => { + for (const verb of AUTHORITY_VERBS) { + expect(NO_AUTHORITY[verb], `no report line for '${verb}'`).toBeTruthy(); + } + }); +});