Role permissions: the glassie can see everything and touch nothing

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-26 20:02:54 +10:00
parent 700bd48bcb
commit 195325f8f1
5 changed files with 249 additions and 4 deletions

View File

@ -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.

View File

@ -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<RoleDef, 'authority'>): boolean {
return role.authority;
}
export function canWork(role: Pick<RoleDef, 'stations'>, 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'],
},
];

View File

@ -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)',

View File

@ -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<Record<DrunkStage, number>> = {
/** 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<FloorOrderKind, number>();
/** Zero authority: things already called in, so you cannot spam the radio. */
private readonly reported = new Set<string>();
/** 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. */

View File

@ -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();
}
});
});