import Phaser from 'phaser';
import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { activeRules, announcedRules, shuffleWindow, violations, type DoorDressCodeRule } from '../../rules/dressCode';
import { drunkStage } from '../../rules/drunk';
import { idVerdict, formatDate } from '../../rules/idCheck';
import { LOCKOUT_MIN, judge } from '../../rules/judge';
import type { DoorOutcome } from '../../rules/doorTypes';
import { CONTRABAND } from '../../data/contraband';
import type { Patron, Verdict } from '../../data/types';
import {
  ADMIT_REACTIONS,
  KEBAB,
  DENY_REACTIONS,
  DOOR_TUTORIAL,
  DOOR_UI,
  BOARD_SHUFFLE_TOASTS,
  CARPET_UI,
  FRANKO_TIPS,
  PATDOWN_LINES,
  RULE_RETRACTIONS,
} from '../../data/strings/door';
import { CARPET_TUNING, QueueManager, entranceBonus } from './QueueManager';
import { KAYDEN_HOLD_COOLDOWN_MS, KAYDEN_HOLD_MS } from '../../rules/staff';
import { STAFF_RADIO } from '../../data/strings/floor';
import { PatronUpView } from './PatronUpView';
import { IdCardView } from './IdCardView';
import { PhoneWidget } from './PhoneWidget';
import { DressCodeCard } from './DressCodeCard';
import { ClipboardPanel, type ClipboardTab } from './ClipboardPanel';
import { encounterById } from '../../rules/encounters';
import { SobrietyModal } from './SobrietyModal';
import { stamp, type StampHandle } from '../../ui/Stamp';
import { tutorialAlpha } from './tutorialFade';
import { Button, DOOR_PALETTE, MeterBar, MONO, panel, text } from './ui';
import { shouldRecordStrike, type NightContext } from './NightScene';

const W = 640;
const H = 360;
const DESK_Y = 250;
const UP_X = 300;
/** Real-ms between Kayden's rulings while the player is on the floor (~2.3 clock-min). */
const KAYDEN_RULE_MS = 5000;
const UP_FOOT_Y = 244;
const DOOR_X = 566;
const QUEUE_FOOT_Y = 228;

/**
 * TODO(contract): CCR-4 — `dazza:text` carries `rule?: DressCodeRule`, so a rule
 * emitted by this lane comes back without the `short` card label the lane added.
 * Until the payload widens, narrow it here honestly instead of casting.
 */
const isDoorRule = (r: unknown): r is DoorDressCodeRule =>
  typeof r === 'object' && r !== null && typeof (r as DoorDressCodeRule).short === 'string';

interface QueueSprite {
  patron: Patron;
  image: Phaser.GameObjects.Image;
  link: Phaser.GameObjects.Rectangle | null;
}

// The Door. Thin by design: every number it acts on comes from src/rules/, every
// arrival comes from QueueManager, and the night's lifetime belongs to
// NightScene. What lives here is layout, input and feel.
export class DoorScene extends Phaser.Scene {
  private night!: NightContext;
  private queue!: QueueManager;
  private up!: PatronUpView;
  private idCard!: IdCardView;
  private phone!: PhoneWidget;
  private codeCard!: DressCodeCard;
  private clipboard!: ClipboardPanel;
  private deskTab: ClipboardTab = 'rules';
  private tabButtons: Partial<Record<ClipboardTab, Button>> = {};
  private sobriety!: SobrietyModal;

  private queueSprites: QueueSprite[] = [];
  private carpetSprites: QueueSprite[] = [];
  private trayCard: Phaser.GameObjects.Container | null = null;
  private buttons: Record<string, Button> = {};
  private vibeBar!: MeterBar;
  private aggroBar!: MeterBar;
  private hypeText!: Phaser.GameObjects.Text;
  private clockText!: Phaser.GameObjects.Text;
  private countText!: Phaser.GameObjects.Text;
  private heatText!: Phaser.GameObjects.Text;
  private clickerText!: Phaser.GameObjects.Text;
  private toast!: Phaser.GameObjects.Text;
  private tutorial!: Phaser.GameObjects.Text;
  private ropeProp!: Phaser.GameObjects.Container;

  private busy = false;
  private rulingToken = 0;
  private elapsedMs = 0;
  /** ROOM: which 15-min shuffle window the code board currently shows. */
  private codeWindow = -1;
  private testedThisPatron = false;
  private pattedThisPatron = false;
  private offs: Array<() => void> = [];
  /** Is the player at the rope? When false, Kayden works the door. Badly. */
  private present = true;
  private kaydenAccMs = 0;
  /** Radio command: Kayden holds the rope until this elapsedMs, ready again after the other. */
  private kaydenHoldUntilMs = 0;
  private kaydenHoldReadyMs = 0;
  private readonly retiredRules = new Set<string>();
  /** Every card ADMITTED tonight, name|dob -> clock label. The passback net. */
  private readonly seenCards = new Map<string, string>();
  /** Patrons still owed a shouted tip from the planter (ex-doorman aftermath). */
  private frankoTips = 0;
  /** The kebab run: Dazza's mid-slam errand. 'pending' once asked, until done or sulked. */
  private kebabAskAtMin: number | null = null;
  private kebabPending = false;
  private kebabHandled = false;
  private onKebabRun = false;
  private kebabRunMsLeft = 0;
  private kebabUi: Phaser.GameObjects.GameObject[] = [];
  private kebabFill: Phaser.GameObjects.Rectangle | null = null;
  private kebabGlow: Phaser.GameObjects.Rectangle | null = null;
  private venueDoorGlow: Phaser.GameObjects.Rectangle | null = null;

  constructor() {
    super('Door');
  }

  init(data: { night: NightContext }): void {
    this.night = data.night;
  }

  create(): void {
    this.queue = new QueueManager(
      this.night.bus,
      this.night.rng,
      this.night.nightDate,
      this.night.seenEncounters,
      this.night.venue.arrivalScale,
      this.night.venue.archetypeWeights,
      this.night.regulars,
    );
    this.up = new PatronUpView(this, UP_X, UP_FOOT_Y);
    this.idCard = new IdCardView(this, this.night.nightDate);
    this.sobriety = new SobrietyModal(this);
    this.present = true;
    this.kaydenAccMs = 0;

    // Some nights, mid-slam, Dazza needs a kebab. The shop is right there.
    const kebabRng = this.night.rng.stream('kebab');
    this.kebabAskAtMin = kebabRng.chance(0.65) ? kebabRng.int(140, 260) : null;

    this.buildStreet();
    this.buildDesk();
    this.buildHud();

    // Radio command (design §6, Head of Security tier): from the door you
    // order the FLOOR staff; Kayden's hold only matters from the floor, so
    // he registers here and answers there.
    if (this.night.venue.radioCommand) {
      this.night.kaydenHold = () => {
        if (this.elapsedMs < this.kaydenHoldReadyMs) return 'cooldown';
        this.kaydenHoldUntilMs = this.elapsedMs + KAYDEN_HOLD_MS;
        this.kaydenHoldReadyMs = this.elapsedMs + KAYDEN_HOLD_COOLDOWN_MS;
        return 'sent';
      };
      this.input.keyboard?.on('keydown', (ev: KeyboardEvent) => {
        if (!this.present || this.sobriety.isOpen) return;
        if (ev.key === '1') this.radioFloorOrder('mess');
        else if (ev.key === '2') this.radioFloorOrder('water');
      });
      this.add
        .text(6, 246, STAFF_RADIO.hintDoor, { fontFamily: MONO, fontSize: '7px', color: '#6a6a82' })
        .setDepth(30);
    }

    this.offs = [
      this.night.bus.on('clock:tick', ({ clockMin }) => {
        this.queue.onClockMinute(clockMin);
        // ROOM: the board rewrites itself on every 15-min window turn.
        if (this.night.venue.shuffleDressCode) {
          const w = shuffleWindow(clockMin);
          if (w !== this.codeWindow) {
            this.codeWindow = w;
            this.codeCard.setAll(this, activeRules(clockMin), this.elapsedMs);
            this.showToast(BOARD_SHUFFLE_TOASTS[w % BOARD_SHUFFLE_TOASTS.length]!);
            this.night.sfx?.play('phoneBuzz');
          }
        }
      }),
      this.night.bus.on('meters:changed', ({ vibe, aggro, hype }) => {
        this.vibeBar.set(vibe / 100, vibe < 20);
        this.aggroBar.set(aggro / 100, aggro > 80);
        this.hypeText.setText(`HYPE x${hype.toFixed(1)}`);
      }),
      this.night.bus.on('dazza:text', ({ rule }) => {
        this.night.sfx?.play('phoneBuzz');
        // The bus payload is typed with the frozen DressCodeRule, which has no
        // `short`. Narrowing back to the lane's own DoorDressCodeRule is a real
        // contract gap (CCR-4) — this predicate is the honest version of it
        // rather than an `as never` punch-through.
        if (isDoorRule(rule)) {
          this.codeCard.add(this, rule, this.elapsedMs);
          this.retireStaleRules();
        }
      }),
      this.night.bus.on('door:verdict', ({ patron, verdict }) => {
        if (verdict !== 'admit') return;
        // The desk remembers the card, whoever waved it through.
        this.seenCards.set(
          `${patron.idCard.name}|${patron.idCard.dob}`,
          this.night.clock.label,
        );
      }),
      this.night.bus.on('heat:strike', () => this.refreshCounters()),
      this.night.bus.on('night:phaseChange', ({ location }) => {
        this.present = location === 'door';
        if (this.present) this.refreshCounters();
        else this.kaydenAccMs = 0;
      }),
      this.night.bus.on('radio:call', ({ line, urgency }) => {
        if (!this.present) return;
        this.showToast(`RADIO — ${line}`);
        // The venue door glows when the floor wants you: the way in IS the button.
        if (this.venueDoorGlow) {
          this.venueDoorGlow.setAlpha(urgency >= 3 ? 0.5 : 0.3);
          this.tweens.add({ targets: this.venueDoorGlow, alpha: 0.08, duration: 6000 });
        }
      }),
    ];

    this.events.once('shutdown', () => this.teardown());
    this.refreshCounters();
    this.vibeBar.set(this.night.state.vibe / 100);
    this.aggroBar.set(this.night.state.aggro / 100);
  }

  // ---------- construction ----------

  private buildStreet(): void {
    if (this.textures.exists('gen:prop:street_backdrop')) {
      // The generated plate (SPACES §5 item 11): brick, posters, grime. Sits
      // where the flat wall-of-night rect went; everything else layers on top.
      this.add.image(0, 0, 'gen:prop:street_backdrop').setOrigin(0, 0).setDisplaySize(W, 176).setAlpha(0.9);
    } else {
      this.add.rectangle(W / 2, 88, W, 176, 0x0a0a12); // wall of night
    }
    this.add.rectangle(W / 2, 212, W, 76, 0x1a1a24); // footpath
    this.add.rectangle(W / 2, 176, W, 2, 0x2a2a38);

    // kebab shop, stage left — visible all night, the moral centre (design §5)
    this.add.rectangle(62, 120, 112, 106, 0x2a1c08);
    this.add.rectangle(62, 88, 96, 18, DOOR_PALETTE.kebab, 0.9);
    this.add.text(28, 82, 'KEBABS', { fontFamily: MONO, fontSize: '12px', color: '#3a2404' });
    this.add.rectangle(62, 148, 92, 46, 0xe8c060, 0.12);
    // The whole shopfront becomes a button when Dazza wants feeding.
    this.kebabGlow = this.add.rectangle(62, 120, 112, 106, 0xe8c060, 0).setInteractive();
    this.kebabGlow.on('pointerdown', () => this.startKebabRun());
    this.add.rectangle(62, 214, 96, 8, DOOR_PALETTE.kebab, 0.13);

    // the venue
    this.add.rectangle(DOOR_X, 108, 116, 140, 0x181828);
    this.add.rectangle(DOOR_X, 58, 92, 16, DOOR_PALETTE.neon, 0.92);
    this.add.text(DOOR_X - 40, 51, 'NOT TONIGHT', { fontFamily: MONO, fontSize: '10px', color: '#2a0818' });
    this.add.rectangle(DOOR_X, 140, 34, 74, 0x05050a);
    this.add.rectangle(DOOR_X, 214, 66, 8, DOOR_PALETTE.neon, 0.16);

    // The doorway is how you get to the floor — click it to step inside.
    // It glows when the radio wants you in there.
    this.venueDoorGlow = this.add.rectangle(DOOR_X, 140, 38, 78, DOOR_PALETTE.neon, 0.08);
    const doorway = this.add
      .rectangle(DOOR_X, 140, 40, 80, 0xffffff, 0)
      .setInteractive({ useHandCursor: true });
    doorway.on('pointerover', () => this.venueDoorGlow?.setAlpha(0.28));
    doorway.on('pointerout', () => this.venueDoorGlow?.setAlpha(0.08));
    doorway.on('pointerdown', () => {
      if (!this.busy) this.night.requestLocation('floor');
    });
    this.add
      .text(DOOR_X, 184, 'step inside', { fontFamily: MONO, fontSize: '7px', color: '#6a4a5a' })
      .setOrigin(0.5, 0);

    // The red carpet: a strip of somewhere-better between rope and door,
    // flanked by brass posts. Bodies parked here are rendered by
    // syncCarpetSprites; the strip is set dressing.
    if (this.textures.exists('gen:prop:carpet')) {
      this.add.image(420, 236, 'gen:prop:carpet').setOrigin(0.5, 1).setDisplaySize(132, 34).setDepth(2);
    } else {
      this.add.rectangle(420, 232, 124, 12, 0x8a1428).setDepth(2);
      this.add.rectangle(420, 227, 124, 2, 0xa8283c).setDepth(2);
      for (const px of [356, 484]) {
        this.add.rectangle(px, 216, 3, 22, 0xc8a24a).setDepth(2);
        this.add.rectangle(px, 205, 7, 4, 0xe0c060).setDepth(2);
      }
      this.add.rectangle(420, 206, 126, 2, 0x7a2438).setDepth(2); // the velvet itself
    }

    this.buildRain();
    this.buildRope();
  }

  private buildRain(): void {
    const g = this.add.graphics();
    g.fillStyle(0x8899bb, 1);
    g.fillRect(0, 0, 1, 4);
    g.generateTexture('door:rain', 1, 4);
    g.destroy();
    this.add
      .particles(0, 0, 'door:rain', {
        x: { min: -20, max: W },
        y: -6,
        lifespan: 800,
        speedY: { min: 230, max: 310 },
        speedX: { min: -34, max: -12 },
        quantity: 2,
        alpha: { start: 0.45, end: 0.12 },
      })
      .setDepth(400);
  }

  private buildRope(): void {
    const c = this.add.container(0, 0).setDepth(280);
    c.add(this.add.rectangle(238, 216, 4, 56, 0x8a7a4a)); // post
    c.add(this.add.rectangle(238, 190, 8, 8, 0xc0a860));
    const rope = this.add.rectangle(206, 198, 62, 3, 0x8a2038).setName('rope');
    c.add(rope);
    this.ropeProp = c;

    const hit = this.add
      .rectangle(222, 200, 78, 40, 0xffffff, 0)
      .setInteractive({ useHandCursor: true })
      .setDepth(281);
    hit.on('pointerdown', () => this.callNext());
    hit.on('pointerover', () => rope.setFillStyle(0xc03050));
    hit.on('pointerout', () => rope.setFillStyle(0x8a2038));
  }

  private buildDesk(): void {
    this.add.rectangle(W / 2, (DESK_Y + H) / 2, W, H - DESK_Y, DOOR_PALETTE.desk).setDepth(20);
    this.add.rectangle(W / 2, DESK_Y + 1, W, 2, DOOR_PALETTE.deskEdge).setDepth(21);

    // Two panels, one footprint. The tab header sits above them both.
    this.codeCard = new DressCodeCard(this, 6, 268, 168, 86);
    // ROOM: no Dazza chronology — the board opens already written, and the
    // rope reads lips (PatronUpView renders every line as a guess).
    if (this.night.venue.shuffleDressCode) {
      this.codeWindow = shuffleWindow(this.night.state.clockMin);
      this.codeCard.setAll(this, activeRules(this.night.state.clockMin), this.elapsedMs);
    }
    this.up.lipRead = this.night.venue.lipRead === true;
    this.clipboard = new ClipboardPanel(this, this.queue.guestList, 6, 268, 168, 86);
    this.buildDeskTabs();

    // ID tray
    panel(this, 180, 256, 96, 98).setDepth(30);
    text(this, 185, 260, DOOR_UI.idTray, 7, DOOR_PALETTE.inkDim).setDepth(31);
    IdCardView.holoSwatch(this, 228, 336).setDepth(31);

    // clicker
    panel(this, 282, 256, 74, 98).setDepth(30);
    text(this, 287, 260, DOOR_UI.clicker, 7, DOOR_PALETTE.inkDim).setDepth(31);
    this.clickerText = this.add
      .text(319, 286, '0', { fontFamily: MONO, fontSize: '20px', color: '#f0e4d0' })
      .setOrigin(0.5)
      .setDepth(31);
    this.buttons.clickIn = new Button(this, {
      x: 286, y: 304, w: 66, h: 22, label: 'CLICK IN', size: 7,
      fill: DOOR_PALETTE.amber, hover: DOOR_PALETTE.amberHot,
      onClick: () => this.click('in'),
    });
    this.buttons.clickIn.setDepth(32);
    this.buttons.clickOut = new Button(this, {
      x: 286, y: 328, w: 66, h: 20, label: 'out', size: 7,
      onClick: () => this.click('out'),
    });
    this.buttons.clickOut.setDepth(32);

    // phone
    panel(this, 362, 256, 74, 98).setDepth(30);
    text(this, 367, 260, DOOR_UI.phone, 7, DOOR_PALETTE.inkDim).setDepth(31);
    this.phone = new PhoneWidget(
      this,
      this.night.bus,
      399,
      302,
      () => this.queue.lookAtPhone(),
      () => this.night.clock.label,
    );

    // verdicts
    this.buttons.rope = new Button(this, {
      x: 442, y: 256, w: 130, h: 24, label: DOOR_UI.rope, size: 9,
      fill: 0x5a2030, hover: 0x8a2038,
      onClick: () => this.callNext(),
    });
    this.buttons.carpet = new Button(this, {
      x: 576, y: 256, w: 58, h: 24, label: DOOR_UI.carpet, size: 7,
      fill: 0x6a1424, hover: 0x9a2038,
      onClick: () => this.callCarpet(),
    });
    this.buttons.test = new Button(this, {
      x: 442, y: 284, w: 94, h: 22, label: DOOR_UI.test, size: 7,
      onClick: () => this.runSobriety(),
    });
    this.buttons.pat = new Button(this, {
      x: 540, y: 284, w: 94, h: 22, label: DOOR_UI.patDown, size: 7,
      onClick: () => this.runPatDown(),
    });
    this.buttons.admit = new Button(this, {
      x: 442, y: 310, w: 94, h: 44, label: DOOR_UI.admit, size: 11,
      fill: DOOR_PALETTE.green, hover: DOOR_PALETTE.greenHot,
      onClick: () => this.rule('admit'),
    });
    this.buttons.deny = new Button(this, {
      x: 540, y: 310, w: 94, h: 44, label: DOOR_UI.deny, size: 11,
      fill: DOOR_PALETTE.red, hover: DOOR_PALETTE.redHot,
      onClick: () => this.rule('deny'),
    });
    for (const b of Object.values(this.buttons)) b.setDepth(32);
    this.setVerdictButtons(false);
  }

  /** RULES | LIST switch above the shared left panel. */
  private buildDeskTabs(): void {
    const mk = (tab: ClipboardTab, label: string, x: number, w: number): Button =>
      new Button(this, {
        x, y: 254, w, h: 13, label, size: 6,
        fill: DOOR_PALETTE.deskEdge,
        hover: DOOR_PALETTE.panelEdge,
        onClick: () => this.showTab(tab),
      }).setDepth(52);
    this.tabButtons.rules = mk('rules', "DAZZA'S RULES", 6, 84);
    this.tabButtons.list = mk('list', 'GUEST LIST', 90, 84);
    this.showTab('rules');
  }

  private showTab(tab: ClipboardTab): void {
    this.deskTab = tab;
    this.codeCard.setVisible(tab === 'rules');
    this.clipboard.setVisible(tab === 'list');
    // NOT setEnabled: disabling the current tab greys it out, which reads as
    // "this one is off" — exactly backwards. Both stay live; the selected one is
    // simply the bright one, sitting flush with the panel it opens.
    for (const key of ['rules', 'list'] as const) {
      const b = this.tabButtons[key];
      if (!b) continue;
      const active = key === tab;
      b.rect.setFillStyle(active ? DOOR_PALETTE.panelEdge : 0x1b1720);
      b.label.setColor(active ? DOOR_PALETTE.inkHot : DOOR_PALETTE.inkDim);
    }
  }

  private buildHud(): void {
    this.add.rectangle(W / 2, 9, W, 18, 0x05050a, 0.75).setDepth(500);
    this.vibeBar = new MeterBar(this, 6, 10, 88, 'VIBE', DOOR_PALETTE.green);
    this.aggroBar = new MeterBar(this, 104, 10, 88, 'AGGRO', DOOR_PALETTE.red);
    this.hypeText = text(this, 202, 6, 'HYPE x1.0', 7, '#d0b060').setDepth(501);
    this.countText = text(this, 268, 6, '', 7, DOOR_PALETTE.inkDim).setDepth(501);
    this.heatText = text(this, 380, 6, '', 7, '#ff8080').setDepth(501);
    this.clockText = this.add
      .text(W - 6, 5, '9:00 PM', { fontFamily: MONO, fontSize: '10px', color: '#9fe8a0' })
      .setOrigin(1, 0)
      .setDepth(501);
    this.add
      .text(W - 6, 16, formatDate(this.night.nightDate), {
        fontFamily: MONO,
        fontSize: '7px',
        color: DOOR_PALETTE.inkDim,
      })
      .setOrigin(1, 0)
      .setDepth(501);

    this.toast = this.add
      .text(UP_X, 178, '', { fontFamily: MONO, fontSize: '8px', color: '#f0e4d0', align: 'center' })
      .setOrigin(0.5)
      .setDepth(600)
      .setAlpha(0);

    this.tutorial = this.add
      .text(240, 24, DOOR_TUTORIAL.join('\n'), {
        fontFamily: MONO,
        fontSize: '8px',
        color: '#6a7a8a',
        align: 'left',
        lineSpacing: 3,
      })
      .setDepth(501);
  }

  // ---------- interaction ----------

  private callNext(): void {
    if (this.busy || this.queue.patronUp) return;
    const p = this.queue.callNext();
    if (!p) {
      this.showToast(DOOR_UI.ropeEmpty);
      return;
    }
    this.presentUp(p);
  }

  /** Everything that happens once SOMEBODY is at the rope — the rope and the
   * carpet recall share this path, so a recalled patron is a full patron. */
  private presentUp(p: Patron): void {
    this.testedThisPatron = false;
    this.pattedThisPatron = false;
    // Passback: this exact card already went in tonight. The tell is set HERE —
    // it is the DESK's memory, not the generator's — so Kayden (who never
    // checks) waves the same card through all night.
    if (this.seenCards.has(`${p.idCard.name}|${p.idCard.dob}`)) {
      p.idCard.fake = { ...(p.idCard.fake ?? {}), passback: true };
    }
    this.night.sfx?.play('ropeUnhook');
    this.up.show(p, this.queue.encounterFor(p));
    if (this.frankoTips > 0 && this.queue.encounterFor(p) === undefined) {
      this.frankoTips--;
      const tip = this.frankoTip(p);
      this.time.delayedCall(1700, () => this.showToast(tip));
    }
    this.showTray(p);
    this.setVerdictButtons(true);
    // Only a claim puts a name on the clipboard. Someone who says nothing is
    // not on the list and is not pretending to be.
    this.clipboard.check(p.flags.claimsGuestList ? p.idCard.name : null);
    if (p.flags.claimsGuestList) this.flashTab('list');

    this.tweens.add({
      targets: this.ropeProp.getByName('rope') as Phaser.GameObjects.Rectangle,
      scaleX: { from: 1, to: 0.2 },
      duration: 160,
      yoyo: true,
    });
  }

  /** Door-side radio: order the floor staff without leaving the rope. */
  private radioFloorOrder(kind: 'mess' | 'water'): void {
    const ack = this.night.floorOrder?.(kind, (msg) => {
      if (this.present) this.showToast(msg);
    });
    if (ack === 'sent') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpAck : STAFF_RADIO.shanAck);
    else if (ack === 'cooldown') this.showToast(STAFF_RADIO.cooldown);
    else if (ack === 'nothing') this.showToast(kind === 'mess' ? STAFF_RADIO.bumpNothing : STAFF_RADIO.shanNothing);
  }

  // ---- the red carpet ----------------------------------------------------

  /** The patron at the rope takes position between the brass posts. */
  private callCarpet(): void {
    const p = this.queue.patronUp;
    if (!p || this.busy) return;
    if (this.queue.toCarpet() === null) {
      this.showToast(CARPET_UI.full);
      return;
    }
    this.idCard.close();
    this.trayCard?.destroy();
    this.trayCard = null;
    this.up.clear();
    this.setVerdictButtons(false);
    this.night.sfx?.play('ropeUnhook');
    this.showToast(CARPET_UI.sent);
    this.logIncidentDoor('carpet', p.id, 'Sent to the carpet to be seen waiting.');
  }

  /** Click a carpet doll: wave them up to the rope for the actual ruling. */
  private recallCarpet(patronId: string): void {
    if (this.busy) return;
    if (this.queue.patronUp) {
      this.showToast(CARPET_UI.recallBusy);
      return;
    }
    const p = this.queue.recallFromCarpet(patronId);
    if (!p) return;
    this.presentUp(p);
  }

  private logIncidentDoor(kind: string, patronId: string | undefined, detail: string): void {
    this.night.bus.emit('incident:log', { clockMin: this.night.state.clockMin, kind, patronId, detail });
  }

  /** The carpet is a view of QueueManager, same discipline as the queue line. */
  private syncCarpetSprites(): void {
    const slots = this.queue.carpetSnapshot;
    const same =
      slots.length === this.carpetSprites.length &&
      slots.every((s, i) => this.carpetSprites[i]?.patron.id === s.patron.id);
    if (!same) {
      for (const s of this.carpetSprites) s.image.destroy();
      this.carpetSprites = slots.map((slot, i) => {
        const x = 372 + i * 34;
        const img = this.add
          .image(x, QUEUE_FOOT_Y + 6, renderDoll(this, slot.patron, 'queue'))
          .setOrigin(0.5, 1)
          .setScale(1.5)
          .setDepth(205)
          .setInteractive({ useHandCursor: true });
        img.on('pointerdown', () => this.recallCarpet(slot.patron.id));
        img.setData('baseX', x);
        return { patron: slot.patron, image: img, link: null };
      });
    }
    // Impatience is visible: the sway grows as the patience runs out.
    for (let i = 0; i < this.carpetSprites.length; i++) {
      const s = this.carpetSprites[i]!;
      const slot = slots[i];
      if (!slot) continue;
      const fuse = Math.min(1, slot.standMs / slot.patienceMs);
      s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / (300 - 140 * fuse)) * (1 + fuse * 3);
    }

    for (const p of this.queue.takeStorms()) {
      this.showToast(CARPET_UI.storm);
      this.night.sfx?.play('doorBang');
      this.logIncidentDoor('carpetStorm', p.id, 'Ran out of patience on the carpet and left, loudly.');
    }
  }

  /** Nudge the player toward a tab that just became relevant, without switching it. */
  private flashTab(tab: ClipboardTab): void {
    const b = this.tabButtons[tab];
    if (!b || this.deskTab === tab) return;
    this.tweens.add({ targets: b.label, alpha: { from: 1, to: 0.25 }, duration: 320, yoyo: true, repeat: 3 });
  }

  private showTray(p: Patron): void {
    this.trayCard?.destroy();
    const c = this.idCard.thumbnail(p, 228, 300);
    c.setDepth(31);
    c.setInteractive(new Phaser.Geom.Rectangle(-39, -23, 78, 46), Phaser.Geom.Rectangle.Contains);
    c.on('pointerdown', () => this.idCard.open(p));
    this.tweens.add({ targets: c, angle: { from: -6, to: 0 }, y: { from: 314, to: 300 }, duration: 220 });
    this.trayCard = c;
  }

  private click(direction: 'in' | 'out'): void {
    this.night.sfx?.play('clickerClunk');
    this.night.bus.emit('door:clicker', { direction });
    this.refreshCounters();
    this.tweens.add({ targets: this.clickerText, scale: { from: 1.35, to: 1 }, duration: 120 });
  }

  private runSobriety(): void {
    const p = this.queue.patronUp;
    if (!p || this.busy || this.sobriety.isOpen) return;
    if (this.testedThisPatron) {
      this.showToast('you already tested them');
      return;
    }
    this.testedThisPatron = true;
    this.applyOutcome(p, 'sobrietyTest', judge(p, 'sobrietyTest', this.judgeCtx()));
    this.sobriety.open(p, this.night.rng.stream('sobriety'), ({ ruling, performance }) => {
      // Failing someone who objectively passed is allowed and profitable — and
      // the queue can tell (design §3.1: "You may fail them even if they pass").
      if (ruling === 'fail' && performance.objectivelySober) {
        this.night.bus.emit('meters:delta', { vibe: 2, aggro: 5 });
        this.showToast('they were fine.\nthe queue noticed');
      } else if (ruling === 'pass' && !performance.objectivelySober) {
        this.showToast('generous.');
      }
    });
  }

  private runPatDown(): void {
    const p = this.queue.patronUp;
    if (!p || this.busy) return;
    if (this.pattedThisPatron) {
      this.showToast('their pockets are already out');
      return;
    }
    this.pattedThisPatron = true;
    this.applyOutcome(p, 'patDown', judge(p, 'patDown', this.judgeCtx()));

    const ids = p.flags.contraband ?? [];
    if (ids.length === 0) {
      this.showToast(PATDOWN_LINES.clean);
      return;
    }
    const names = ids.map((id) => CONTRABAND.find((c) => c.id === id)?.name ?? id);
    this.night.bus.emit('meters:delta', { vibe: 1 });
    this.showToast(PATDOWN_LINES.found(names.join(' + ')));
  }

  private rule(verdict: Extract<Verdict, 'admit' | 'deny'>): void {
    const p = this.queue.patronUp;
    if (!p || this.busy) return;
    this.busy = true;
    this.setVerdictButtons(false);
    this.idCard.close();
    this.trayCard?.destroy();
    this.trayCard = null;

    // STATE SETTLES NOW, ANIMATION IS JUST ANIMATION.
    //
    // This used to run inside the stamp's onImpact callback, which meant a tween
    // that failed to fire left the patron still standing at a rope the player
    // could rule on again — one patron was scored eleven times in a test night.
    // Anything that touches meters, capacity or the queue happens synchronously
    // here; the tween callbacks below may only move pixels.
    const outcome = judge(p, verdict, this.judgeCtx());
    if (verdict === 'admit') {
      // The clicker does NOT move unless the player moves it. The gap between
      // clickerShown and the truth is the mechanic (CONTRACTS.md §3).
      this.night.state.capacity.inside++;
    }
    this.applyOutcome(p, verdict, outcome);
    this.applyEncounter(p, verdict);
    // The entrance dividend: an admit off the carpet pays for every minute
    // they were made to stand there being looked at.
    const stoodMs = this.queue.carpetMsFor(p.id);
    if (verdict === 'admit' && stoodMs > 0) {
      this.night.bus.emit('meters:delta', { vibe: CARPET_TUNING.entranceVibe, hype: entranceBonus(stoodMs) });
      this.time.delayedCall(600, () => this.showToast(CARPET_UI.entrance));
      this.logIncidentDoor('carpetEntrance', p.id, `Admitted after ${Math.round(stoodMs / 1000)}s on the carpet.`);
    }
    this.queue.resolveUp(verdict === 'deny');

    // Highlight AFTER the call, never before. Lighting up the broken rules the
    // moment a patron steps up hands the player the answer and reduces all
    // eight dress-code rules to reading a red line off the desk — it deletes
    // the inspection loop the whole scene exists for. As post-verdict feedback
    // it teaches instead: here is what you just missed.
    const broken = violations(p, this.night.state.clockMin).map((r) => r.id);
    this.codeCard.highlight(broken);
    this.time.delayedCall(2200, () => this.codeCard.highlight([]));

    // `busy` gates input for the length of the animation. It is still released
    // from a callback, so it still gets a watchdog — but now the worst case is a
    // couple of dead seconds, not a corrupted night. The token stops ruling N's
    // watchdog from unlocking the buttons partway through ruling N+1.
    const token = ++this.rulingToken;
    const release = (): void => {
      if (token === this.rulingToken) this.busy = false;
    };
    this.time.delayedCall(2600, release);

    if (verdict === 'deny') {
      // LANE-JUICE's stamp (screen shake, ink ring, sfx). The decal is ours to
      // destroy — the denied patron takes their mark with them into the night.
      let handle: StampHandle | null = null;
      const sfx = this.night.sfx;
      handle = stamp(this, {
        x: UP_X,
        y: UP_FOOT_Y - 70,
        text: 'NOT TONIGHT',
        depth: 320,
        seed: p.dollSeed,
        ...(sfx
          ? { sfx: { stampSlam: () => sfx.play('stampSlam'), stampInk: () => sfx.play('stampInk') } }
          : {}),
        onSlam: () => {
          this.up.walkOffDenied();
          this.showToast(this.pick(DENY_REACTIONS, p));
        },
        onDone: () => {
          release();
          this.time.delayedCall(900, () => handle?.decal.destroy());
        },
      });
      return;
    }

    this.showToast(this.pick(ADMIT_REACTIONS, p));
    this.up.walkInAdmitted(DOOR_X, () => {
      release();
      this.refreshCounters();
    });
  }

  /**
   * A scripted encounter's own consequence, on top of the ordinary judge()
   * score. Deliberately silent about whether the call was decent: §4.3 says the
   * cost is real and the verdict is absent, so there is no chime here and no
   * tally anywhere.
   */
  private applyEncounter(p: Patron, verdict: 'admit' | 'deny'): void {
    const id = this.queue.encounterFor(p);
    const enc = id ? encounterById(id) : undefined;
    if (!enc) return;
    const out = enc.resolve ? enc.resolve(p, verdict) : verdict === 'admit' ? enc.onAdmit : enc.onDeny;

    const delta: { vibe?: number; aggro?: number } = {};
    if (out.vibeDelta) delta.vibe = out.vibeDelta;
    if (out.aggroDelta) delta.aggro = out.aggroDelta;
    if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);

    if (out.heatStrike && shouldRecordStrike(this.night.state, out.heatStrike)) {
      // Fresh object per firing: the encounter tables are module singletons, so
      // pushing the literal would alias one strike across the whole run.
      this.night.bus.emit('heat:strike', { ...out.heatStrike });
    }
    if (out.dazzaText) this.night.bus.emit('dazza:text', { text: out.dazzaText });
    if (out.note) this.time.delayedCall(900, () => this.showToast(out.note!));

    // Later consequences (hen's party detonation, pap swarm) go through the
    // night's deferred machinery so they survive a location switch and land in
    // the summary with their reason attached.
    if (out.deferredHits && out.deferredHits.length > 0) {
      const dr = this.night.rng.stream('encounterAftermath');
      this.night.scheduleDeferred(
        out.deferredHits.map((h) => ({
          atClockMin: this.night.state.clockMin + dr.int(h.afterMin[0], h.afterMin[1]),
          ...(h.vibeDelta !== undefined ? { vibeDelta: h.vibeDelta } : {}),
          ...(h.aggroDelta !== undefined ? { aggroDelta: h.aggroDelta } : {}),
          reason: h.reason,
          patronId: p.id,
        })),
      );
    }

    // Whatever you ruled, Franko sets up by the planter and starts helping.
    if (id === 'exDoorman') this.frankoTips = 4;

    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: 'encounter',
      patronId: p.id,
      detail: `${id}: ${verdict === 'admit' ? 'let in' : 'turned away'}`,
    });
  }

  /**
   * Franko's tip for the patron at the rope. Category is drawn from the REAL
   * inspection surfaces; 1-in-5 he confidently names a problem the patron does
   * not have. Fourteen years of doors.
   */
  private frankoTip(p: Patron): string {
    const rng = this.night.rng.stream('franko');
    const v = idVerdict(p.idCard, this.night.nightDate);
    const stage = drunkStage(p.intoxication);
    const truths: Array<keyof typeof FRANKO_TIPS> = [];
    if (v.looksFake) truths.push('fake');
    if (v.underage) truths.push('underage');
    if (stage === 'loose' || stage === 'messy' || stage === 'maggot') truths.push('drunk');
    if (violations(p, this.night.state.clockMin).length > 0) truths.push('dress');
    if (truths.length === 0) truths.push('clean');
    let cat = truths[rng.int(0, truths.length - 1)]!;
    if (rng.chance(0.2)) {
      const lies = (Object.keys(FRANKO_TIPS) as Array<keyof typeof FRANKO_TIPS>).filter(
        (k) => !truths.includes(k),
      );
      if (lies.length > 0) cat = lies[rng.int(0, lies.length - 1)]!;
    }
    const pool = FRANKO_TIPS[cat];
    return pool[rng.int(0, pool.length - 1)]!;
  }

  /** The ask, the glow, and — ignored long enough — the sulk. */
  private tickKebab(): void {
    const min = this.night.state.clockMin;
    if (this.kebabAskAtMin !== null && !this.kebabHandled && !this.kebabPending && min >= this.kebabAskAtMin) {
      this.kebabPending = true;
      this.night.bus.emit('dazza:text', { text: KEBAB.ask });
      if (this.kebabGlow) {
        this.kebabGlow.setFillStyle(0xe8c060, 0.22);
        this.tweens.add({ targets: this.kebabGlow, alpha: { from: 1, to: 0.4 }, duration: 900, yoyo: true, repeat: -1 });
      }
    }
    if (this.kebabPending && this.kebabAskAtMin !== null && min >= this.kebabAskAtMin + 25) {
      // He waited 25 clock-minutes. He found the muesli bar.
      this.kebabPending = false;
      this.kebabHandled = true;
      if (this.kebabGlow) {
        this.kebabGlow.setFillStyle(0xe8c060, 0);
        this.tweens.killTweensOf(this.kebabGlow);
        this.kebabGlow.setAlpha(1);
      }
      this.night.bus.emit('meters:delta', { vibe: -2 });
      this.night.bus.emit('dazza:text', { text: KEBAB.sulk });
    }
  }

  /**
   * Twelve real seconds in the shop, in full view of the queue, while Kayden
   * finds the clipboard. The whole game in one errand (docs/SCENARIOS.md).
   */
  private startKebabRun(): void {
    if (!this.kebabPending || this.onKebabRun || this.busy) return;
    this.kebabPending = false;
    this.kebabHandled = true;
    this.onKebabRun = true;
    this.kebabRunMsLeft = 12_000;
    if (this.kebabGlow) {
      this.kebabGlow.setFillStyle(0xe8c060, 0);
      this.tweens.killTweensOf(this.kebabGlow);
      this.kebabGlow.setAlpha(1);
    }

    const shade = this.add.rectangle(320, 180, 640, 360, 0x08060a, 0.86).setDepth(950).setInteractive();
    const label = this.add
      .text(320, 150, KEBAB.running, {
        fontFamily: MONO,
        fontSize: '11px',
        color: '#e8c060',
        align: 'center',
        lineSpacing: 4,
      })
      .setOrigin(0.5)
      .setDepth(951);
    const bar = this.add.rectangle(320 - 90, 232, 180, 6, 0x3a3424).setOrigin(0, 0.5).setDepth(951);
    this.kebabFill = this.add.rectangle(320 - 90, 232, 1, 6, 0xe8c060).setOrigin(0, 0.5).setDepth(951);
    this.kebabUi = [shade, label, bar, this.kebabFill];
  }

  /** Driven from update() — a real 12s of shop time, not a tween the pane can stall. */
  private tickKebabRun(deltaMs: number): void {
    if (!this.onKebabRun) return;
    this.kebabRunMsLeft -= deltaMs;
    this.kebabFill?.setDisplaySize(Math.max(1, 180 * (1 - this.kebabRunMsLeft / 12_000)), 6);
    if (this.kebabRunMsLeft > 0) return;
    for (const o of this.kebabUi) o.destroy();
    this.kebabUi = [];
    this.kebabFill = null;
    this.onKebabRun = false;
    this.night.bus.emit('meters:delta', { vibe: 2 });
    this.night.bus.emit('dazza:text', { text: KEBAB.thanks });
    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: 'kebabRun',
      detail: 'Left the rope mid-shift. Returned with a kebab. Garlic sauce correct.',
    });
    this.showToast('back on the rope. the kebab is gone within the minute.');
  }

  private judgeCtx(): Parameters<typeof judge>[2] {
    return {
      clockMin: this.night.state.clockMin,
      nightDate: this.night.nightDate,
      lapRoll: this.night.rng.stream('dazzaLaps').next(),
      insideCount: this.night.state.capacity.inside,
      licensed: this.night.state.capacity.licensed,
    };
  }

  /** The one path from a DoorOutcome to the world. Nothing else touches meters. */
  private applyOutcome(patron: Patron, verdict: Verdict, outcome: DoorOutcome): void {
    const delta: { vibe?: number; aggro?: number; hype?: number } = {};
    if (outcome.vibeDelta) delta.vibe = outcome.vibeDelta;
    if (outcome.aggroDelta) delta.aggro = outcome.aggroDelta;
    if (outcome.hypeDelta) delta.hype = outcome.hypeDelta;
    if (Object.keys(delta).length > 0) this.night.bus.emit('meters:delta', delta);

    if (
      outcome.heatStrike &&
      !outcome.heatStrike.deferred &&
      shouldRecordStrike(this.night.state, outcome.heatStrike)
    ) {
      this.night.bus.emit('heat:strike', outcome.heatStrike);
    }
    if (outcome.dazzaText) this.night.bus.emit('dazza:text', { text: outcome.dazzaText });
    if (outcome.deferred?.length) this.night.scheduleDeferred(outcome.deferred);
    if (outcome.note) this.showToast(outcome.note);

    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: verdict,
      patronId: patron.id,
      detail: `${patron.archetype} · id ${idVerdict(patron.idCard, this.night.nightDate).claimedAge}`,
    });
    this.night.bus.emit('door:verdict', { patron, verdict, outcome });
    this.refreshCounters();
  }

  private pick(pool: readonly string[], p: Patron): string {
    return pool[p.dollSeed % pool.length] ?? pool[0]!;
  }

  private showToast(msg: string): void {
    this.toast.setText(msg).setAlpha(1).setY(178);
    this.tweens.killTweensOf(this.toast);
    this.tweens.add({ targets: this.toast, alpha: 0, y: 168, delay: 1900, duration: 700 });
  }

  private setVerdictButtons(on: boolean): void {
    this.buttons.admit?.setEnabled(on);
    this.buttons.deny?.setEnabled(on);
    this.buttons.test?.setEnabled(on);
    this.buttons.pat?.setEnabled(on);
  }

  /**
   * One Kayden decision. Synchronous — no animation, no busy flag, and the
   * patron never appears in the up-view, so returning to the door always finds
   * a clean rope. Kayden never tests, never pats down, and admits four in five.
   */
  private kaydenRule(): void {
    if (this.queue.patronUp) return; // player left mid-ruling; don't double-serve
    const p = this.queue.callNext();
    if (!p) return;
    const kayden = this.night.rng.stream('kayden');
    const verdict: Extract<Verdict, 'admit' | 'deny'> = kayden.chance(0.8) ? 'admit' : 'deny';
    const outcome = judge(p, verdict, this.judgeCtx());
    if (verdict === 'admit') this.night.state.capacity.inside++;
    // applyOutcome emits door:verdict (and the verdict incident) — same path as
    // a player ruling, so the floor is fed exactly once per admission.
    this.applyOutcome(p, verdict, outcome);
    this.applyEncounter(p, verdict);
    this.queue.resolveUp(verdict === 'deny');
    this.night.bus.emit('incident:log', {
      clockMin: this.night.state.clockMin,
      kind: 'kayden',
      patronId: p.id,
      detail: `Kayden ${verdict === 'admit' ? 'waved through' : 'knocked back'} ${p.archetype}`,
    });
  }

  /**
   * Rules cap at ACTIVE_RULE_CAP (Dazza's attention span). When a new one lands,
   * grey out whatever fell off the card and let Dazza announce the retraction —
   * judge() and the card must agree about what is live, or the card lies.
   */
  private retireStaleRules(): void {
    const live = new Set(activeRules(this.night.state.clockMin).map((r) => r.id));
    for (const r of announcedRules(this.night.state.clockMin)) {
      if (live.has(r.id)) continue;
      // Always re-sync the card: Dazza's texts can LAG the clock (the send
      // cooldown queues them), so a rule can be rescinded before its card row
      // has even landed. retire() is an idempotent no-op until the row exists,
      // and every later text re-runs this sync — the card converges.
      this.codeCard.retire(r.id);
      if (this.retiredRules.has(r.id)) continue; // toast once per rule, though
      this.retiredRules.add(r.id);
      const line = RULE_RETRACTIONS[this.retiredRules.size % RULE_RETRACTIONS.length]!;
      // A retraction is a TEXT — it lands on the phone with a buzz like every
      // other pronouncement, and lives in the thread as evidence.
      this.night.bus.emit('dazza:text', { text: line.replace('{rule}', r.short).replace('dazza: ', '') });
    }
  }

  private refreshCounters(): void {
    const cap = this.night.state.capacity;
    this.clickerText.setText(String(cap.clickerShown));
    this.clickerText.setColor(cap.clickerShown > cap.licensed ? '#ff8080' : '#f0e4d0');
    this.countText.setText(`INSIDE ${cap.inside}/${cap.licensed}`);
    this.countText.setColor(cap.inside > cap.licensed ? '#ff8080' : DOOR_PALETTE.inkDim);
    const strikes = this.night.state.heatStrikes.length;
    this.heatText.setText(strikes > 0 ? `HEAT ${'!'.repeat(Math.min(3, strikes))}` : '');
  }

  // ---------- frame ----------

  override update(_time: number, deltaMs: number): void {
    this.elapsedMs += deltaMs;
    this.queue.update(deltaMs);
    this.up.update(deltaMs);
    this.codeCard.update(this.elapsedMs);
    this.night.reportQueue(this.queue.queueLength);

    // Player is on the floor: Trainee Kayden works the rope. He is quick,
    // confident, and wrong a lot — his admits go through the same judge(), so
    // his mistakes cost the same vibe/heat yours do. (His stamp accuracy is
    // NightScene's problem; his verdicts are made here on the real queue.)
    if (!this.present || this.onKebabRun) {
      // "Hold the rope" (radio command): Kayden does nothing, magnificently.
      if (this.elapsedMs < this.kaydenHoldUntilMs) {
        this.kaydenAccMs = 0;
      } else {
        this.kaydenAccMs += deltaMs;
        while (this.kaydenAccMs >= KAYDEN_RULE_MS) {
          this.kaydenAccMs -= KAYDEN_RULE_MS;
          this.kaydenRule();
        }
      }
    }

    this.tickKebab();
    this.tickKebabRun(deltaMs);

    const locked = this.night.state.clockMin >= LOCKOUT_MIN;
    this.clockText.setText(locked ? `${this.night.clock.label} · LOCKOUT` : this.night.clock.label);
    this.clockText.setColor(locked ? '#ff8080' : '#9fe8a0');
    if (this.tutorial.visible) {
      const a = tutorialAlpha(this.elapsedMs);
      this.tutorial.setAlpha(a);
      if (a <= 0) this.tutorial.setVisible(false);
    }

    this.syncQueueSprites();
    this.syncCarpetSprites();
    this.buttons.carpet?.setEnabled(
      !this.busy && this.queue.patronUp !== null && this.queue.carpetSnapshot.length < CARPET_TUNING.slots,
    );

    const rope = this.buttons.rope;
    if (rope) {
      const waiting = this.queue.queueLength;
      const canCall = !this.queue.patronUp && waiting > 0;
      rope.setLabel(canCall ? `${DOOR_UI.ropeWaiting} (${waiting})` : DOOR_UI.rope);
      rope.setEnabled(canCall);
    }
  }

  /** The visible queue is a view of QueueManager, rebuilt only when it changes. */
  private syncQueueSprites(): void {
    const visible = this.queue.snapshot.visible;
    const same =
      visible.length === this.queueSprites.length &&
      visible.every((p, i) => this.queueSprites[i]?.patron.id === p.id);
    if (same) {
      // still animate the drunk sway of whoever is waiting
      for (const s of this.queueSprites) {
        const sway = dollPlan(s.patron, 'queue').swayPx;
        if (sway > 0) s.image.x = s.image.getData('baseX') + Math.sin(this.elapsedMs / 320) * sway;
      }
      return;
    }

    for (const s of this.queueSprites) {
      s.image.destroy();
      s.link?.destroy();
    }
    this.queueSprites = visible.map((p, i) => {
      const x = 196 - i * 24;
      const y = QUEUE_FOOT_Y - (i % 2) * 3;
      const img = this.add
        .image(x, y, renderDoll(this, p, 'queue'))
        .setOrigin(0.5, 1)
        .setScale(1.5)
        .setDepth(200 - i)
        .setTint(0xc8c8d8);
      img.setData('baseX', x);

      // hand-holding couples are drawn linked, so noHandHolders is a rule you
      // can actually see coming (rules judge data, players judge pixels)
      let link: Phaser.GameObjects.Rectangle | null = null;
      const prev = visible[i - 1];
      if (p.flags.handHolding && prev?.flags.handHolding) {
        link = this.add.rectangle(x + 12, y - 26, 14, 2, 0xe0a0b0).setDepth(210);
      }
      return { patron: p, image: img, link };
    });

    const offscreen = this.queue.snapshot.offscreen;
    this.countText.setText(
      `INSIDE ${this.night.state.capacity.inside}/${this.night.state.capacity.licensed}` +
        (offscreen > 0 ? `  +${offscreen} round the corner` : ''),
    );
  }

  private teardown(): void {
    for (const s of this.carpetSprites) s.image.destroy();
    this.carpetSprites = [];
    for (const off of this.offs) off();
    this.offs = [];
    this.phone?.destroy();
    this.codeCard?.destroy();
    this.sobriety?.close();
    this.idCard?.close();
    this.up?.clear();
  }
}
