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:
parent
4eb8135585
commit
9805ff8fe5
6
.claude/launch.json
Normal file
6
.claude/launch.json
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
18
CLAUDE.md
18
CLAUDE.md
@ -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
|
||||||
|
|||||||
@ -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").
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
17
eslint.config.js
Normal 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
16
index.html
Normal 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>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
3044
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
43
src/core/EventBus.ts
Normal 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
78
src/core/GameClock.ts
Normal 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
77
src/core/SeededRNG.ts
Normal 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
37
src/core/StubBeatClock.ts
Normal 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
67
src/core/meters.ts
Normal 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
40
src/core/save.ts
Normal 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
34
src/data/archetypes.ts
Normal 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
21
src/data/contraband.ts
Normal 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
84
src/data/outfits.ts
Normal 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
36
src/data/strings/dazza.ts
Normal 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
140
src/data/types.ts
Normal 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
20
src/main.ts
Normal 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
29
src/patrons/doll.ts
Normal 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
126
src/patrons/dollPlan.ts
Normal 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
130
src/patrons/generator.ts
Normal 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
30
src/patrons/memory.ts
Normal 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
17
src/rules/drunk.ts
Normal 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';
|
||||||
|
}
|
||||||
13
src/scenes/shared/BootScene.ts
Normal file
13
src/scenes/shared/BootScene.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/scenes/shared/HudStub.ts
Normal file
23
src/scenes/shared/HudStub.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/scenes/shared/ParadeScene.ts
Normal file
182
src/scenes/shared/ParadeScene.ts
Normal 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
27
tests/beatclock.test.ts
Normal 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
69
tests/clock.test.ts
Normal 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
72
tests/dollplan.test.ts
Normal 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
54
tests/eventbus.test.ts
Normal 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
95
tests/generator.test.ts
Normal 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
40
tests/memory.test.ts
Normal 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
61
tests/meters.test.ts
Normal 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
71
tests/rng.test.ts
Normal 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
40
tests/save.test.ts
Normal 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
18
tsconfig.json
Normal 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
11
vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user