Playable start to finish: 9 PM to 3 AM, patrons arrive on a curve, you work the rope, inspect them, and rule. Dazza texts escalate the dress code through the night, deferred consequences land when you can no longer argue, and the night ends in a summary or one of two fail states. Rules engine (src/rules/, pure, no Phaser): - dressCode: 8 rules whose text/activeFrom are read out of data/strings/dazza.ts by ruleId, with a load-time throw if a rule and its announcing text disagree - idCheck: all ID date maths, built on core/GameClock's ageOn - judge: the single scoring function, every magnitude named in JUDGE_TUNING - sobriety: challenge content and the drunk-performance model Scene layer (src/scenes/door/): NightScene shell, DoorScene, pure QueueManager + arrivalCurve, patron-up inspection view, ID card, phone, dress-code card, sobriety modal, stamp, summary. Tests 49 -> 229. Ten bugs found by playing it rather than by testing it, including one patron being ruled eleven times (state was mutating inside a tween callback; it now settles synchronously), a correctly-played night still rioting at 1:37 AM, and 28% of patrons being deniable for a logo the renderer never drew. Contract change requests CCR-1..3 are in LANEHANDOVER.md. src/data/types.ts, src/core/ and docs/ are byte-identical to main; the two additive extensions are declare-module augmentations. src/main.ts is the one file touched outside the lane (8 lines, to boot into the night) and is flagged for the reviewer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
// The dress code Dazza texts through the night. Rules evaluate outfit DATA; the
|
|
// player only ever sees pixels (design §4.1) — the gap is the game.
|
|
//
|
|
// `text` and `activeFrom` are NOT written here. They are read out of
|
|
// data/strings/dazza.ts by ruleId, because a rule whose card disagrees with the
|
|
// text that announced it is unplayable, and two hand-kept copies always drift.
|
|
|
|
import './doorTypes';
|
|
import { DAZZA_TEXTS } from '../data/strings/dazza';
|
|
import type { DressCodeRule, OutfitLayer, OutfitSlot, Patron } from '../data/types';
|
|
|
|
/** Contract rule + a card label short enough for the on-screen dress-code board. */
|
|
export interface DoorDressCodeRule extends DressCodeRule {
|
|
short: string;
|
|
}
|
|
|
|
// The vocab spells "not wearing one" as type 'none' (data/outfits.ts), so every
|
|
// lookup has to treat that as absent or `outer.type === 'blazer'` style checks
|
|
// start reasoning about a garment nobody is wearing.
|
|
const worn = (p: Patron, slot: OutfitSlot): OutfitLayer | undefined => {
|
|
const layer = p.outfit.find((l) => l.slot === slot);
|
|
return layer && layer.type !== 'none' ? layer : undefined;
|
|
};
|
|
|
|
const isType = (p: Patron, slot: OutfitSlot, type: string): boolean => worn(p, slot)?.type === type;
|
|
|
|
// Slots whose logo dollPlan.ts actually paints (src/patrons/dollPlan.ts:104 draws
|
|
// the white logo rect for the TOP slot and nothing else). The generator hands out
|
|
// logos on five slots, so enforcing all of them denies roughly a quarter of the
|
|
// queue over pixels that were never on screen — and Dazza's own text says
|
|
// "no VISIBLE logos". Widen this set the moment the renderer draws the rest; see
|
|
// CCR-3 in LANEHANDOVER.md.
|
|
const LOGO_VISIBLE_SLOTS: readonly OutfitSlot[] = ['top'];
|
|
|
|
const hasVisibleLogo = (p: Patron): boolean =>
|
|
p.outfit.some(
|
|
(l) => l.type !== 'none' && l.logo === true && LOGO_VISIBLE_SLOTS.includes(l.slot),
|
|
);
|
|
|
|
// Signals of the bloke who will be at the DJ booth by 1:15 asking for one song,
|
|
// just one. No single item convicts — a jersey is a jersey — but two of these at
|
|
// once is a man with a request already typed into his notes app.
|
|
//
|
|
// `renders` marks the tells dollPlan.ts actually DRAWS today. A jersey torso and
|
|
// trackie legs are plain coloured rects, indistinguishable from a tee and jeans,
|
|
// so convicting on those two alone denies a man over pixels that were never on
|
|
// screen — the one thing design §4.1 forbids. Conviction therefore needs two
|
|
// tells of which at least one is visible. When the art pass gives jersey and
|
|
// trackies their own geometry, flip `renders` and this guard dissolves.
|
|
interface Tell {
|
|
test: (p: Patron) => boolean;
|
|
renders: boolean;
|
|
}
|
|
|
|
const SONG_REQUEST_TELLS: readonly Tell[] = [
|
|
{ test: (p) => isType(p, 'top', 'jersey'), renders: false },
|
|
{ test: (p) => isType(p, 'hair', 'mullet'), renders: true },
|
|
{ test: (p) => isType(p, 'legs', 'trackies'), renders: false },
|
|
{
|
|
test: (p) => {
|
|
const acc = worn(p, 'accessory')?.type;
|
|
return acc === 'cap' || acc === 'chain' || acc === 'bumbag';
|
|
},
|
|
renders: true,
|
|
},
|
|
];
|
|
|
|
function looksLikeASongRequest(p: Patron): boolean {
|
|
const hits = SONG_REQUEST_TELLS.filter((t) => t.test(p));
|
|
return hits.length >= 2 && hits.some((t) => t.renders);
|
|
}
|
|
|
|
interface RuleSpec {
|
|
id: string;
|
|
short: string;
|
|
violates: (p: Patron) => boolean;
|
|
}
|
|
|
|
// Declared in DAZZA_TEXTS order so the two files read side by side. That order is
|
|
// NOT chronological (Dazza is not an organised man), so the sort below is
|
|
// load-bearing — announcement order comes from activeFrom, never from this array.
|
|
const SPECS: readonly RuleSpec[] = [
|
|
{
|
|
id: 'noThongsSinglets',
|
|
short: 'NO THONGS / NO SINGLETS',
|
|
violates: (p) => isType(p, 'shoes', 'thong') || isType(p, 'top', 'singlet'),
|
|
},
|
|
{
|
|
id: 'noLogos',
|
|
short: 'NO VISIBLE LOGOS',
|
|
violates: hasVisibleLogo,
|
|
},
|
|
{
|
|
id: 'noWhiteSneakers',
|
|
short: 'NO WHITE SNEAKERS, VINTAGE OK',
|
|
violates: (p) => {
|
|
const shoes = worn(p, 'shoes');
|
|
return shoes?.type === 'sneaker' && shoes.colour === 'white' && shoes.vintage !== true;
|
|
},
|
|
},
|
|
{
|
|
id: 'noSongRequesters',
|
|
short: 'NO SONG REQUESTERS',
|
|
violates: looksLikeASongRequest,
|
|
},
|
|
{
|
|
id: 'noBucketHats',
|
|
short: 'NO BUCKET HATS',
|
|
violates: (p) => isType(p, 'accessory', 'bucketHat'),
|
|
},
|
|
{
|
|
id: 'noSunnies',
|
|
short: 'NO SUNNIES INSIDE',
|
|
violates: (p) => isType(p, 'accessory', 'sunnies'),
|
|
},
|
|
{
|
|
id: 'noBlazers',
|
|
short: 'NO BLAZERS',
|
|
violates: (p) => isType(p, 'outer', 'blazer'),
|
|
},
|
|
{
|
|
id: 'noHandHolders',
|
|
short: 'NO HAND HOLDING',
|
|
// QueueManager pairs arrivals; the linked-hands pixel is what the player sees.
|
|
violates: (p) => p.flags.handHolding === true,
|
|
},
|
|
];
|
|
|
|
const DAZZA_BY_RULE = new Map(
|
|
DAZZA_TEXTS.filter((l): l is typeof l & { ruleId: string } => l.ruleId !== undefined).map(
|
|
(l) => [l.ruleId, l] as const,
|
|
),
|
|
);
|
|
|
|
function build(spec: RuleSpec): DoorDressCodeRule {
|
|
const line = DAZZA_BY_RULE.get(spec.id);
|
|
// Shipping a rule with an empty card is worse than not booting: the player
|
|
// would be denying people over a rule nobody announced.
|
|
if (!line) throw new Error(`dressCode: no Dazza text announces rule '${spec.id}'`);
|
|
return {
|
|
id: spec.id,
|
|
text: line.text,
|
|
activeFrom: line.fromMin,
|
|
short: spec.short,
|
|
violates: spec.violates,
|
|
};
|
|
}
|
|
|
|
for (const ruleId of DAZZA_BY_RULE.keys()) {
|
|
if (!SPECS.some((s) => s.id === ruleId)) {
|
|
throw new Error(`dressCode: Dazza announces rule '${ruleId}' but nothing enforces it`);
|
|
}
|
|
}
|
|
|
|
// Sorted once at load so activeRules/violations preserve announcement order for free.
|
|
export const DRESS_CODE_RULES: readonly DoorDressCodeRule[] = SPECS.map(build).sort(
|
|
(a, b) => a.activeFrom - b.activeFrom,
|
|
);
|
|
|
|
/** Rules in force at `clockMin`. Live the moment the text lands, hence inclusive. */
|
|
export function activeRules(clockMin: number): DoorDressCodeRule[] {
|
|
return DRESS_CODE_RULES.filter((r) => r.activeFrom <= clockMin);
|
|
}
|
|
|
|
export function violations(patron: Patron, clockMin: number): DoorDressCodeRule[] {
|
|
return activeRules(clockMin).filter((r) => r.violates(patron));
|
|
}
|
|
|
|
export function ruleById(id: string): DoorDressCodeRule | undefined {
|
|
return DRESS_CODE_RULES.find((r) => r.id === id);
|
|
}
|