not-tonight/src/scenes/door/NightSummaryScene.ts

268 lines
9.3 KiB
TypeScript

import Phaser from 'phaser';
import type { NightState } from '../../data/types';
import { DAZZA_SIGNOFF, FAIL_TEXT, SUMMARY_UI } from '../../data/strings/door';
import { MAX_HEAT_STRIKES, NIGHT_DATES, type NightLog, type RunInfo } from './NightScene';
import { DOOR_PALETTE, MONO, panel, text } from './ui';
// Phase-2: run-aware summary. Still owes Phase 3 the incident-report form and
// the cross-night audit.
const W = 640;
const H = 360;
const NIGHT_NAMES = ['thursday', 'friday', 'saturday'] as const;
export function nightCash(log: NightLog, state: NightState): number {
// Wage plus a hype bonus, minus a strike deduction. Vibe is not paid directly —
// Dazza pays for the room feeling busy, not for you being right.
const wage = log.wage;
const hypeBonus = Math.round((log.hypePeak - 1) * 90);
const doorBonus = log.admitted * 2;
const strikes = state.heatStrikes.length * 60;
return Math.max(0, wage + hypeBonus + doorBonus - strikes) + log.floorCash;
}
export function signoffFor(vibe: number, strikes: number): string {
if (strikes >= 2) return DAZZA_SIGNOFF[0]!;
const idx = vibe < 25 ? 0 : vibe < 50 ? 1 : vibe < 75 ? 2 : 3;
return DAZZA_SIGNOFF[idx]!;
}
export class NightSummaryScene extends Phaser.Scene {
private log!: NightLog;
private state!: NightState;
private run: RunInfo | null = null;
constructor() {
super('NightSummary');
}
init(data: { log: NightLog; state: NightState; run?: RunInfo }): void {
this.log = data.log;
this.state = data.state;
this.run = data.run ?? null;
}
create(): void {
const reason = this.log.endReason;
const failed = reason !== 'clock';
this.add.rectangle(W / 2, H / 2, W, H, failed ? 0x160a0e : 0x0a0a12);
const title = reason !== 'clock' ? FAIL_TEXT[reason].title : SUMMARY_UI.title;
const nightName = this.run ? (NIGHT_NAMES[this.run.nightIndex] ?? '') : '';
this.add
.text(W / 2, 26, title, {
fontFamily: MONO,
fontSize: '18px',
color: failed ? '#ff8080' : '#f0e4d0',
})
.setOrigin(0.5);
this.add
.text(
W / 2,
46,
failed
? `${this.run?.venue.name ?? this.state.venueId} · ${nightName} · night over early`
: `${SUMMARY_UI.subtitle}${nightName ? ` · ${nightName}` : ''} · shift: ${this.log.role.toLowerCase()} $${this.log.wage}`,
{
fontFamily: MONO,
fontSize: '8px',
color: DOOR_PALETTE.inkDim,
},
)
.setOrigin(0.5);
this.drawVibeGraph(30, 64, 300, 88);
this.drawIncidents(30, 158, 300, 82);
this.drawStats(354, 64);
this.drawDazza(30, 250);
const { prompt, next } = this.nextAction();
this.add
.text(W / 2, H - 14, `${prompt} · seed ${this.log.seed}`, {
fontFamily: MONO,
fontSize: '8px',
color: this.run?.runOver ? '#ff8080' : DOOR_PALETTE.inkDim,
})
.setOrigin(0.5);
// The paperwork sits between the night and the next shift (design §3.3).
const toReport = (): void => {
this.scene.start('IncidentReport', {
state: this.state,
run: this.run?.game ?? null,
seed: this.log.seed,
next,
});
};
this.input.once('pointerdown', toReport);
this.input.keyboard?.once('keydown-SPACE', toReport);
}
/** What clicking through leads to: the next shift, or a fresh run. */
private nextAction(): { prompt: string; next: () => void } {
const r = this.run;
if (!r) {
return {
prompt: SUMMARY_UI.replay,
next: () => this.scene.start('Roster', { seed: this.log.seed + 1 }),
};
}
if (r.runOver) {
return {
prompt: 'licence gone · click to start a fresh week somewhere that has not heard of you',
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 13 }),
};
}
if (r.seasonDone) {
return {
prompt: 'THE SEASON IS DONE. every door in the valley, held. click for a fresh run',
next: () => this.scene.start('Roster', { freshRun: true, seed: this.log.seed + 7 }),
};
}
if (r.weekDone && r.promotedTo) {
const v = r.promotedTo;
return {
// Dazza's pitch sells the next rung — the ladder is the reward.
prompt: `WEEK SURVIVED — PROMOTED: ${v.name.toUpperCase()}. "${v.pitch}"`,
next: () => this.scene.start('Roster', {}),
};
}
const failedNight = this.log.endReason !== 'clock';
const nextName = failedNight
? (NIGHT_NAMES[r.nightIndex] ?? 'the same night')
: (NIGHT_NAMES[r.nightIndex + 1] ?? 'the next night');
return {
prompt: failedNight ? `click to run ${nextName} back` : `click for ${nextName} · ${NIGHT_DATES[r.nightIndex + 1] ?? ''}`,
next: () => this.scene.start('Roster', {}),
};
}
private drawVibeGraph(x: number, y: number, w: number, h: number): void {
panel(this, x, y, w, h, 0x11111a, 0x2a2a38);
text(this, x + 5, y + 4, 'VIBE ACROSS THE NIGHT', 7, DOOR_PALETTE.inkDim);
// 50 is the line you started on — crossing below it is the story of the night
const mid = y + h - 14 - (h - 34) * 0.5;
this.add.rectangle(x + w / 2, mid, w - 10, 1, 0x33333f);
const pts = this.log.vibeHistory;
if (pts.length < 2) return;
const g = this.add.graphics();
g.lineStyle(2, DOOR_PALETTE.greenHot, 1);
g.beginPath();
pts.forEach((v, i) => {
const px = x + 5 + ((w - 10) * i) / (pts.length - 1);
const py = y + h - 14 - ((h - 34) * Math.max(0, Math.min(100, v))) / 100;
if (i === 0) g.moveTo(px, py);
else g.lineTo(px, py);
});
g.strokePath();
text(this, x + 5, y + h - 12, '9PM', 7, DOOR_PALETTE.inkDim);
text(this, x + w - 30, y + h - 12, '3AM', 7, DOOR_PALETTE.inkDim);
}
private drawStats(x: number, y: number): void {
const w = 256;
panel(this, x, y, w, 168, 0x11111a, 0x2a2a38);
const cash = nightCash(this.log, this.state);
const rows: Array<[string, string, string?]> = [
['admitted', String(this.log.admitted)],
['knocked back', String(this.log.denied)],
['sobriety tests', String(this.log.tested)],
['pat-downs', String(this.log.pattedDown)],
['peak hype', `x${this.log.hypePeak.toFixed(1)}`],
['final vibe', String(Math.round(this.state.vibe))],
['final aggro', String(Math.round(this.state.aggro))],
[
'clicker vs reality',
`${this.state.capacity.clickerShown} vs ${this.state.capacity.inside}`,
this.state.capacity.clickerShown === this.state.capacity.inside ? undefined : '#ffc060',
],
[
'heat strikes',
`${this.state.heatStrikes.length}/3`,
this.state.heatStrikes.length > 0 ? '#ff8080' : undefined,
],
['floor score', `$${this.log.floorCash}`, this.log.floorCash > 0 ? '#f0d060' : undefined],
];
rows.forEach(([label, value, colour], i) => {
const ry = y + 10 + i * 13;
text(this, x + 8, ry, label, 8, DOOR_PALETTE.inkDim);
this.add
.text(x + w - 8, ry, value, {
fontFamily: MONO,
fontSize: '8px',
color: colour ?? DOOR_PALETTE.inkHot,
})
.setOrigin(1, 0);
});
this.add.rectangle(x + w / 2, y + 138, w - 16, 1, 0x2a2a38);
text(this, x + 8, y + 145, 'PAY', 9, '#d0b060');
this.add
.text(x + w - 8, y + 143, `$${cash}`, { fontFamily: MONO, fontSize: '14px', color: '#f0d060' })
.setOrigin(1, 0);
}
/**
* The strikes spelled out. A count alone tells you the run is over; the
* reasons tell you which call did it, which is the only way the next night is
* played differently. Lives in its own panel because the reasons are long
* sentences and used to run straight off the right edge of the stats table.
*/
private drawIncidents(x: number, y: number, w: number, h: number): void {
panel(this, x, y, w, h, 0x11111a, 0x2a2a38);
const strikes = this.state.heatStrikes;
text(this, x + 5, y + 4, strikes.length > 0 ? 'THE INSPECTOR WROTE UP' : 'NOTHING ON THE RECORD', 7,
strikes.length > 0 ? '#ff8080' : DOOR_PALETTE.inkDim);
if (strikes.length === 0) {
this.add.text(x + 5, y + 18, 'a clean sheet. no strikes, no questions.\nthe licence survives to friday.', {
fontFamily: MONO, fontSize: '7px', color: DOOR_PALETTE.inkDim, wordWrap: { width: w - 12 }, lineSpacing: 2,
});
return;
}
let ry = y + 16;
for (const s of strikes.slice(0, MAX_HEAT_STRIKES)) {
const line = this.add.text(x + 5, ry, `· ${s.reason}`, {
fontFamily: MONO, fontSize: '7px', color: '#ff9090', wordWrap: { width: w - 12 },
});
ry += line.height + 3;
}
}
private drawDazza(x: number, y: number): void {
const w = W - 60;
panel(this, x, y, w, 74, 0x0d1116, 0x2c4a38);
text(this, x + 8, y + 6, 'DAZZA', 8, '#7de08a');
const reason = this.log.endReason;
const body =
reason !== 'clock'
? FAIL_TEXT[reason].dazza
: signoffFor(this.state.vibe, this.state.heatStrikes.length);
this.add.text(x + 8, y + 20, body, {
fontFamily: MONO,
fontSize: '9px',
color: reason !== 'clock' ? '#ffb0b0' : '#bfe8c8',
wordWrap: { width: w - 16 },
});
if (this.state.heatStrikes.length >= 3) {
this.add.text(x + 8, y + 56, 'THREE STRIKES. THE LICENCE IS GONE. RUN OVER.', {
fontFamily: MONO,
fontSize: '9px',
color: '#ff6060',
});
}
}
}