not-tonight/src/scenes/floor/escalation.ts
type-two 3eee8432d4 LANE-FLOOR: the Floor — patrol, flashlight/UV, cut-off, stalls, pat-down
FloorDemoScene (F from Parade): an 80x45-tile venue you patrol in the dark with
a flashlight cone that reveals detail, plus all three v0.2 infractions and the
UV stamp check. Self-populates from the patron generator; consumes Patron[] only,
so Phase 2 can hand it the real admitted list.

Pure, tested logic (venueMap, cone, player, crowdSim, bangJudge, escalation,
patDown); Phaser layer stays thin (FloorView + three overlays). 120 new tests,
169 total.

Two bugs found by running it rather than by the suite, both fixed:
- bump penalties had a per-agent cooldown but no sim-wide bound, so the leak rate
  scaled with crowd size and an unattended venue pinned vibe to 0 / aggro to 100
  by ~1AM. Sim-wide gate added, with a regression test.
- beat.update() sat below the overlay early-return, freezing the beat clock behind
  modals and making the stall rhythm game unwinnable. Clock and beat now always run.

Also retuned dwell times and the tile/darkness palette for readability, and
dropped Light2D in favour of the cone mask it was fighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:49:13 +10:00

99 lines
2.7 KiB
TypeScript

// The Cut-Off judgement matrix. Pure — no Phaser, no bus. The scene decides
// what to do with the result; this only decides what the room thinks of you.
import type { DrunkStage } from '../../data/types';
import {
CUTOFF_REPLY_CLEAN,
CUTOFF_REPLY_SCENE,
CUTOFF_REPLY_SOFT,
ESCALATION_LINES,
type EscalationLine,
} from './strings';
export type EscalationOutcome = 'clean' | 'scene' | 'staggerFree';
export interface EscalationResult {
outcome: EscalationOutcome;
vibeDelta: number;
aggroDelta: number;
/** What you said (from ESCALATION_LINES). */
line: string;
/** What they said back (from CUTOFF_REPLY_*). */
reply: string;
/** Eject: attach them to the escort-out walk. */
eject: boolean;
/** Resolved: stop treating them as an open infraction. */
resolved: boolean;
}
/** Softest first — comparing rungs is the whole judgement. */
const RUNG: Readonly<Record<EscalationLine, number>> = {
water: 0,
done: 1,
escort: 2,
};
/** The correct rung for each drunk stage. */
export const CORRECT_LINE: Readonly<Record<DrunkStage, EscalationLine>> = {
sober: 'water',
tipsy: 'water',
loose: 'water',
messy: 'done',
maggot: 'escort',
};
// Deliberately small. Being right about a drunk shouldn't feel like a jackpot,
// and overreach should sting more than restraint pays (design doc §4.3).
const CLEAN_VIBE = 4;
const SCENE_VIBE_PER_RUNG = -3;
const SCENE_AGGRO_PER_RUNG = 5;
const SOFT_VIBE = -1;
export function judgeEscalation(stage: DrunkStage, line: EscalationLine): EscalationResult {
const said = ESCALATION_LINES[line];
const overreach = RUNG[line] - RUNG[CORRECT_LINE[stage]];
if (overreach === 0) {
return {
outcome: 'clean',
vibeDelta: CLEAN_VIBE,
aggroDelta: 0,
line: said,
reply: CUTOFF_REPLY_CLEAN[stage],
// Water is a kindness, not an exit — only the top two stages walk.
eject: stage === 'messy' || stage === 'maggot',
resolved: true,
};
}
if (overreach > 0) {
return {
outcome: 'scene',
vibeDelta: SCENE_VIBE_PER_RUNG * overreach,
aggroDelta: SCENE_AGGRO_PER_RUNG * overreach,
line: said,
reply: CUTOFF_REPLY_SCENE[stage],
// They're not going anywhere with you now, but the moment is spent.
eject: false,
resolved: true,
};
}
return {
outcome: 'staggerFree',
vibeDelta: SOFT_VIBE,
aggroDelta: 0,
line: said,
reply: CUTOFF_REPLY_SOFT[stage],
eject: false,
resolved: false,
};
}
/** A maggot left alone too long becomes a deferred heat risk. */
export const MAGGOT_GRACE_MIN = 25;
export function maggotOverdue(spottedAtClockMin: number, nowClockMin: number): boolean {
return nowClockMin - spottedAtClockMin > MAGGOT_GRACE_MIN;
}