6.8 KiB
NOT TONIGHT — Shared Contracts
The typed spine of the project. Phase 0 implements these verbatim in
src/data/types.ts and src/core/. After Phase 0 review, changes require a
CONTRACT CHANGE REQUEST in LANEHANDOVER.md. Lanes may EXTEND (new event names in
their namespace, new fields marked optional) but never repurpose existing fields.
1. Patron
export type Archetype =
| 'fresh18' | 'almost18' | 'financeBro' | 'influencer'
| 'regular' | 'ownersMate' | 'quietMessy' | 'inspector' | 'punter';
export type DrunkStage = 'sober' | 'tipsy' | 'loose' | 'messy' | 'maggot';
export interface OutfitLayer {
slot: 'shoes' | 'legs' | 'top' | 'outer' | 'hair' | 'accessory';
type: string; // e.g. 'sneaker' | 'thong' | 'singlet' | 'blazer' — open vocab, see data/outfits.ts
colour: string; // canonical colour name, not hex: 'white' | 'black' | 'red' ...
logo?: boolean;
vintage?: boolean;
}
export interface IdCard {
name: string;
dob: string; // ISO date. Age math vs GameClock date is the minigame.
expiry: string; // ISO date
photoSeed: number; // paper-doll seed the photo was rendered from
fake?: { // present only on fakes; each flag = one visible tell
peelingLaminate?: boolean;
wrongHologram?: boolean;
jokeName?: boolean; // e.g. 'M. Lovin'
photoMismatch?: boolean; // photoSeed deliberately ≠ patron.dollSeed
};
}
export interface Patron {
id: string; // stable across the run — regulars keep theirs
dollSeed: number; // drives paper-doll face/body render
archetype: Archetype;
age: number; // true age; ID may disagree
idCard: IdCard;
outfit: OutfitLayer[];
intoxication: number; // 0..1, drifts up per tolerance
tolerance: number; // 0..1
flags: {
contraband?: string[]; // item ids from data/contraband.ts
onGuestList?: boolean;
claimsGuestList?: boolean;
knowsOwner?: boolean; // the trap: claims can be TRUE
uvStamped?: boolean;
timesDeniedBefore?: number; // regulars' grudge counter
disguised?: boolean;
};
}
export const drunkStage = (x: number): DrunkStage => /* thresholds in rules/ */
2. Rules engine (pure functions only — no Phaser imports in src/rules/)
export interface DressCodeRule {
id: string;
text: string; // exactly what Dazza's text says
activeFrom: number; // clock minutes since 21:00
violates: (p: Patron) => boolean; // evaluates DATA, never pixels
}
export type Verdict = 'admit' | 'deny' | 'sobrietyTest' | 'patDown' | 'wait';
export interface VerdictOutcome { // returned by rules/judge.ts, consumed by scenes
vibeDelta: number;
aggroDelta: number;
heatStrike?: { reason: string; deferred?: boolean }; // deferred = surfaces on inspection/audit
dazzaText?: string;
}
3. Game state & meters
export interface NightState {
venueId: string; nightIndex: number;
vibe: number; // 0..100
aggro: number; // 0..100
heatStrikes: HeatStrike[]; // run-scoped, max 3
hype: number; // vibe multiplier from queue theatre
capacity: { inside: number; licensed: number; clickerShown: number }; // clickerShown can drift if you misclick — that's the game
clockMin: number; // minutes since 21:00; night ends 360
location: 'door' | 'floor';
incidents: IncidentRecord[]; // truth log; report form may diverge — divergence is stored
}
GameState (run-scoped) holds: seed, venue progression, regulars' memory
(Map<patronId, RegularMemory>), past incident reports (for cross-night audit).
4. Event bus (src/core/EventBus.ts — typed emitter over this map)
export interface EventMap {
// clock & flow
'clock:tick': { clockMin: number };
'night:phaseChange': { location: 'door' | 'floor' };
'radio:call': { urgency: 1|2|3; line: string }; // floor pulling you in
// door
'door:patronUp': { patron: Patron };
'door:verdict': { patron: Patron; verdict: Verdict; outcome: VerdictOutcome };
'door:clicker': { direction: 'in'|'out' };
'dazza:text': { text: string; rule?: DressCodeRule };
// floor
'floor:infractionSpotted': { patronId: string; kind: 'drunk'|'stall'|'contraband'|'noStamp'|'fight' };
'floor:ejection': { patronId: string; style: 'clean'|'messy' };
// audio (LANE-JUICE emits; StubBeatClock emits same shape at fixed 128bpm)
'beat:tick': { beatIndex: number; audioTimeMs: number };
'audio:location': { location: 'door'|'floor' }; // drives low-pass cutoff
// meters (single writer: core/meters.ts — scenes emit deltas, never set directly)
'meters:delta': { vibe?: number; aggro?: number; hype?: number };
'meters:changed': { vibe: number; aggro: number; hype: number };
'heat:strike': HeatStrike;
// folded in at the Phase-1 review (2026-07-19):
'incident:log': IncidentRecord; // single writer: core/meters.ts appends to NightState.incidents
'door:phoneTheatre': { durationMs: number };
'audio:unlocked': { atMs: number };
'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number };
}
Phase-1 review also added: PatronFlags.handHolding? (CCR-1),
VerdictOutcome.hypeDelta? (CCR-2), DressCodeRule.short? (CCR-4).
src/data/types.ts remains the source of truth where this doc lags.
Bus discipline: scenes emit meters:delta; only core/meters.ts mutates state
and emits meters:changed. HUD listens to meters:changed only. This keeps three
lanes from fighting over the same numbers.
5. Paper-doll renderer (src/patrons/doll.ts)
// Renders a Patron (dollSeed + outfit layers) to a Phaser texture at 3 sizes:
export type DollPose = 'queue' | 'idPhoto' | 'floorTopDown';
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string; // returns texture key
v0.x layers are procedural coloured-pixel shapes; the function signature is the
permanent contract so real art swaps in without touching callers. Drunk sway/red-eye
tells are render-time modifiers driven by intoxication — renderer owns tells,
rules own thresholds.
6. RNG & determinism
SeededRNG (mulberry32 or similar) — ALL generation flows from the run seed via
named child streams: rng.stream('patrons'), rng.stream('idFakes'), etc.
No Math.random() anywhere (add an eslint rule). Same seed ⇒ identical night.
Tests assert this.
7. Naming & conventions
- Event names:
domain:eventNameas above; new events go in your lane's domain. - No cross-lane scene imports. Shared visual bits live in
ui/(LANE-JUICE owns). - Aussie dialogue strings live in
data/strings/as typed const maps, not inline. - Every pure module gets a test file. Scenes don't need tests; their logic should
be thin enough not to want them (if it isn't, move logic into
rules/).