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:
parent
700bd48bcb
commit
195325f8f1
@ -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.
|
||||
|
||||
@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user