not-tonight/docs/CONTRACTS.md
type-two 5086a325d8 Design docs, build plan, contracts, and lane instructions for NOT TONIGHT
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:48:44 +10:00

6.3 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;
}

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:eventName as 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/).