Phase 0: scaffold, contracts, core systems, patron generator, paper-doll renderer, Parade demo (49 tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-19 16:20:53 +10:00
parent 4eb8135585
commit 9805ff8fe5
45 changed files with 5041 additions and 8 deletions

6
.claude/launch.json Normal file
View File

@ -0,0 +1,6 @@
{
"version": "0.0.1",
"configurations": [
{ "name": "not-tonight", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 5199 }
]
}

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

View File

@ -29,6 +29,24 @@ by parallel "lanes" coordinated through files, reviewed by Fable.
branch. An unpushed session didn't happen. branch. An unpushed session didn't happen.
- Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`. - Remote is Gitea: `ssh://git@100.71.119.27:222/monster/not-tonight.git`.
## Dev notes (Phase 0 landed 2026-07-19)
- Gate: `npm run lint && npm run build && npm test` — all three must pass before
any push. Dev server: `npm run dev` (port 5199, config in `.claude/launch.json`).
- Key APIs (read the source, it's short): `core/EventBus` (`on` returns an
unsubscribe fn), `core/SeededRNG` (`rng.stream(name)` — NEVER `Math.random`,
eslint blocks it), `core/GameClock` (feed real ms via `update(delta)` from your
scene; `ageOn(dob, nightDate)` is the ID math), `core/meters` (emit
`meters:delta`, never mutate NightState), `patrons/generator`
(`generatePatron(ctx, clockMin, override?)`), `patrons/dollPlan` (pure,
testable) + `patrons/doll` (`renderDoll(scene, patron, pose)` → texture key),
`rules/drunk` (`drunkStage`). `StubBeatClock` needs an owning scene calling
`update(delta)` each frame.
- **Verifying in Claude's browser preview pane:** the pane tab reports
`document.hidden = true`, so Chrome suspends rAF and the Phaser loop freezes at
frame 0. Drive frames manually via the exposed debug handle:
`const b = performance.now(); for (let i = 1; i <= 900; i++) window.game.loop.step(b + i * 16.67)`
then screenshot/inspect. In a normal browser the game runs fine.
## Tone guard ## Tone guard
The game is Aussie hospitality satire — affectionate, funny, with a moral undertow The game is Aussie hospitality satire — affectionate, funny, with a moral undertow
(design doc §4.3). If content you're writing reads as mean-spirited rather than (design doc §4.3). If content you're writing reads as mean-spirited rather than

View File

@ -46,3 +46,52 @@ BOTTOM. Push after every session so the reviewer can see state remotely.
--- ---
*(log starts below)* *(log starts below)*
---
### SESSION — LANE-0 (executed by Fable, reviewer) — 2026-07-19 16:30
**Branch/commits:** main (reviewer executed Phase 0 directly; no lane branch)
**Done:**
- Vite + strict-TS + Phaser 3.90 + Vitest + ESLint scaffold; `Math.random` banned by lint rule.
- All CONTRACTS.md types verbatim in `src/data/types.ts`.
- `core/`: SeededRNG (mulberry32, named streams), typed EventBus (snapshot-emit
semantics), GameClock (real-ms driven, `ageOn` birthday math incl. leap-day),
Meters (single-writer, hype multiplies positive vibe only, clicker drift),
StubBeatClock (128 BPM), versioned corrupt-safe localStorage save.
- `data/`: outfit vocabulary (+PALETTE), 9 archetypes, 10 contraband items,
17 Dazza texts (8 rule-bearing with ruleIds).
- `patrons/`: generator (fake-ID tell system, DOBs clustered near the 18
boundary, late-arrival drunk drift), pure `dollPlan` + Phaser `renderDoll`
(3 poses, drunk tells baked), regulars' memory store.
- `rules/drunk.ts`: drunkStage thresholds.
- ParadeScene demo: rainy street, kebab-shop + venue dressing, 24-walker parade
with ID cards rendered from the same data, SPACE reseed / D toggle cards.
- 49 tests green; lint/build/test gate clean.
**How to see it:** `npm run dev` → http://localhost:5199 (in the Claude preview
pane, drive frames per CLAUDE.md "Dev notes"). Watch for an `almost18` label
walking under an ID card claiming 18+ — that's the whole game in one image.
**Tests:** 49 passing / 0 failing.
**Broke / known-wonky:**
- ID cards overlap when the footpath is crowded — cosmetic, parade-only.
- `window.game` debug handle exposed in main.ts (deliberate, for pane-driving).
- Spec deviations: doll renderer split into `dollPlan.ts` (pure) + `doll.ts`
(Phaser) — better than spec'd, plans are pixel-testable without a GPU; added
a 5th fake-ID tell (expired real card, no boolean tells); no MainMenu scene
(Boot → Parade direct) — menu is Phase 2 shell work.
**Contract change requests:** none (contracts implemented as written).
**Questions for reviewer:** n/a (self-reviewed).
**Next session should:** Phase 1 lanes are OPEN — see REVIEW block below.
---
### REVIEW — Fable — 2026-07-19
**Reviewed:** Phase 0 (self-executed; verified via test gate + in-browser parade).
**Merged to main:** yes (direct).
**Contract decisions:** CONTRACTS.md now frozen. Extension requests via this file.
**Instructions for Phase 1 (LANE-DOOR, LANE-FLOOR, LANE-JUICE):**
1. Branch from current main into `lane/door` / `lane/floor` / `lane/juice`
(worktrees if sharing a machine). Read your lane file's new
"Phase-0 reality" section FIRST — it corrects the original spec where the
landed code differs.
2. LANE-DOOR is the critical path; if only one Opus session is available, run
DOOR first, JUICE second, FLOOR third.
3. Every session ends with the gate + a SESSION block here + a push. Fable
reviews on request ("review the lanes").

View File

@ -73,11 +73,20 @@ worth cloning for an `art_src/` pipeline here.
## Ground rules for the eventual art pass ## Ground rules for the eventual art pass
**Licensing: none needed.** This is a private tailnet sandbox game, never public —
John's call (2026-07-19). Anything on the fleet or gdrive is fair game, including
the audio archives and MONSTER MIXES. (If that ever changes and the game goes
public, this section is the checklist to revisit first.)
1. Everything funnels through the existing contracts (`renderDoll` poses, outfit 1. Everything funnels through the existing contracts (`renderDoll` poses, outfit
slots) — art swaps in behind interfaces, callers never change. slots) — art swaps in behind interfaces, callers never change.
2. One palette, one pixel density (32×48 queue dolls at 640×360) decided ONCE 2. One palette, one pixel density (32×48 queue dolls at 640×360) decided ONCE
before any batch conversion; retrofitting mixed densities is the classic trap. before any batch conversion; retrofitting mixed densities is the classic trap.
3. Provenance: only fleet-owned / John-authored / MODELBEAST-generated / CC0 3. Log each imported batch (source → transform → dest) in this file — not for
sources. Log each imported batch (source → transform → dest) in this file. licensing, just so we can regenerate/retrace later.
4. Check the MeshGod gallery + MODELBEAST `data/` (m3ultra) before generating 4. Check the MeshGod gallery + MODELBEAST `data/` (m3ultra) before generating
anything — the inventory's rule "catalog before regenerating" applies here too. anything — the inventory's rule "catalog before regenerating" applies here too.
5. The synthesized techno engine stays the core soundtrack (the location-filter
trick needs a synth we control), but real archive audio is now allowed as
layers/easter eggs — e.g. MONSTER MIXES on a kebab-shop jukebox, real crowd
ambience beds under the synth.

View File

@ -42,12 +42,13 @@ CONTRACT CHANGE REQUEST in the handover file and reviewer sign-off — never jus
## 3. Phases ## 3. Phases
### Phase 0 — Scaffold & Contracts (ONE lane, sequential — everything depends on it) ### Phase 0 — Scaffold & Contracts — ✅ DONE 2026-07-19 (executed by Fable)
`lanes/LANE0_SCAFFOLD.md`. Deliverable: repo compiles, tests green, and a **Patron `lanes/LANE0_SCAFFOLD.md`. Delivered: strict-TS Vite+Phaser scaffold, all
Parade** demo scene proves the generator + paper-doll renderer (generated patrons CONTRACTS.md types in `src/data/types.ts`, core systems (`SeededRNG`, `EventBus`,
walk across the screen; ID card renders from the same data). All shared types in `GameClock`+`ageOn`, `Meters`, `StubBeatClock`, `save`), patron generator +
`src/data/types.ts` matching CONTRACTS.md. Event bus + seeded RNG + game clock done paper-doll renderer (pure `dollPlan` + Phaser `renderDoll`), 49 unit tests, and
and tested. the Patron Parade demo verified in-browser. See the LANE-0 SESSION block in
LANEHANDOVER.md for API notes Phase-1 lanes must read.
### Phase 1 — Parallel lanes (after Phase 0 review passes) ### Phase 1 — Parallel lanes (after Phase 0 review passes)
Three lanes run concurrently on separate branches/worktrees: Three lanes run concurrently on separate branches/worktrees:

17
eslint.config.js Normal file
View File

@ -0,0 +1,17 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
// All randomness must flow from SeededRNG streams — determinism is a contract.
'no-restricted-properties': [
'error',
{ object: 'Math', property: 'random', message: 'Use SeededRNG streams (docs/CONTRACTS.md §6).' },
],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
);

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NOT TONIGHT</title>
<style>
html, body { margin: 0; padding: 0; background: #0a0a12; height: 100%; }
body { display: flex; align-items: center; justify-content: center; }
canvas { image-rendering: pixelated; }
</style>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,5 +1,9 @@
# LANE-0 — Scaffold & Contracts (Phase 0, single lane) # LANE-0 — Scaffold & Contracts (Phase 0, single lane)
> **✅ EXECUTED 2026-07-19 by Fable (reviewer) directly on main.** Kept for
> reference. Deviations from the original spec are noted in the LANE-0 SESSION
> block in LANEHANDOVER.md — that block is the accurate record.
**Executor:** Opus 4.8. **Branch:** `lane/scaffold`. **Prereq reading (in order):** **Executor:** Opus 4.8. **Branch:** `lane/scaffold`. **Prereq reading (in order):**
`docs/GAME_DESIGN.md``docs/BUILD_PLAN.md``docs/CONTRACTS.md``LANEHANDOVER.md`. `docs/GAME_DESIGN.md``docs/BUILD_PLAN.md``docs/CONTRACTS.md``LANEHANDOVER.md`.
Everything downstream depends on this lane being solid. Favour boring correctness Everything downstream depends on this lane being solid. Favour boring correctness

View File

@ -10,6 +10,30 @@ REVIEW blocks addressed to you).
as a Door-only night. Bias every decision toward "playable and funny" over as a Door-only night. Bias every decision toward "playable and funny" over
"architecturally complete". "architecturally complete".
## Phase-0 reality (read before coding)
Phase 0 landed 2026-07-19 — see CLAUDE.md "Dev notes" for the API map and the
browser-pane verification trick. Door-relevant specifics:
- `rules/drunk.ts` already exists (drunkStage thresholds + `hasVisibleTells`) —
build `dressCode.ts`, `idCheck.ts`, `judge.ts` beside it.
- ID date math: `ageOn(dob, nightDate)` in `core/GameClock` and
`idAge(patron, nightDate)` in `patrons/generator` exist and are tested —
`idCheck.ts` should build on them, not reimplement.
- Fake-ID tells now include a fifth: an **expired real-looking card** (fake
present, all tell booleans false, expiry in the past). Your `idVerdict` must
catch it — see `tests/generator.test.ts` "every fake has a tell".
- Patron generation: `generatePatron({rng, nightDate}, clockMin, override?)`;
ids are serial (`p0…`), reset per run via `_resetPatronSerial()`.
- `renderDoll(scene, patron, 'queue')` returns a texture key; drunk tells are
baked per sway-bucket (see `dollTextureKey`). For the ×3 patron-up view just
scale the image — pixelArt rendering keeps it crisp.
- `ParadeScene` shows the working pattern for bus/clock/meters lifecycle
(`resetRun`) — copy that shape, don't invent one. `StubBeatClock` requires
your scene to call `update(delta)` each frame.
- Dazza strings live in `data/strings/dazza.ts` keyed with `ruleId`s — your
`dressCode.ts` rule ids must match them (8 rule-bearing texts are already
written: noThongsSinglets, noLogos, noWhiteSneakers, noSongRequesters,
noBucketHats, noSunnies, noBlazers, noHandHolders).
## Deliverable ## Deliverable
`npm run dev` → full Door-only night: 9:00 PM to 3:00 AM, patrons queue and step `npm run dev` → full Door-only night: 9:00 PM to 3:00 AM, patrons queue and step

View File

@ -12,6 +12,21 @@ infractions (cut-off, toilet stall, pat-down) + UV stamp checks. Runs standalone
it self-populates from the patron generator; integration (Phase 2) will hand it the it self-populates from the patron generator; integration (Phase 2) will hand it the
real admitted-patron list instead. Code against `Patron[]` input from day one. real admitted-patron list instead. Code against `Patron[]` input from day one.
## Phase-0 reality (read before coding)
Phase 0 landed 2026-07-19 — see CLAUDE.md "Dev notes" for the API map and the
browser-pane verification trick. Floor-relevant specifics:
- Drunk thresholds exist in `rules/drunk.ts` (`drunkStage`, `hasVisibleTells`) —
no local copy needed; delete that TODO from your plan.
- `renderDoll(scene, patron, 'floorTopDown')` already produces the 16×16
top-down doll (hair blob over shoulders/top colour).
- `StubBeatClock` exists in core; instantiate it in your demo scene and call
`update(delta)` each frame — it emits `beat:tick` at 128 BPM.
- `generatePatron` + `ParadeScene`'s `resetRun` lifecycle are the reference
patterns for self-populating your demo.
- Register your demo scene in `src/main.ts`'s scene list; launch order stays
Boot → Parade. Menu keys (F for floor demo) can live in ParadeScene as a
scene-switch keybind — that one-line touch outside your dirs is pre-approved.
## Scene: the venue (top-down, ~2× screen scrollable) ## Scene: the venue (top-down, ~2× screen scrollable)
Tilemap (hand-authored array is fine, no Tiled dependency): entry corridor, bar Tilemap (hand-authored array is fine, no Tiled dependency): entry corridor, bar

View File

@ -15,6 +15,26 @@ plus the techno engine playing with a big on-screen low-pass cutoff slider and a
DOOR/FLOOR toggle. Beat indicator flashes on `beat:tick` and visibly matches the DOOR/FLOOR toggle. Beat indicator flashes on `beat:tick` and visibly matches the
audio. audio.
## Phase-0 reality (read before coding)
Phase 0 landed 2026-07-19 — see CLAUDE.md "Dev notes" for the API map and the
browser-pane verification trick. Juice-relevant specifics:
- `core/StubBeatClock.ts` is the stub you'll replace: it's scene-update-driven
and emits `beat:tick {beatIndex, audioTimeMs}`. Your TechnoEngine must emit the
same event/payload (audio-clock authoritative) so consumers don't change.
- `EventBus.on` returns an unsubscribe fn — widgets should keep them and detach
in `destroy()` (see `HudStub` for the pattern your `MeterHud` replaces).
- `data/outfits.ts` exports `PALETTE` (name → hex) — use it for widget colours
so UI and dolls share one palette.
- `door:phoneTheatre` is NOT in the EventMap yet — adding it is a worked example
of the CONTRACT CHANGE REQUEST flow; file it in your first handover.
- Audio can't be heard in the Claude browser pane — verify SFX/engine by ear in
a real browser, and note in your handover what was ear-checked vs only
logic-checked.
- License note: per docs/ASSETS.md this is a private sandbox — real audio
samples are ALLOWED as layers/ambience if synthesis fights you, but the
location-filter architecture (music through the lowpass, SFX around it) is
non-negotiable.
## Part A — Audio (`src/audio/`) — raw WebAudio, no libraries ## Part A — Audio (`src/audio/`) — raw WebAudio, no libraries
### 1. `TechnoEngine.ts` — the looping track ### 1. `TechnoEngine.ts` — the looping track

3044
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "not-tonight",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"lint": "eslint src tests"
},
"dependencies": {
"phaser": "^3.90.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0",
"typescript": "^5.9.0",
"typescript-eslint": "^8.0.0",
"vite": "^7.0.0",
"vitest": "^3.2.0"
}
}

43
src/core/EventBus.ts Normal file
View File

@ -0,0 +1,43 @@
import type { EventMap } from '../data/types';
type Handler<K extends keyof EventMap> = (payload: EventMap[K]) => void;
// Typed pub/sub. Handlers added/removed during an emit don't affect that emit
// (we snapshot the handler list), matching what scenes need on shutdown.
export class EventBus {
private handlers = new Map<keyof EventMap, Set<Handler<never>>>();
on<K extends keyof EventMap>(event: K, fn: Handler<K>): () => void {
let set = this.handlers.get(event);
if (!set) {
set = new Set();
this.handlers.set(event, set);
}
set.add(fn as Handler<never>);
return () => this.off(event, fn);
}
once<K extends keyof EventMap>(event: K, fn: Handler<K>): () => void {
const off = this.on(event, (payload) => {
off();
fn(payload);
});
return off;
}
off<K extends keyof EventMap>(event: K, fn: Handler<K>): void {
this.handlers.get(event)?.delete(fn as Handler<never>);
}
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
const set = this.handlers.get(event);
if (!set) return;
for (const fn of [...set]) {
if (set.has(fn)) (fn as Handler<K>)(payload);
}
}
removeAll(): void {
this.handlers.clear();
}
}

78
src/core/GameClock.ts Normal file
View File

@ -0,0 +1,78 @@
import type { EventBus } from './EventBus';
export interface ClockConfig {
nightClockMinutes: number; // in-game minutes per night (design: 360 = 9PM→3AM)
nightRealMinutes: number; // real minutes a night takes (design: ~13)
nightDate: string; // ISO date of the night — anchors ID birthday math
}
export const DEFAULT_CLOCK: ClockConfig = {
nightClockMinutes: 360,
nightRealMinutes: 13,
nightDate: '2026-07-18', // Saturday, night 1
};
// Engine-agnostic clock: feed it real elapsed ms (from Phaser's update or a
// test harness), it emits clock:tick once per in-game minute.
export class GameClock {
private elapsedRealMs = 0;
private emittedMin = -1;
private running = false;
constructor(
private readonly bus: EventBus,
public readonly config: ClockConfig = DEFAULT_CLOCK,
) {}
get clockMin(): number {
const ratio = this.config.nightClockMinutes / (this.config.nightRealMinutes * 60_000);
return Math.min(this.config.nightClockMinutes, Math.floor(this.elapsedRealMs * ratio));
}
get isOver(): boolean {
return this.clockMin >= this.config.nightClockMinutes;
}
/** "9:47 PM" style label; clock starts at 21:00. */
get label(): string {
const total = 21 * 60 + this.clockMin;
const h24 = Math.floor(total / 60) % 24;
const m = total % 60;
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
const ampm = h24 < 12 ? 'AM' : 'PM';
return `${h12}:${String(m).padStart(2, '0')} ${ampm}`;
}
get nightDate(): Date {
return new Date(`${this.config.nightDate}T00:00:00`);
}
start(): void {
this.running = true;
}
pause(): void {
this.running = false;
}
update(deltaMs: number): void {
if (!this.running || this.isOver) return;
this.elapsedRealMs += deltaMs;
const min = this.clockMin;
while (this.emittedMin < min) {
this.emittedMin++;
this.bus.emit('clock:tick', { clockMin: this.emittedMin });
}
}
}
/** Full years between an ISO DOB and a night date. The ID minigame's math. */
export function ageOn(dobIso: string, nightDate: Date): number {
const dob = new Date(`${dobIso}T00:00:00`);
let age = nightDate.getFullYear() - dob.getFullYear();
const beforeBirthday =
nightDate.getMonth() < dob.getMonth() ||
(nightDate.getMonth() === dob.getMonth() && nightDate.getDate() < dob.getDate());
if (beforeBirthday) age--;
return age;
}

77
src/core/SeededRNG.ts Normal file
View File

@ -0,0 +1,77 @@
// Deterministic RNG. Every random draw in the game flows from a run seed via
// named streams so systems can't perturb each other's sequences.
export interface RngStream {
next(): number; // [0, 1)
int(min: number, max: number): number; // inclusive both ends
pick<T>(arr: readonly T[]): T;
chance(p: number): boolean;
weighted<T>(entries: ReadonlyArray<readonly [T, number]>): T;
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// FNV-1a over the stream name, mixed with the run seed.
function hashName(seed: number, name: string): number {
let h = 0x811c9dc5 ^ seed;
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
class Stream implements RngStream {
private readonly rand: () => number;
constructor(seed: number) {
this.rand = mulberry32(seed);
}
next(): number {
return this.rand();
}
int(min: number, max: number): number {
return min + Math.floor(this.rand() * (max - min + 1));
}
pick<T>(arr: readonly T[]): T {
if (arr.length === 0) throw new Error('pick from empty array');
return arr[Math.floor(this.rand() * arr.length)] as T;
}
chance(p: number): boolean {
return this.rand() < p;
}
weighted<T>(entries: ReadonlyArray<readonly [T, number]>): T {
let total = 0;
for (const [, w] of entries) total += w;
if (total <= 0) throw new Error('weighted with non-positive total weight');
let roll = this.rand() * total;
for (const [value, w] of entries) {
roll -= w;
if (roll < 0) return value;
}
return entries[entries.length - 1]![0];
}
}
export class SeededRNG {
private readonly streams = new Map<string, Stream>();
constructor(public readonly seed: number) {}
/** Same (seed, name) always yields the same stream state history. */
stream(name: string): RngStream {
let s = this.streams.get(name);
if (!s) {
s = new Stream(hashName(this.seed, name));
this.streams.set(name, s);
}
return s;
}
}

37
src/core/StubBeatClock.ts Normal file
View File

@ -0,0 +1,37 @@
import type { EventBus } from './EventBus';
// Placeholder beat authority: fixed-BPM beat:tick until LANE-JUICE's TechnoEngine
// replaces it (same event, same payload shape — consumers must not care which).
export class StubBeatClock {
private accMs = 0;
private beatIndex = 0;
private totalMs = 0;
private running = false;
constructor(
private readonly bus: EventBus,
public readonly bpm: number = 128,
) {}
get beatIntervalMs(): number {
return 60_000 / this.bpm;
}
start(): void {
this.running = true;
}
stop(): void {
this.running = false;
}
update(deltaMs: number): void {
if (!this.running) return;
this.accMs += deltaMs;
this.totalMs += deltaMs;
while (this.accMs >= this.beatIntervalMs) {
this.accMs -= this.beatIntervalMs;
this.bus.emit('beat:tick', { beatIndex: this.beatIndex++, audioTimeMs: this.totalMs - this.accMs });
}
}
}

67
src/core/meters.ts Normal file
View File

@ -0,0 +1,67 @@
import type { EventBus } from './EventBus';
import type { HeatStrike, NightState } from '../data/types';
export function freshNightState(venueId: string, nightIndex: number, licensed: number): NightState {
return {
venueId,
nightIndex,
vibe: 50,
aggro: 0,
heatStrikes: [],
hype: 1,
capacity: { inside: 0, licensed, clickerShown: 0 },
clockMin: 0,
location: 'door',
incidents: [],
};
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
// Single writer for the night's numbers. Scenes emit meters:delta / heat:strike;
// nothing else may mutate NightState meters directly.
export class Meters {
private readonly offs: Array<() => void> = [];
constructor(
private readonly bus: EventBus,
public readonly state: NightState,
) {
this.offs.push(
bus.on('meters:delta', (d) => this.applyDelta(d)),
bus.on('heat:strike', (s) => this.applyStrike(s)),
bus.on('clock:tick', ({ clockMin }) => {
this.state.clockMin = clockMin;
}),
bus.on('night:phaseChange', ({ location }) => {
this.state.location = location;
}),
bus.on('door:clicker', ({ direction }) => {
this.state.capacity.clickerShown = Math.max(
0,
this.state.capacity.clickerShown + (direction === 'in' ? 1 : -1),
);
}),
);
}
private applyDelta(d: { vibe?: number; aggro?: number; hype?: number }): void {
const s = this.state;
if (d.vibe !== undefined) {
// Hype multiplies the good news only — queue theatre never softens a hit.
const scaled = d.vibe > 0 ? d.vibe * s.hype : d.vibe;
s.vibe = clamp(s.vibe + scaled, 0, 100);
}
if (d.aggro !== undefined) s.aggro = clamp(s.aggro + d.aggro, 0, 100);
if (d.hype !== undefined) s.hype = clamp(s.hype + d.hype, 1, 3);
this.bus.emit('meters:changed', { vibe: s.vibe, aggro: s.aggro, hype: s.hype });
}
private applyStrike(strike: HeatStrike): void {
this.state.heatStrikes.push(strike);
}
destroy(): void {
for (const off of this.offs) off();
}
}

40
src/core/save.ts Normal file
View File

@ -0,0 +1,40 @@
import type { GameState } from '../data/types';
const KEY = 'not-tonight-save';
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export function freshGameState(seed: number): GameState {
return { v: 1, seed, venueIndex: 0, nightIndex: 0, heatStrikes: [], regulars: {}, pastReports: [] };
}
export function saveGame(state: GameState, storage: StorageLike = localStorage): void {
storage.setItem(KEY, JSON.stringify(state));
}
// Corrupt or version-mismatched saves fall back to a fresh run — never throw.
export function loadGame(fallbackSeed: number, storage: StorageLike = localStorage): GameState {
try {
const raw = storage.getItem(KEY);
if (!raw) return freshGameState(fallbackSeed);
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === 'object' && parsed !== null &&
(parsed as GameState).v === 1 &&
typeof (parsed as GameState).seed === 'number'
) {
return parsed as GameState;
}
return freshGameState(fallbackSeed);
} catch {
return freshGameState(fallbackSeed);
}
}
export function clearSave(storage: StorageLike = localStorage): void {
storage.removeItem(KEY);
}

34
src/data/archetypes.ts Normal file
View File

@ -0,0 +1,34 @@
import type { Archetype } from './types';
export interface ArchetypeDef {
key: Archetype;
/** Spawn weight in the general queue mix. 0 = only spawned by script/override. */
weight: number;
ageRange: [number, number];
toleranceRange: [number, number];
/** Starting intoxication range at 9 PM; drifts up with arrival time. */
startDrunkRange: [number, number];
chances: {
fakeId?: number;
contraband?: number;
claimsGuestList?: number;
onGuestList?: number;
knowsOwner?: number;
};
}
export const ARCHETYPES: readonly ArchetypeDef[] = [
{ key: 'punter', weight: 50, ageRange: [18, 45], toleranceRange: [0.3, 0.8], startDrunkRange: [0, 0.3], chances: { fakeId: 0.02, contraband: 0.08, claimsGuestList: 0.05 } },
{ key: 'fresh18', weight: 8, ageRange: [18, 18], toleranceRange: [0.1, 0.35], startDrunkRange: [0, 0.15], chances: {} },
{ key: 'almost18', weight: 7, ageRange: [17, 17], toleranceRange: [0.1, 0.4], startDrunkRange: [0, 0.25], chances: { fakeId: 1 } },
{ key: 'financeBro', weight: 10, ageRange: [23, 34], toleranceRange: [0.4, 0.75], startDrunkRange: [0.15, 0.55], chances: { contraband: 0.25, claimsGuestList: 0.15, knowsOwner: 0.02 } },
{ key: 'influencer', weight: 7, ageRange: [19, 29], toleranceRange: [0.3, 0.6], startDrunkRange: [0, 0.25], chances: { claimsGuestList: 0.75, onGuestList: 0.2, contraband: 0.1 } },
{ key: 'regular', weight: 8, ageRange: [25, 55], toleranceRange: [0.6, 0.95], startDrunkRange: [0.1, 0.4], chances: { onGuestList: 0.1 } },
{ key: 'ownersMate', weight: 3, ageRange: [30, 60], toleranceRange: [0.5, 0.9], startDrunkRange: [0.1, 0.5], chances: { claimsGuestList: 0.9, knowsOwner: 0.6 } },
{ key: 'quietMessy', weight: 6, ageRange: [20, 38], toleranceRange: [0.1, 0.3], startDrunkRange: [0.25, 0.5], chances: { contraband: 0.15 } },
{ key: 'inspector', weight: 1, ageRange: [38, 55], toleranceRange: [0.9, 1], startDrunkRange: [0, 0], chances: {} },
];
export const ARCHETYPE_BY_KEY: ReadonlyMap<Archetype, ArchetypeDef> = new Map(
ARCHETYPES.map((a) => [a.key, a]),
);

21
src/data/contraband.ts Normal file
View File

@ -0,0 +1,21 @@
export interface ContrabandDef {
id: string;
name: string;
/** 1 = confiscate & wave through · 2 = confiscate, warning · 3 = ejection-worthy */
severity: 1 | 2 | 3;
weight: number;
spriteHint: string; // placeholder-art colour/shape note until real sprites
}
export const CONTRABAND: readonly ContrabandDef[] = [
{ id: 'baggie', name: 'Suspicious baggie', severity: 3, weight: 3, spriteHint: 'tiny white rect' },
{ id: 'flask', name: 'Hip flask', severity: 2, weight: 5, spriteHint: 'silver rounded rect' },
{ id: 'goonSack', name: 'Goon sack', severity: 2, weight: 3, spriteHint: 'silver pillow' },
{ id: 'tinnies', name: 'Two warm tinnies', severity: 1, weight: 4, spriteHint: 'gold cans' },
{ id: 'kebab', name: 'A whole kebab', severity: 1, weight: 3, spriteHint: 'foil cylinder' },
{ id: 'someoneElsesId', name: "Someone else's ID", severity: 2, weight: 2, spriteHint: 'small card' },
{ id: 'budgie', name: 'A live budgie', severity: 1, weight: 1, spriteHint: 'green blob (flaps)' },
{ id: 'vapes', name: 'Fourteen vapes', severity: 1, weight: 3, spriteHint: 'neon sticks' },
{ id: 'marker', name: 'Fat permanent marker', severity: 2, weight: 2, spriteHint: 'black stick' },
{ id: 'snags', name: 'Raw sausages (why)', severity: 1, weight: 1, spriteHint: 'pink links' },
];

84
src/data/outfits.ts Normal file
View File

@ -0,0 +1,84 @@
import type { OutfitSlot } from './types';
// Outfit vocabulary. Types/colours the generator draws from and dress-code rules
// predicate on. Slot taxonomy cross-checked against the 90sDJsim wardrobe catalog
// (docs/ASSETS.md §1) so a future art pass maps 1:1.
export interface OutfitTypeDef {
type: string;
weight: number;
colours: readonly string[];
canLogo?: boolean;
canVintage?: boolean;
}
export const PALETTE: Record<string, number> = {
white: 0xf2f2f2,
black: 0x14141c,
grey: 0x8a8a94,
navy: 0x1e2a52,
red: 0xc03434,
green: 0x2e7d46,
blue: 0x3465c0,
pink: 0xe06fa4,
purple: 0x7a4fc0,
yellow: 0xd8c23a,
orange: 0xd07c2e,
brown: 0x6e4a2e,
cream: 0xe8ddc0,
denim: 0x4a6a95,
};
export const OUTFIT_VOCAB: Record<OutfitSlot, readonly OutfitTypeDef[]> = {
shoes: [
{ type: 'sneaker', weight: 5, colours: ['white', 'black', 'red', 'grey'], canLogo: true, canVintage: true },
{ type: 'boot', weight: 2, colours: ['black', 'brown'] },
{ type: 'dress', weight: 2, colours: ['black', 'brown'] },
{ type: 'heel', weight: 2, colours: ['black', 'red', 'pink', 'white'] },
{ type: 'thong', weight: 1, colours: ['black', 'blue', 'yellow'] }, // the footwear, obviously
{ type: 'loafer', weight: 1, colours: ['brown', 'black', 'cream'] },
{ type: 'platform', weight: 1, colours: ['black', 'purple', 'pink'], canVintage: true },
],
legs: [
{ type: 'jeans', weight: 5, colours: ['denim', 'black', 'grey'] },
{ type: 'chinos', weight: 3, colours: ['cream', 'navy', 'brown'] },
{ type: 'skirt', weight: 3, colours: ['black', 'red', 'denim', 'pink'] },
{ type: 'trackies', weight: 2, colours: ['grey', 'black', 'navy'], canLogo: true },
{ type: 'shorts', weight: 2, colours: ['denim', 'cream', 'black'] },
{ type: 'slacks', weight: 2, colours: ['black', 'grey', 'navy'] },
{ type: 'cargo', weight: 1, colours: ['green', 'grey', 'brown'] },
],
top: [
{ type: 'tee', weight: 5, colours: ['white', 'black', 'grey', 'red', 'green', 'navy'], canLogo: true, canVintage: true },
{ type: 'shirt', weight: 4, colours: ['white', 'blue', 'pink', 'cream'] },
{ type: 'singlet', weight: 2, colours: ['white', 'black', 'grey'], canLogo: true },
{ type: 'polo', weight: 2, colours: ['navy', 'white', 'green', 'red'], canLogo: true },
{ type: 'crop', weight: 2, colours: ['white', 'black', 'pink', 'purple'] },
{ type: 'dressShirt', weight: 2, colours: ['white', 'black'] },
{ type: 'jersey', weight: 1, colours: ['red', 'blue', 'yellow'], canLogo: true, canVintage: true },
],
outer: [
{ type: 'none', weight: 6, colours: ['black'] },
{ type: 'blazer', weight: 2, colours: ['black', 'navy', 'grey'] },
{ type: 'bomber', weight: 2, colours: ['black', 'green', 'navy'], canLogo: true, canVintage: true },
{ type: 'hoodie', weight: 2, colours: ['black', 'grey', 'red'], canLogo: true },
{ type: 'leather', weight: 1, colours: ['black', 'brown'], canVintage: true },
{ type: 'puffer', weight: 1, colours: ['black', 'orange', 'purple'], canLogo: true },
],
hair: [
{ type: 'short', weight: 5, colours: ['black', 'brown', 'yellow', 'grey'] },
{ type: 'long', weight: 3, colours: ['black', 'brown', 'yellow', 'red'] },
{ type: 'bun', weight: 2, colours: ['black', 'brown', 'yellow'] },
{ type: 'mullet', weight: 2, colours: ['brown', 'yellow', 'black'], canVintage: true },
{ type: 'shaved', weight: 2, colours: ['black', 'brown'] },
{ type: 'dyed', weight: 1, colours: ['pink', 'green', 'purple', 'blue'] },
],
accessory: [
{ type: 'none', weight: 6, colours: ['black'] },
{ type: 'bucketHat', weight: 2, colours: ['cream', 'black', 'green'], canLogo: true, canVintage: true },
{ type: 'cap', weight: 2, colours: ['black', 'red', 'navy'], canLogo: true },
{ type: 'sunnies', weight: 2, colours: ['black'] }, // sunglasses. at night. a tell in itself
{ type: 'bumbag', weight: 1, colours: ['black', 'purple', 'yellow'], canLogo: true, canVintage: true },
{ type: 'chain', weight: 1, colours: ['yellow', 'grey'] },
],
};

36
src/data/strings/dazza.ts Normal file
View File

@ -0,0 +1,36 @@
// Dazza (management) text messages. Voice: aggressive lowercase, no full stops,
// occasionally terrified of the owner. Rule-bearing texts pair with a
// DressCodeRule id in rules/dressCode.ts.
export interface DazzaLine {
id: string;
text: string;
/** dress-code rule id this text announces, if any */
ruleId?: string;
/** earliest clock-min this can fire */
fromMin: number;
}
export const DAZZA_TEXTS: readonly DazzaLine[] = [
// rule-bearing escalation (design doc §3.1)
{ id: 'rule-basics', text: 'mate its 930 no thongs no singlets we are not a beer garden', ruleId: 'noThongsSinglets', fromMin: 30 },
{ id: 'rule-logos', text: 'seeing too many logos in there. no visible logos from NOW', ruleId: 'noLogos', fromMin: 90 },
{ id: 'rule-sneakers', text: 'white sneakers are OVER. unless vintage. u know the difference', ruleId: 'noWhiteSneakers', fromMin: 150 },
{ id: 'rule-songRequest', text: 'if they look like theyd request a song they dont come in. u know the look', ruleId: 'noSongRequesters', fromMin: 240 },
{ id: 'rule-bucketHats', text: 'bucket hats r making the room feel like a festival toilet queue. gone', ruleId: 'noBucketHats', fromMin: 180 },
{ id: 'rule-sunnies', text: 'anyone wearing sunnies inside at 11pm is a cop or a flog. neither gets in', ruleId: 'noSunnies', fromMin: 120 },
{ id: 'rule-suits', text: 'too corporate in here tonight. no blazers til the suits ive already got leave', ruleId: 'noBlazers', fromMin: 200 },
{ id: 'rule-couples', text: 'no more couples holding hands in the queue its making the room feel married', ruleId: 'noHandHolders', fromMin: 280 },
// vibe commentary
{ id: 'vibe-mid', text: 'why is the floor MID rn. fix it', fromMin: 60 },
{ id: 'vibe-good', text: 'room is going OFF right now whatever ur doing keep doing it', fromMin: 100 },
{ id: 'vibe-queue', text: 'queue looks massive from my office window. love that. keep them out there', fromMin: 80 },
{ id: 'vibe-empty', text: 'i can see the back wall from the bar. the BACK WALL. let some people in', fromMin: 45 },
// 2am philosophy / the owner
{ id: 'owner-scare', text: 'if a bloke called terry shows up he gets in. do NOT stamp terry. please', fromMin: 150 },
{ id: 'philosophy-1', text: 'a nightclub is just a room where everyone agreed to pretend. dont let them stop pretending', fromMin: 300 },
{ id: 'philosophy-2', text: 'do u ever think about how the kebab shop outlives us all', fromMin: 320 },
{ id: 'kayden', text: 'kayden let in a bloke wearing CROCS while u were inside. deal w kayden', fromMin: 120 },
];

140
src/data/types.ts Normal file
View File

@ -0,0 +1,140 @@
// NOT TONIGHT — shared contract types. Source of truth: docs/CONTRACTS.md.
// Post-Phase-0 changes require a CONTRACT CHANGE REQUEST in LANEHANDOVER.md.
export type Archetype =
| 'fresh18' | 'almost18' | 'financeBro' | 'influencer'
| 'regular' | 'ownersMate' | 'quietMessy' | 'inspector' | 'punter';
export type DrunkStage = 'sober' | 'tipsy' | 'loose' | 'messy' | 'maggot';
export type OutfitSlot = 'shoes' | 'legs' | 'top' | 'outer' | 'hair' | 'accessory';
export interface OutfitLayer {
slot: OutfitSlot;
type: string;
colour: string;
logo?: boolean;
vintage?: boolean;
}
export interface IdFakeTells {
peelingLaminate?: boolean;
wrongHologram?: boolean;
jokeName?: boolean;
photoMismatch?: boolean;
}
export interface IdCard {
name: string;
dob: string; // ISO date
expiry: string; // ISO date
photoSeed: number;
fake?: IdFakeTells;
}
export interface PatronFlags {
contraband?: string[];
onGuestList?: boolean;
claimsGuestList?: boolean;
knowsOwner?: boolean;
uvStamped?: boolean;
timesDeniedBefore?: number;
disguised?: boolean;
}
export interface Patron {
id: string;
dollSeed: number;
archetype: Archetype;
age: number;
idCard: IdCard;
outfit: OutfitLayer[];
intoxication: number; // 0..1
tolerance: number; // 0..1
flags: PatronFlags;
}
// ---- rules ----
export interface DressCodeRule {
id: string;
text: string;
activeFrom: number; // clock minutes since 21:00
violates: (p: Patron) => boolean;
}
export type Verdict = 'admit' | 'deny' | 'sobrietyTest' | 'patDown' | 'wait';
export interface HeatStrike {
reason: string;
deferred?: boolean;
}
export interface VerdictOutcome {
vibeDelta: number;
aggroDelta: number;
heatStrike?: HeatStrike;
dazzaText?: string;
}
// ---- night / run state ----
export interface IncidentRecord {
clockMin: number;
kind: string;
patronId?: string;
detail: string;
}
export interface NightState {
venueId: string;
nightIndex: number;
vibe: number; // 0..100
aggro: number; // 0..100
heatStrikes: HeatStrike[];
hype: number;
capacity: { inside: number; licensed: number; clickerShown: number };
clockMin: number; // minutes since 21:00; night ends at 360
location: 'door' | 'floor';
incidents: IncidentRecord[];
}
export interface RegularMemory {
patronId: string;
timesDenied: number;
lastSeenNight: number;
grudge: number; // 0..1
}
export interface GameState {
v: 1;
seed: number;
venueIndex: number;
nightIndex: number;
heatStrikes: HeatStrike[];
regulars: Record<string, RegularMemory>;
pastReports: IncidentRecord[][];
}
// ---- event bus ----
export interface EventMap {
'clock:tick': { clockMin: number };
'night:phaseChange': { location: 'door' | 'floor' };
'radio:call': { urgency: 1 | 2 | 3; line: string };
'door:patronUp': { patron: Patron };
'door:verdict': { patron: Patron; verdict: Verdict; outcome: VerdictOutcome };
'door:clicker': { direction: 'in' | 'out' };
'dazza:text': { text: string; rule?: DressCodeRule };
'floor:infractionSpotted': { patronId: string; kind: 'drunk' | 'stall' | 'contraband' | 'noStamp' | 'fight' };
'floor:ejection': { patronId: string; style: 'clean' | 'messy' };
'beat:tick': { beatIndex: number; audioTimeMs: number };
'audio:location': { location: 'door' | 'floor' };
'meters:delta': { vibe?: number; aggro?: number; hype?: number };
'meters:changed': { vibe: number; aggro: number; hype: number };
'heat:strike': HeatStrike;
}

20
src/main.ts Normal file
View File

@ -0,0 +1,20 @@
import Phaser from 'phaser';
import { BootScene } from './scenes/shared/BootScene';
import { ParadeScene } from './scenes/shared/ParadeScene';
const game = new Phaser.Game({
type: Phaser.AUTO,
width: 640,
height: 360,
pixelArt: true,
backgroundColor: '#0a0a12',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
zoom: Phaser.Scale.MAX_ZOOM,
},
scene: [BootScene, ParadeScene],
});
// debug handle (harmless in prod; used by dev tooling)
(window as unknown as { game: Phaser.Game }).game = game;

29
src/patrons/doll.ts Normal file
View File

@ -0,0 +1,29 @@
import Phaser from 'phaser';
import { dollPlan, type DollPose } from './dollPlan';
import type { Patron } from '../data/types';
// Phaser half of the paper-doll renderer: paints a DollPlan into a cached
// texture. Contract (docs/CONTRACTS.md §5): callers get a texture key and never
// care whether the pixels came from procedural rects or real art.
export function dollTextureKey(p: Patron, pose: DollPose): string {
// Drunk tells are baked into the texture, so intoxication stage is part of
// the cache key (coarse: swayPx bucket, not raw float).
return `doll:${p.dollSeed}:${pose}:${dollPlan(p, pose).swayPx}`;
}
export function renderDoll(scene: Phaser.Scene, p: Patron, pose: DollPose): string {
const key = dollTextureKey(p, pose);
if (scene.textures.exists(key)) return key;
const plan = dollPlan(p, pose);
const canvas = scene.textures.createCanvas(key, plan.width, plan.height);
if (!canvas) return key; // key collision race — texture already exists
const ctx = canvas.getContext();
for (const r of plan.rects) {
ctx.fillStyle = `#${r.colour.toString(16).padStart(6, '0')}`;
ctx.fillRect(r.x, r.y, r.w, r.h);
}
canvas.refresh();
return key;
}

126
src/patrons/dollPlan.ts Normal file
View File

@ -0,0 +1,126 @@
// Pure half of the paper-doll renderer: Patron -> list of coloured rects.
// No Phaser imports — fully unit-testable, deterministic per (patron, pose).
// doll.ts paints these plans into textures; real pixel art later replaces the
// painter while THIS data contract (and renderDoll's signature) stays put.
import { PALETTE } from '../data/outfits';
import { drunkStage } from '../rules/drunk';
import type { Patron } from '../data/types';
export type DollPose = 'queue' | 'idPhoto' | 'floorTopDown';
export interface PlanRect {
x: number;
y: number;
w: number;
h: number;
colour: number;
}
export interface DollPlan {
width: number;
height: number;
rects: PlanRect[];
/** render-time tell: sway amplitude in px (0 = steady) */
swayPx: number;
}
export const DOLL_SIZES: Record<DollPose, { width: number; height: number }> = {
queue: { width: 32, height: 48 },
idPhoto: { width: 24, height: 24 },
floorTopDown: { width: 16, height: 16 },
};
const SKIN_TONES = [0xf1c9a5, 0xd9a066, 0xa5673f, 0x6b4423, 0x8d5524, 0xe8b088] as const;
// A tiny deterministic hash so body build/skin derive from dollSeed without
// consuming RNG stream state (plans must be reproducible in isolation).
function mix(seed: number, salt: number): number {
let h = (seed ^ (salt * 0x9e3779b9)) >>> 0;
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0;
h = Math.imul(h ^ (h >>> 13), 0x45d9f3b) >>> 0;
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
const colourOf = (name: string): number => PALETTE[name] ?? 0xff00ff;
function layerColour(p: Patron, slot: string): number | undefined {
const layer = p.outfit.find((l) => l.slot === slot);
if (!layer || layer.type === 'none') return undefined;
return colourOf(layer.colour);
}
export function dollPlan(p: Patron, pose: DollPose): DollPlan {
const { width, height } = DOLL_SIZES[pose];
const skin = SKIN_TONES[Math.floor(mix(p.dollSeed, 1) * SKIN_TONES.length)]!;
const build = 0.8 + mix(p.dollSeed, 2) * 0.4; // 0.8..1.2 body width factor
const stage = drunkStage(p.intoxication);
const swayPx = stage === 'loose' ? 1 : stage === 'messy' ? 2 : stage === 'maggot' ? 3 : 0;
const rects: PlanRect[] = [];
const seedForPhoto = pose === 'idPhoto' ? p.idCard.photoSeed : p.dollSeed;
const photoSkin = SKIN_TONES[Math.floor(mix(seedForPhoto, 1) * SKIN_TONES.length)]!;
const hair = layerColour(p, 'hair') ?? 0x14141c;
const photoHair = pose === 'idPhoto' && p.idCard.photoSeed !== p.dollSeed
? (Object.values(PALETTE)[Math.floor(mix(seedForPhoto, 3) * 10)] ?? hair)
: hair;
if (pose === 'idPhoto') {
// Head-and-shoulders on a flat card background.
rects.push({ x: 0, y: 0, w: width, h: height, colour: 0xdad4c4 });
rects.push({ x: 7, y: 6, w: 10, h: 10, colour: photoSkin }); // face
rects.push({ x: 6, y: 3, w: 12, h: 4, colour: photoHair }); // hair
rects.push({ x: 5, y: 16, w: 14, h: 8, colour: layerColour(p, 'top') ?? 0x555555 }); // shoulders
return { width, height, rects, swayPx: 0 };
}
if (pose === 'floorTopDown') {
// Seen from above: hair blob over shoulders (top colour).
const top = layerColour(p, 'outer') ?? layerColour(p, 'top') ?? 0x555555;
rects.push({ x: 3, y: 3, w: 10, h: 10, colour: top }); // shoulders
rects.push({ x: 5, y: 5, w: 6, h: 6, colour: hair }); // head/hair
return { width, height, rects, swayPx };
}
// queue pose — full front-facing figure, 32x48.
const bw = Math.round(12 * build); // body width
const cx = Math.floor(width / 2);
const shoes = layerColour(p, 'shoes') ?? 0x14141c;
const legs = layerColour(p, 'legs') ?? 0x333344;
const top = layerColour(p, 'top') ?? 0x555566;
const outer = layerColour(p, 'outer');
const acc = p.outfit.find((l) => l.slot === 'accessory');
rects.push({ x: cx - 4, y: 44, w: 3, h: 4, colour: shoes }); // L shoe
rects.push({ x: cx + 1, y: 44, w: 3, h: 4, colour: shoes }); // R shoe
rects.push({ x: cx - 4, y: 30, w: 8, h: 14, colour: legs }); // legs
rects.push({ x: cx - Math.floor(bw / 2), y: 16, w: bw, h: 14, colour: top }); // torso
if (outer !== undefined) {
rects.push({ x: cx - Math.floor(bw / 2) - 1, y: 16, w: 3, h: 13, colour: outer }); // jacket L
rects.push({ x: cx + Math.floor(bw / 2) - 2, y: 16, w: 3, h: 13, colour: outer }); // jacket R
}
const topLayer = p.outfit.find((l) => l.slot === 'top');
if (topLayer?.logo) rects.push({ x: cx - 2, y: 20, w: 4, h: 3, colour: 0xffffff }); // visible logo
rects.push({ x: cx - 3, y: 6, w: 6, h: 9, colour: skin }); // face
rects.push({ x: cx - 4, y: 3, w: 8, h: 4, colour: hair }); // hair
const hairLayer = p.outfit.find((l) => l.slot === 'hair');
if (hairLayer?.type === 'long') rects.push({ x: cx - 5, y: 6, w: 2, h: 8, colour: hair });
if (hairLayer?.type === 'mullet') rects.push({ x: cx - 4, y: 12, w: 8, h: 3, colour: hair });
if (acc && acc.type !== 'none') {
const accColour = colourOf(acc.colour);
if (acc.type === 'bucketHat' || acc.type === 'cap') rects.push({ x: cx - 5, y: 2, w: 10, h: 3, colour: accColour });
if (acc.type === 'sunnies') rects.push({ x: cx - 3, y: 8, w: 6, h: 2, colour: 0x000000 });
if (acc.type === 'bumbag') rects.push({ x: cx - 5, y: 28, w: 10, h: 3, colour: accColour });
if (acc.type === 'chain') rects.push({ x: cx - 2, y: 16, w: 4, h: 1, colour: 0xf0d060 });
}
// drunk tells: bloodshot eye pixels from 'loose' up
if (swayPx > 0) {
rects.push({ x: cx - 2, y: 9, w: 1, h: 1, colour: 0xcc3333 });
rects.push({ x: cx + 1, y: 9, w: 1, h: 1, colour: 0xcc3333 });
}
return { width, height, rects, swayPx };
}

130
src/patrons/generator.ts Normal file
View File

@ -0,0 +1,130 @@
import type { RngStream, SeededRNG } from '../core/SeededRNG';
import { ageOn } from '../core/GameClock';
import { ARCHETYPES, ARCHETYPE_BY_KEY } from '../data/archetypes';
import { CONTRABAND } from '../data/contraband';
import { OUTFIT_VOCAB } from '../data/outfits';
import type { Archetype, IdCard, OutfitLayer, OutfitSlot, Patron } from '../data/types';
const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const;
const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const;
const JOKE_NAMES = ['M. Lovin', 'B. Drinkwater', 'Rusty Shackleford', 'A. Nonymous', 'J. Kebab'] as const;
let serial = 0;
/** Test hook — resets the patron id counter so runs are comparable. */
export function _resetPatronSerial(): void {
serial = 0;
}
function isoDaysFrom(base: Date, days: number): string {
const d = new Date(base.getTime());
d.setDate(d.getDate() + days);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** DOB that makes the patron `years` old, offset by `extraDays`, on nightDate. */
function dobForAge(nightDate: Date, years: number, extraDays: number): string {
const d = new Date(nightDate.getTime());
d.setFullYear(d.getFullYear() - years);
return isoDaysFrom(d, -extraDays);
}
function rollOutfit(rng: RngStream): OutfitLayer[] {
const layers: OutfitLayer[] = [];
for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
const def = rng.weighted(OUTFIT_VOCAB[slot].map((d) => [d, d.weight] as const));
const layer: OutfitLayer = { slot, type: def.type, colour: rng.pick(def.colours) };
if (def.canLogo && rng.chance(0.35)) layer.logo = true;
if (def.canVintage && rng.chance(0.2)) layer.vintage = true;
layers.push(layer);
}
return layers;
}
function rollId(rng: RngStream, nightDate: Date, trueAge: number, dollSeed: number, wantFake: boolean): IdCard {
const name = `${rng.pick(FIRST)} ${rng.pick(LAST)}`;
if (!wantFake) {
// Real ID. DOBs cluster near the 18 boundary so the birthday math stays live.
const extraDays = trueAge === 18 ? rng.int(0, 90) : rng.int(0, 364);
return {
name,
dob: dobForAge(nightDate, trueAge, extraDays),
expiry: isoDaysFrom(nightDate, rng.int(30, 365 * 4)),
photoSeed: dollSeed,
};
}
// Fake: claims 18-19ish, carries 1-2 visible tells. Occasionally the "fake" is
// just an expired real card — expiry IS the tell then.
const claimedAge = 18 + rng.int(0, 1);
const tellPool = ['peelingLaminate', 'wrongHologram', 'jokeName', 'photoMismatch', 'expired'] as const;
const first = rng.pick(tellPool);
const second = rng.chance(0.4) ? rng.pick(tellPool) : undefined;
const tells = new Set([first, second].filter((t): t is (typeof tellPool)[number] => t !== undefined));
const card: IdCard = {
name: tells.has('jokeName') ? rng.pick(JOKE_NAMES) : name,
dob: dobForAge(nightDate, claimedAge, rng.int(10, 200)),
expiry: tells.has('expired') ? isoDaysFrom(nightDate, -rng.int(5, 400)) : isoDaysFrom(nightDate, rng.int(30, 365 * 3)),
photoSeed: tells.has('photoMismatch') ? dollSeed + 7777 : dollSeed,
fake: {},
};
if (tells.has('peelingLaminate')) card.fake!.peelingLaminate = true;
if (tells.has('wrongHologram')) card.fake!.wrongHologram = true;
if (tells.has('jokeName')) card.fake!.jokeName = true;
if (tells.has('photoMismatch')) card.fake!.photoMismatch = true;
return card;
}
export interface GeneratorContext {
rng: SeededRNG;
nightDate: Date;
}
export function generatePatron(ctx: GeneratorContext, clockMin: number, archetypeOverride?: Archetype): Patron {
const rng = ctx.rng.stream('patrons');
const def = archetypeOverride
? ARCHETYPE_BY_KEY.get(archetypeOverride)!
: rng.weighted(ARCHETYPES.map((a) => [a, a.weight] as const));
const dollSeed = rng.int(0, 2 ** 31 - 1);
const age = rng.int(def.ageRange[0], def.ageRange[1]);
const wantFake = rng.chance(def.chances.fakeId ?? 0);
const idCard = rollId(rng, ctx.nightDate, age, dollSeed, wantFake);
// Crowd arrives progressively drunker as the night wears on.
const lateDrift = Math.min(0.35, (clockMin / 360) * 0.45);
const [lo, hi] = def.startDrunkRange;
const intoxication = Math.min(1, lo + rng.next() * (hi - lo) + lateDrift * rng.next());
const flags: Patron['flags'] = {};
if (rng.chance(def.chances.contraband ?? 0)) {
const item = rng.weighted(CONTRABAND.map((c) => [c, c.weight] as const));
flags.contraband = [item.id];
if (rng.chance(0.2)) {
const extra = rng.weighted(CONTRABAND.map((c) => [c, c.weight] as const));
if (extra.id !== item.id) flags.contraband.push(extra.id);
}
}
if (rng.chance(def.chances.onGuestList ?? 0)) flags.onGuestList = true;
if (flags.onGuestList || rng.chance(def.chances.claimsGuestList ?? 0)) flags.claimsGuestList = true;
if (rng.chance(def.chances.knowsOwner ?? 0)) flags.knowsOwner = true;
return {
id: `p${serial++}`,
dollSeed,
archetype: def.key,
age,
idCard,
outfit: rollOutfit(rng),
intoxication,
tolerance: def.toleranceRange[0] + rng.next() * (def.toleranceRange[1] - def.toleranceRange[0]),
flags,
};
}
/** True iff the ID itself (not the face in front of you) reads as valid tonight. */
export function idAge(patron: Patron, nightDate: Date): number {
return ageOn(patron.idCard.dob, nightDate);
}

30
src/patrons/memory.ts Normal file
View File

@ -0,0 +1,30 @@
import type { GameState, Patron, RegularMemory } from '../data/types';
// Regulars' grudge store. Behaviour wiring (disguises, dialogue callbacks) lands
// in Phase 2/3 — this is just the persistent bookkeeping, run-scoped in GameState.
export function recordDenial(state: GameState, patron: Patron, nightIndex: number): RegularMemory {
const existing = state.regulars[patron.id];
const mem: RegularMemory = existing ?? {
patronId: patron.id,
timesDenied: 0,
lastSeenNight: nightIndex,
grudge: 0,
};
mem.timesDenied += 1;
mem.lastSeenNight = nightIndex;
mem.grudge = Math.min(1, mem.grudge + 0.34); // three denials = maximum grudge
state.regulars[patron.id] = mem;
return mem;
}
export function recordSeen(state: GameState, patronId: string, nightIndex: number): void {
const mem = state.regulars[patronId];
if (mem) mem.lastSeenNight = nightIndex;
}
/** A regular with maxed grudge comes back disguised (bad fake moustache era). */
export function shouldReturnDisguised(state: GameState, patronId: string): boolean {
const mem = state.regulars[patronId];
return !!mem && mem.timesDenied >= 2;
}

17
src/rules/drunk.ts Normal file
View File

@ -0,0 +1,17 @@
import type { DrunkStage } from '../data/types';
// Single authority for intoxication thresholds. Renderer reads stages for tells;
// scenes read them for gameplay; nobody hardcodes numbers.
export function drunkStage(intoxication: number): DrunkStage {
if (intoxication < 0.2) return 'sober';
if (intoxication < 0.45) return 'tipsy';
if (intoxication < 0.65) return 'loose';
if (intoxication < 0.85) return 'messy';
return 'maggot';
}
/** Visible-at-the-rope tells begin at 'loose'. */
export function hasVisibleTells(intoxication: number): boolean {
const s = drunkStage(intoxication);
return s === 'loose' || s === 'messy' || s === 'maggot';
}

View File

@ -0,0 +1,13 @@
import Phaser from 'phaser';
// Asset-less boot: everything v0.x is procedural, so boot just forwards to the
// parade demo. Phase 2 integration will route to a menu/night flow here.
export class BootScene extends Phaser.Scene {
constructor() {
super('Boot');
}
create(): void {
this.scene.start('Parade');
}
}

View File

@ -0,0 +1,23 @@
import Phaser from 'phaser';
import type { EventBus } from '../../core/EventBus';
// Text-only meter readout. LANE-JUICE's MeterHud replaces this at integration.
export class HudStub {
private readonly text: Phaser.GameObjects.Text;
private readonly off: () => void;
constructor(scene: Phaser.Scene, bus: EventBus) {
this.text = scene.add
.text(4, 4, 'vibe 50 aggro 0 hype 1.0', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' })
.setDepth(1000)
.setScrollFactor(0);
this.off = bus.on('meters:changed', ({ vibe, aggro, hype }) => {
this.text.setText(`vibe ${Math.round(vibe)} aggro ${Math.round(aggro)} hype ${hype.toFixed(1)}`);
});
}
destroy(): void {
this.off();
this.text.destroy();
}
}

View File

@ -0,0 +1,182 @@
import Phaser from 'phaser';
import { EventBus } from '../../core/EventBus';
import { GameClock } from '../../core/GameClock';
import { SeededRNG } from '../../core/SeededRNG';
import { Meters, freshNightState } from '../../core/meters';
import { drunkStage } from '../../rules/drunk';
import { generatePatron, idAge, _resetPatronSerial } from '../../patrons/generator';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import type { Patron } from '../../data/types';
import { HudStub } from './HudStub';
const W = 640;
const H = 360;
interface Walker {
patron: Patron;
root: Phaser.GameObjects.Container;
doll: Phaser.GameObjects.Image;
speed: number;
}
// Phase-0 proof scene: generator + doll renderer + RNG determinism + clock all
// visibly working. Patrons stream across a rainy street; their ID card renders
// from the same data record. SPACE reseeds, D toggles ID cards.
export class ParadeScene extends Phaser.Scene {
private bus!: EventBus;
private clock!: GameClock;
private rng!: SeededRNG;
private meters!: Meters;
private hud!: HudStub;
private walkers: Walker[] = [];
private seed = 4207;
private spawnAccMs = 0;
private showIds = true;
private seedText!: Phaser.GameObjects.Text;
private clockText!: Phaser.GameObjects.Text;
constructor() {
super('Parade');
}
create(): void {
this.buildWorld();
this.resetRun(this.seed);
this.input.keyboard?.on('keydown-SPACE', () => this.resetRun(this.seed + 1));
this.input.keyboard?.on('keydown-D', () => {
this.showIds = !this.showIds;
for (const w of this.walkers) {
(w.root.getByName('card') as Phaser.GameObjects.Container | null)?.setVisible(this.showIds);
}
});
}
private buildWorld(): void {
// street
this.add.rectangle(W / 2, H - 40, W, 80, 0x1a1a24); // footpath
this.add.rectangle(W / 2, H / 2 - 40, W, H - 160, 0x0d0d16); // wall of night
// kebab shop glow, stage left — the moral centre of the game
this.add.rectangle(70, 120, 120, 90, 0x2a1c08);
this.add.rectangle(70, 96, 100, 18, 0xd8a020, 0.9);
this.add.text(34, 90, 'KEBABS', { fontFamily: 'monospace', fontSize: '12px', color: '#3a2404' });
this.add.rectangle(70, 150, 100, 40, 0xe8c060, 0.12);
// venue door, stage right
this.add.rectangle(W - 60, 130, 90, 110, 0x181828);
this.add.rectangle(W - 60, 90, 70, 14, 0xd03470, 0.9);
this.add.text(W - 88, 84, 'NOT TONIGHT', { fontFamily: 'monospace', fontSize: '9px', color: '#2a0818' });
this.add.rectangle(W - 60, 150, 26, 54, 0x05050a);
// neon reflections on the footpath
this.add.rectangle(70, H - 60, 90, 6, 0xd8a020, 0.15);
this.add.rectangle(W - 60, H - 60, 60, 6, 0xd03470, 0.15);
// rain
const rainG = this.add.graphics();
rainG.fillStyle(0x8899bb, 1);
rainG.fillRect(0, 0, 1, 4);
rainG.generateTexture('rainDrop', 1, 4);
rainG.destroy();
this.add.particles(0, 0, 'rainDrop', {
x: { min: 0, max: W },
y: -6,
lifespan: 900,
speedY: { min: 220, max: 300 },
speedX: { min: -30, max: -10 },
quantity: 2,
alpha: { start: 0.5, end: 0.15 },
});
this.seedText = this.add.text(4, H - 12, '', { fontFamily: 'monospace', fontSize: '8px', color: '#667' });
this.clockText = this.add.text(W - 60, 4, '', { fontFamily: 'monospace', fontSize: '8px', color: '#9fe8a0' });
this.add.text(4, 14, 'SPACE reseed · D toggle IDs', { fontFamily: 'monospace', fontSize: '8px', color: '#556' });
}
private resetRun(seed: number): void {
this.seed = seed;
for (const w of this.walkers) w.root.destroy();
this.walkers = [];
_resetPatronSerial();
this.bus?.removeAll();
this.bus = new EventBus();
this.rng = new SeededRNG(seed);
this.clock = new GameClock(this.bus);
this.meters?.destroy();
this.meters = new Meters(this.bus, freshNightState('theRoyal', 0, 80));
this.hud?.destroy();
this.hud = new HudStub(this, this.bus);
this.clock.start();
this.bus.on('clock:tick', () => {
// idle proof that the meter pipeline works: hype breathes with the clock
this.bus.emit('meters:delta', { vibe: 0 });
});
this.seedText.setText(`seed ${seed}`);
}
private spawnWalker(): void {
const patron = generatePatron({ rng: this.rng, nightDate: this.clock.nightDate }, this.clock.clockMin);
const plan = dollPlan(patron, 'queue');
const key = renderDoll(this, patron, 'queue');
const y = H - 78 + (this.walkers.length % 5) * 6;
const root = this.add.container(-40, y);
const doll = this.add.image(0, 0, key).setOrigin(0.5, 1);
root.add(doll);
const stage = drunkStage(patron.intoxication);
const label = this.add
.text(0, 4, `${patron.archetype} ${patron.age} ${stage !== 'sober' ? stage : ''}`.trim(), {
fontFamily: 'monospace',
fontSize: '7px',
color: stage === 'maggot' || stage === 'messy' ? '#e06060' : '#556',
})
.setOrigin(0.5, 0);
root.add(label);
const card = this.add.container(0, -plan.height - 18);
card.setName('card');
const idKey = renderDoll(this, patron, 'idPhoto');
card.add(this.add.rectangle(0, 0, 58, 30, 0xdad4c4).setStrokeStyle(1, 0x555555));
card.add(this.add.image(-22, 0, idKey).setScale(1));
const fakeMark = patron.idCard.fake ? ' ?' : '';
card.add(
this.add
.text(-8, -11, `${patron.idCard.name}\nID ${idAge(patron, this.clock.nightDate)}${fakeMark}`, {
fontFamily: 'monospace',
fontSize: '7px',
color: '#222',
})
.setOrigin(0, 0),
);
card.setVisible(this.showIds);
root.add(card);
root.setDepth(y);
this.walkers.push({ patron, root, doll, speed: 26 + (patron.dollSeed % 20) - plan.swayPx * 5 });
}
override update(_time: number, deltaMs: number): void {
this.clock.update(deltaMs);
this.clockText.setText(this.clock.label);
this.spawnAccMs += deltaMs;
const spawnEvery = 700;
while (this.spawnAccMs > spawnEvery && this.walkers.length < 24) {
this.spawnAccMs -= spawnEvery;
this.spawnWalker();
}
for (const w of [...this.walkers]) {
w.root.x += (w.speed * deltaMs) / 1000;
const sway = dollPlan(w.patron, 'queue').swayPx;
if (sway > 0) w.doll.x = Math.sin(w.root.x / (14 - sway * 3)) * sway;
if (w.root.x > W + 40) {
w.root.destroy();
this.walkers.splice(this.walkers.indexOf(w), 1);
}
}
}
}

27
tests/beatclock.test.ts Normal file
View File

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { EventBus } from '../src/core/EventBus';
import { StubBeatClock } from '../src/core/StubBeatClock';
describe('StubBeatClock', () => {
it('emits sequential beats at the configured BPM', () => {
const bus = new EventBus();
const beats: { beatIndex: number; audioTimeMs: number }[] = [];
bus.on('beat:tick', (b) => beats.push(b));
const clock = new StubBeatClock(bus, 120); // 500ms interval
clock.start();
for (let i = 0; i < 100; i++) clock.update(37); // ragged frame times
expect(beats.length).toBe(Math.floor((100 * 37) / 500));
expect(beats.map((b) => b.beatIndex)).toEqual(beats.map((_, i) => i));
// beat timestamps land on exact multiples of the interval
for (const [i, b] of beats.entries()) expect(b.audioTimeMs).toBeCloseTo((i + 1) * 500, 6);
});
it('does not emit when stopped', () => {
const bus = new EventBus();
let count = 0;
bus.on('beat:tick', () => count++);
const clock = new StubBeatClock(bus, 128);
clock.update(10_000);
expect(count).toBe(0);
});
});

69
tests/clock.test.ts Normal file
View File

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { EventBus } from '../src/core/EventBus';
import { GameClock, ageOn, type ClockConfig } from '../src/core/GameClock';
const FAST: ClockConfig = { nightClockMinutes: 360, nightRealMinutes: 13, nightDate: '2026-07-18' };
describe('GameClock', () => {
it('emits one tick per in-game minute, in order', () => {
const bus = new EventBus();
const ticks: number[] = [];
bus.on('clock:tick', ({ clockMin }) => ticks.push(clockMin));
const clock = new GameClock(bus, FAST);
clock.start();
const msPerClockMin = (FAST.nightRealMinutes * 60_000) / FAST.nightClockMinutes;
for (let i = 0; i < 50; i++) clock.update(msPerClockMin / 5); // uneven small steps
expect(ticks).toEqual(Array.from({ length: ticks.length }, (_, i) => i));
expect(clock.clockMin).toBe(ticks[ticks.length - 1]);
});
it('does not advance while paused or before start', () => {
const bus = new EventBus();
const clock = new GameClock(bus, FAST);
clock.update(10_000);
expect(clock.clockMin).toBe(0);
clock.start();
clock.update(5_000);
const at = clock.clockMin;
clock.pause();
clock.update(60_000);
expect(clock.clockMin).toBe(at);
});
it('caps at night end', () => {
const bus = new EventBus();
const clock = new GameClock(bus, FAST);
clock.start();
clock.update(FAST.nightRealMinutes * 60_000 * 3);
expect(clock.clockMin).toBe(360);
expect(clock.isOver).toBe(true);
});
it('formats the label from a 9 PM start', () => {
const bus = new EventBus();
const clock = new GameClock(bus, FAST);
expect(clock.label).toBe('9:00 PM');
});
});
describe('ageOn (the ID birthday math)', () => {
const night = new Date('2026-07-18T00:00:00');
it('18th birthday IS tonight → 18 (they get in)', () => {
expect(ageOn('2008-07-18', night)).toBe(18);
});
it('18th birthday is tomorrow → 17 (denied, so close)', () => {
expect(ageOn('2008-07-19', night)).toBe(17);
});
it('birthday was yesterday → 18', () => {
expect(ageOn('2008-07-17', night)).toBe(18);
});
it('leap-day DOB counts the year correctly', () => {
expect(ageOn('2008-02-29', night)).toBe(18);
expect(ageOn('2008-02-29', new Date('2026-02-28T00:00:00'))).toBe(17);
expect(ageOn('2008-02-29', new Date('2026-03-01T00:00:00'))).toBe(18);
});
});

72
tests/dollplan.test.ts Normal file
View File

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { dollPlan, DOLL_SIZES } from '../src/patrons/dollPlan';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import type { Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
const somePatrons = (seed: number, n: number): Patron[] => {
_resetPatronSerial();
const ctx = { rng: new SeededRNG(seed), nightDate: NIGHT };
return Array.from({ length: n }, () => generatePatron(ctx, 0));
};
describe('dollPlan', () => {
it('is deterministic per (patron, pose)', () => {
for (const p of somePatrons(5, 20)) {
expect(dollPlan(p, 'queue')).toEqual(dollPlan(p, 'queue'));
expect(dollPlan(p, 'idPhoto')).toEqual(dollPlan(p, 'idPhoto'));
expect(dollPlan(p, 'floorTopDown')).toEqual(dollPlan(p, 'floorTopDown'));
}
});
it('respects the pose size contract and stays in bounds', () => {
for (const p of somePatrons(9, 30)) {
for (const pose of ['queue', 'idPhoto', 'floorTopDown'] as const) {
const plan = dollPlan(p, pose);
expect(plan.width).toBe(DOLL_SIZES[pose].width);
expect(plan.height).toBe(DOLL_SIZES[pose].height);
for (const r of plan.rects) {
expect(r.x).toBeGreaterThanOrEqual(-2); // jacket edges may hug the border
expect(r.x + r.w).toBeLessThanOrEqual(plan.width + 2);
expect(r.y).toBeGreaterThanOrEqual(0);
expect(r.y + r.h).toBeLessThanOrEqual(plan.height);
}
}
}
});
it('drunk stages produce sway + bloodshot tells; sober does not', () => {
const [p] = somePatrons(3, 1);
const sober = dollPlan({ ...p!, intoxication: 0.1 }, 'queue');
const maggot = dollPlan({ ...p!, intoxication: 0.95 }, 'queue');
expect(sober.swayPx).toBe(0);
expect(maggot.swayPx).toBe(3);
expect(maggot.rects.length).toBeGreaterThan(sober.rects.length); // eye pixels
});
it('photoMismatch renders the ID photo from the WRONG seed (visible tell)', () => {
const [p] = somePatrons(21, 1);
const honest: Patron = { ...p!, idCard: { ...p!.idCard, photoSeed: p!.dollSeed, fake: undefined } };
const mismatched: Patron = {
...p!,
idCard: { ...p!.idCard, photoSeed: p!.dollSeed + 7777, fake: { photoMismatch: true } },
};
expect(dollPlan(mismatched, 'idPhoto')).not.toEqual(dollPlan(honest, 'idPhoto'));
// and the patron themselves renders identically — only the card lies
expect(dollPlan(mismatched, 'queue')).toEqual(dollPlan(honest, 'queue'));
});
it('outfit data drives visible pixels: white vs black sneakers differ', () => {
const [p] = somePatrons(33, 1);
const white: Patron = {
...p!,
outfit: p!.outfit.map((l) => (l.slot === 'shoes' ? { ...l, type: 'sneaker', colour: 'white' } : l)),
};
const black: Patron = {
...p!,
outfit: p!.outfit.map((l) => (l.slot === 'shoes' ? { ...l, type: 'sneaker', colour: 'black' } : l)),
};
expect(dollPlan(white, 'queue')).not.toEqual(dollPlan(black, 'queue'));
});
});

54
tests/eventbus.test.ts Normal file
View File

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { EventBus } from '../src/core/EventBus';
describe('EventBus', () => {
it('delivers typed payloads to subscribers', () => {
const bus = new EventBus();
const fn = vi.fn();
bus.on('clock:tick', fn);
bus.emit('clock:tick', { clockMin: 5 });
expect(fn).toHaveBeenCalledWith({ clockMin: 5 });
});
it('on() returns an unsubscribe function', () => {
const bus = new EventBus();
const fn = vi.fn();
const off = bus.on('clock:tick', fn);
off();
bus.emit('clock:tick', { clockMin: 1 });
expect(fn).not.toHaveBeenCalled();
});
it('once() fires exactly once', () => {
const bus = new EventBus();
const fn = vi.fn();
bus.once('clock:tick', fn);
bus.emit('clock:tick', { clockMin: 1 });
bus.emit('clock:tick', { clockMin: 2 });
expect(fn).toHaveBeenCalledTimes(1);
});
it('unsubscribing a later handler during emit prevents its delivery', () => {
const bus = new EventBus();
const calls: string[] = [];
let offB: () => void = () => {};
bus.on('clock:tick', () => {
calls.push('a');
offB();
});
offB = bus.on('clock:tick', () => calls.push('b'));
bus.emit('clock:tick', { clockMin: 1 });
expect(calls).toEqual(['a']);
});
it('subscribing during emit does not deliver to the new handler this emit', () => {
const bus = new EventBus();
const calls: string[] = [];
bus.on('clock:tick', () => {
calls.push('a');
bus.on('clock:tick', () => calls.push('late'));
});
bus.emit('clock:tick', { clockMin: 1 });
expect(calls).toEqual(['a']);
});
});

95
tests/generator.test.ts Normal file
View File

@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { ageOn } from '../src/core/GameClock';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import type { Patron } from '../src/data/types';
const NIGHT = new Date('2026-07-18T00:00:00');
const ctx = (seed: number): GeneratorContext => ({ rng: new SeededRNG(seed), nightDate: NIGHT });
const batch = (seed: number, n: number, clockMin = 0): Patron[] => {
_resetPatronSerial();
const c = ctx(seed);
return Array.from({ length: n }, () => generatePatron(c, clockMin));
};
beforeEach(() => _resetPatronSerial());
describe('generatePatron', () => {
it('is deterministic: same seed → identical batch', () => {
expect(batch(42, 50)).toEqual(batch(42, 50));
});
it('different seeds → different batches', () => {
const a = batch(1, 20).map((p) => p.dollSeed);
const b = batch(2, 20).map((p) => p.dollSeed);
expect(a).not.toEqual(b);
});
it('real IDs never carry fake tells and their DOB matches true age', () => {
for (const p of batch(7, 300)) {
if (!p.idCard.fake) {
expect(ageOn(p.idCard.dob, NIGHT)).toBe(p.age);
expect(p.age).toBeGreaterThanOrEqual(18);
}
}
});
it('every fake ID has at least one detectable tell', () => {
const fakes = batch(11, 600).filter((p) => p.idCard.fake);
expect(fakes.length).toBeGreaterThan(0);
for (const p of fakes) {
const f = p.idCard.fake!;
const expired = new Date(`${p.idCard.expiry}T00:00:00`) < NIGHT;
const hasTell =
!!f.peelingLaminate || !!f.wrongHologram || !!f.jokeName || !!f.photoMismatch || expired;
expect(hasTell).toBe(true);
}
});
it('almost18s are 17 with fake IDs claiming 18+; fresh18s are genuinely 18', () => {
_resetPatronSerial();
const c = ctx(13);
for (let i = 0; i < 40; i++) {
const kid = generatePatron(c, 0, 'almost18');
expect(kid.age).toBe(17);
expect(kid.idCard.fake).toBeDefined();
expect(ageOn(kid.idCard.dob, NIGHT)).toBeGreaterThanOrEqual(18);
const fresh = generatePatron(c, 0, 'fresh18');
expect(fresh.age).toBe(18);
expect(fresh.idCard.fake).toBeUndefined();
}
});
it('photoMismatch is the only case where photoSeed differs from dollSeed', () => {
for (const p of batch(17, 600)) {
if (p.idCard.fake?.photoMismatch) expect(p.idCard.photoSeed).not.toBe(p.dollSeed);
else expect(p.idCard.photoSeed).toBe(p.dollSeed);
}
});
it('late crowd arrives drunker on average', () => {
const early = batch(23, 200, 0);
const late = batch(23, 200, 300);
const avg = (ps: Patron[]) => ps.reduce((s, p) => s + p.intoxication, 0) / ps.length;
expect(avg(late)).toBeGreaterThan(avg(early) + 0.05);
});
it('outfits fill every slot and intoxication/tolerance stay in 0..1', () => {
for (const p of batch(29, 100)) {
expect(p.outfit.map((l) => l.slot).sort()).toEqual(
['accessory', 'hair', 'legs', 'outer', 'shoes', 'top'],
);
expect(p.intoxication).toBeGreaterThanOrEqual(0);
expect(p.intoxication).toBeLessThanOrEqual(1);
expect(p.tolerance).toBeGreaterThanOrEqual(0);
expect(p.tolerance).toBeLessThanOrEqual(1);
}
});
it('guest-list truth implies the claim (liars exist, honest listers do not hide)', () => {
for (const p of batch(31, 400)) {
if (p.flags.onGuestList) expect(p.flags.claimsGuestList).toBe(true);
}
});
});

40
tests/memory.test.ts Normal file
View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { freshGameState } from '../src/core/save';
import { recordDenial, shouldReturnDisguised } from '../src/patrons/memory';
import { SeededRNG } from '../src/core/SeededRNG';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
const patron = () => {
_resetPatronSerial();
return generatePatron({ rng: new SeededRNG(1), nightDate: new Date('2026-07-18T00:00:00') }, 0);
};
describe('regular memory', () => {
it('accumulates denials and grudge, capped at 1', () => {
const state = freshGameState(1);
const p = patron();
recordDenial(state, p, 0);
recordDenial(state, p, 1);
const mem = recordDenial(state, p, 2);
expect(mem.timesDenied).toBe(3);
expect(mem.grudge).toBe(1);
expect(mem.lastSeenNight).toBe(2);
});
it('two denials → returns disguised (the bad-moustache rule)', () => {
const state = freshGameState(1);
const p = patron();
expect(shouldReturnDisguised(state, p.id)).toBe(false);
recordDenial(state, p, 0);
expect(shouldReturnDisguised(state, p.id)).toBe(false);
recordDenial(state, p, 1);
expect(shouldReturnDisguised(state, p.id)).toBe(true);
});
it('memory survives a save/load roundtrip shape-wise', () => {
const state = freshGameState(1);
recordDenial(state, patron(), 0);
const json = JSON.parse(JSON.stringify(state)) as typeof state;
expect(json.regulars).toEqual(state.regulars);
});
});

61
tests/meters.test.ts Normal file
View File

@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { EventBus } from '../src/core/EventBus';
import { Meters, freshNightState } from '../src/core/meters';
const setup = () => {
const bus = new EventBus();
const state = freshNightState('theRoyal', 0, 80);
const meters = new Meters(bus, state);
return { bus, state, meters };
};
describe('Meters', () => {
it('applies deltas and emits meters:changed', () => {
const { bus, state } = setup();
const fn = vi.fn();
bus.on('meters:changed', fn);
bus.emit('meters:delta', { vibe: 10, aggro: 5 });
expect(state.vibe).toBe(60);
expect(state.aggro).toBe(5);
expect(fn).toHaveBeenCalledWith({ vibe: 60, aggro: 5, hype: 1 });
});
it('clamps vibe/aggro to 0..100 and hype to 1..3', () => {
const { bus, state } = setup();
bus.emit('meters:delta', { vibe: 999, aggro: -50, hype: 99 });
expect(state.vibe).toBe(100);
expect(state.aggro).toBe(0);
expect(state.hype).toBe(3);
bus.emit('meters:delta', { vibe: -999 });
expect(state.vibe).toBe(0);
});
it('hype multiplies positive vibe only — never softens a hit', () => {
const { bus, state } = setup();
bus.emit('meters:delta', { hype: 1 }); // hype -> 2
bus.emit('meters:delta', { vibe: 10 });
expect(state.vibe).toBe(70); // 50 + 10*2
bus.emit('meters:delta', { vibe: -10 });
expect(state.vibe).toBe(60); // full -10, unscaled
});
it('collects heat strikes and tracks clicker drift', () => {
const { bus, state } = setup();
bus.emit('heat:strike', { reason: 'minor admitted' });
expect(state.heatStrikes).toHaveLength(1);
bus.emit('door:clicker', { direction: 'in' });
bus.emit('door:clicker', { direction: 'in' });
bus.emit('door:clicker', { direction: 'out' });
expect(state.capacity.clickerShown).toBe(1);
bus.emit('door:clicker', { direction: 'out' });
bus.emit('door:clicker', { direction: 'out' });
expect(state.capacity.clickerShown).toBe(0); // clicker can't go negative
});
it('destroy() detaches all listeners', () => {
const { bus, state, meters } = setup();
meters.destroy();
bus.emit('meters:delta', { vibe: 10 });
expect(state.vibe).toBe(50);
});
});

71
tests/rng.test.ts Normal file
View File

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
describe('SeededRNG', () => {
it('same seed + stream name → identical sequence', () => {
const a = new SeededRNG(42).stream('patrons');
const b = new SeededRNG(42).stream('patrons');
for (let i = 0; i < 100; i++) expect(a.next()).toBe(b.next());
});
it('different seeds diverge', () => {
const a = new SeededRNG(1).stream('x');
const b = new SeededRNG(2).stream('x');
const seqA = Array.from({ length: 10 }, () => a.next());
const seqB = Array.from({ length: 10 }, () => b.next());
expect(seqA).not.toEqual(seqB);
});
it('streams are independent — draining one does not shift another', () => {
const rng1 = new SeededRNG(7);
const rng2 = new SeededRNG(7);
rng1.stream('a');
for (let i = 0; i < 500; i++) rng1.stream('a').next(); // drain a
const fromDrained = rng1.stream('b').next();
const fromFresh = rng2.stream('b').next();
expect(fromDrained).toBe(fromFresh);
});
it('stream() returns the same stream instance per name (stateful)', () => {
const rng = new SeededRNG(9);
const first = rng.stream('q').next();
const second = rng.stream('q').next();
expect(first).not.toBe(second); // continued, not restarted
});
it('int() covers bounds inclusively and stays in range', () => {
const s = new SeededRNG(3).stream('ints');
const seen = new Set<number>();
for (let i = 0; i < 2000; i++) {
const v = s.int(1, 6);
expect(v).toBeGreaterThanOrEqual(1);
expect(v).toBeLessThanOrEqual(6);
seen.add(v);
}
expect(seen.size).toBe(6);
});
it('chance() distribution sanity', () => {
const s = new SeededRNG(11).stream('coin');
let hits = 0;
for (let i = 0; i < 10_000; i++) if (s.chance(0.3)) hits++;
expect(hits / 10_000).toBeGreaterThan(0.27);
expect(hits / 10_000).toBeLessThan(0.33);
});
it('weighted() respects weights and never returns zero-weight entries', () => {
const s = new SeededRNG(13).stream('w');
const counts = { a: 0, b: 0, c: 0 };
for (let i = 0; i < 10_000; i++) {
counts[s.weighted([['a', 9], ['b', 1], ['c', 0]] as const)]++;
}
expect(counts.c).toBe(0);
expect(counts.a).toBeGreaterThan(counts.b * 5);
});
it('pick/weighted throw on empty/invalid input', () => {
const s = new SeededRNG(1).stream('e');
expect(() => s.pick([])).toThrow();
expect(() => s.weighted([])).toThrow();
});
});

40
tests/save.test.ts Normal file
View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { freshGameState, loadGame, saveGame, type StorageLike } from '../src/core/save';
const memStorage = (): StorageLike & { data: Map<string, string> } => {
const data = new Map<string, string>();
return {
data,
getItem: (k) => data.get(k) ?? null,
setItem: (k, v) => void data.set(k, v),
removeItem: (k) => void data.delete(k),
};
};
describe('save', () => {
it('roundtrips game state', () => {
const storage = memStorage();
const state = freshGameState(4207);
state.nightIndex = 2;
state.regulars['p9'] = { patronId: 'p9', timesDenied: 2, lastSeenNight: 1, grudge: 0.68 };
saveGame(state, storage);
expect(loadGame(0, storage)).toEqual(state);
});
it('missing save → fresh state with fallback seed', () => {
const loaded = loadGame(77, memStorage());
expect(loaded).toEqual(freshGameState(77));
});
it('corrupt JSON → fresh state, no throw', () => {
const storage = memStorage();
storage.setItem('not-tonight-save', '{nope');
expect(loadGame(5, storage)).toEqual(freshGameState(5));
});
it('wrong version/shape → fresh state', () => {
const storage = memStorage();
storage.setItem('not-tonight-save', JSON.stringify({ v: 99, seed: 'what' }));
expect(loadGame(6, storage)).toEqual(freshGameState(6));
});
});

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "tests"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
server: { port: 5199 },
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});