import Phaser from 'phaser'; import { BackendClient, actorName, seatTexturePrefix, targetLabel, type EventView, type MafiaGameView, type PlayerView, type Role, type Suggestion, } from '../game/BackendClient'; import * as utils from '../utils'; interface Seat { id: string; container: Phaser.GameObjects.Container; portrait: Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle; nameText: Phaser.GameObjects.Text; badgeText: Phaser.GameObjects.Text; ring: Phaser.GameObjects.Arc; deadText: Phaser.GameObjects.Text; } const SEATS: Record = { p1: { x: 768, y: 768 }, p2: { x: 1102, y: 710 }, p3: { x: 1112, y: 496 }, p4: { x: 1034, y: 260 }, p5: { x: 768, y: 194 }, p6: { x: 502, y: 260 }, p7: { x: 396, y: 496 }, }; const ROLE_COLORS: Record = { Mafia: '#eb5757', Detective: '#74b7e8', Doctor: '#6fcf97', Villager: '#e8c87a', }; const PLAYER_COLORS: Record = { p1: '#e8c87a', p2: '#eb5757', p3: '#74b7e8', p4: '#d9a85f', p5: '#6fcf97', p6: '#c785ff', p7: '#f2994a', moderator: '#9ecfff', }; export class MainTableScene extends Phaser.Scene { private client = new BackendClient(); private view!: MafiaGameView; private bgm?: Phaser.Sound.BaseSound; private seats: Record = {}; private actionButtons: Phaser.GameObjects.Container[] = []; private overlay?: Phaser.GameObjects.Container; private chatInput?: Phaser.GameObjects.DOMElement; private bannerText!: Phaser.GameObjects.Text; private subText!: Phaser.GameObjects.Text; private aliveText!: Phaser.GameObjects.Text; private ledgerText!: Phaser.GameObjects.Text; private chatText!: Phaser.GameObjects.Text; private suggestionText!: Phaser.GameObjects.Text; private ledgerDom?: Phaser.GameObjects.DOMElement; private chatDom?: Phaser.GameObjects.DOMElement; private suggestionDom?: Phaser.GameObjects.DOMElement; private autoAdvanceTimer?: Phaser.Time.TimerEvent; private busy = false; private lastSoundSeq = 0; constructor() { super({ key: 'MainTableScene' }); } create(): void { const view = window.__MAFIA_VIEW__; if (!view) { this.scene.start('TitleScreen'); return; } this.view = view; this.createBackground(); this.createTable(); this.createSeats(); this.createHUD(); this.createChatInput(); this.playMusic(); this.cameras.main.fadeIn(450, 0, 0, 0); this.refreshAll(); this.events.once('shutdown', () => this.cleanup()); } private createBackground(): void { const cam = this.cameras.main; const key = this.view?.scene?.sceneKey === 'vote' && utils.textureExists(this, 'main_table_bg') ? 'main_table_bg' : 'main_table_bg'; if (utils.textureExists(this, key)) { const bg = this.add.image(cam.width / 2, cam.height / 2, key); bg.setDisplaySize(cam.width, cam.height); this.add.rectangle(cam.width / 2, cam.height / 2, cam.width, cam.height, 0x000000, 0.28); } else { this.add.rectangle(cam.width / 2, cam.height / 2, cam.width, cam.height, 0x110d0a, 1); } } private createTable(): void { this.add.rectangle(768, 512, 720, 520, 0x000000, 0.08); } private createSeats(): void { this.view.players.forEach((player) => { const pos = SEATS[player.id] ?? { x: 768, y: 500 }; const container = this.add.container(pos.x, pos.y); const ring = this.add.circle(0, 0, player.isHuman ? 88 : 78, 0xe8c87a, 0).setStrokeStyle(4, 0xe8c87a, 0.95); ring.setVisible(false); container.add(ring); const prefix = this.texturePrefixFor(player); const portraitKey = `${prefix}_neutral`; let portrait: Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle; if (utils.textureExists(this, portraitKey)) { const img = this.add.image(0, 0, portraitKey); img.setScale(Math.min((player.isHuman ? 146 : 132) / img.height, (player.isHuman ? 146 : 132) / img.width)); portrait = img; } else { portrait = this.add.rectangle(0, 0, 120, 138, 0x3a2418, 0.92).setStrokeStyle(2, 0x6b5326); } container.add(portrait); const plateWidth = player.isHuman ? 250 : 214; const plate = this.add.rectangle(0, 112, plateWidth, 76, 0x0c0a12, 0.92).setStrokeStyle(1, 0x6b5326); const nameText = this.add.text(0, 96, player.isHuman ? `${player.name} (You)` : player.name, { fontFamily: 'monospace', fontSize: player.isHuman ? '17px' : '15px', color: '#e8dcc0', stroke: '#000', strokeThickness: 3, fixedWidth: plateWidth - 14, fixedHeight: 24, wordWrap: { width: plateWidth - 18 }, align: 'center', }).setOrigin(0.5); const badgeText = this.add.text(0, 124, '', { fontFamily: 'monospace', fontSize: '11px', color: '#b99b70', fixedWidth: plateWidth - 16, fixedHeight: 34, wordWrap: { width: plateWidth - 20 }, align: 'center', }).setOrigin(0.5); const deadText = this.add.text(0, 0, 'ELIMINATED', { fontFamily: 'monospace', fontSize: '14px', color: '#eb5757', backgroundColor: '#140708', padding: { x: 8, y: 4 }, }).setOrigin(0.5).setVisible(false); container.add([plate, nameText, badgeText, deadText]); this.seats[player.id] = { id: player.id, container, portrait, nameText, badgeText, ring, deadText }; }); } private createHUD(): void { this.add.rectangle(768, 58, 920, 108, 0x0c0a12, 0.86).setStrokeStyle(2, 0x6b5326); this.bannerText = this.add.text(768, 28, '', { fontFamily: 'Georgia', fontSize: '30px', color: '#e8c87a', stroke: '#000', strokeThickness: 4, }).setOrigin(0.5); this.subText = this.add.text(768, 66, '', { fontFamily: 'monospace', fontSize: '14px', color: '#d6d2c4', }).setOrigin(0.5); this.aliveText = this.add.text(768, 92, '', { fontFamily: 'monospace', fontSize: '13px', color: '#b99b70', }).setOrigin(0.5); this.add.rectangle(136, 510, 264, 790, 0x0c0a12, 0.78).setStrokeStyle(2, 0x6b5326); this.add.text(18, 130, 'STATUS UPDATES', { fontFamily: 'monospace', fontSize: '15px', color: '#b8443c', stroke: '#000', strokeThickness: 3, }); this.ledgerText = this.add.text(18, 164, '', { fontFamily: 'monospace', fontSize: '12px', color: '#d6d2c4', lineSpacing: 4, wordWrap: { width: 226 }, }).setVisible(false); this.add.rectangle(1410, 510, 246, 790, 0x0c0a12, 0.8).setStrokeStyle(2, 0x6b5326); this.add.text(1308, 130, 'DISCUSSIONS', { fontFamily: 'monospace', fontSize: '15px', color: '#b8443c', stroke: '#000', strokeThickness: 3, }); this.chatText = this.add.text(1308, 162, '', { fontFamily: 'monospace', fontSize: '12px', color: '#e8dcc0', lineSpacing: 5, wordWrap: { width: 216 }, }).setVisible(false); this.suggestionText = this.add.text(1308, 772, '', { fontFamily: 'monospace', fontSize: '12px', color: '#b99b70', lineSpacing: 5, wordWrap: { width: 216 }, }).setVisible(false); this.createInfoDomPanels(); } private createInfoDomPanels(): void { const ledger = document.createElement('div'); ledger.className = 'mafia-ledger-dom-panel'; this.ledgerDom = this.add.dom(136, 506, ledger).setDepth(18); const chat = document.createElement('div'); chat.className = 'mafia-chat-dom-panel'; this.chatDom = this.add.dom(1410, 444, chat).setDepth(18); const suggestions = document.createElement('div'); suggestions.className = 'mafia-suggestions-dom-panel'; suggestions.addEventListener('click', (event) => { const target = event.target as HTMLElement | null; const button = target?.closest('button[data-suggestion-index]'); if (!button) return; event.stopPropagation(); const index = Number(button.dataset.suggestionIndex ?? '-1'); const suggestion = (this.view.suggestions ?? [])[index]; if (suggestion) { this.playClick(); void this.sendSuggestion(suggestion); } }); this.suggestionDom = this.add.dom(1410, 806, suggestions).setDepth(18); } private createChatInput(): void { const wrapper = document.createElement('form'); wrapper.className = 'mafia-chat-compose'; wrapper.innerHTML = ` `; const input = wrapper.querySelector('input'); if (!input) return; const sendButton = wrapper.querySelector('button'); const send = () => { if (!input.value.trim()) return; this.playClick(); void this.commitAction('message', () => this.client.message(this.view.gameId, input.value.trim()), 'Message sent to the moderator queue.'); input.value = ''; }; input.addEventListener('keydown', (event) => { event.stopPropagation(); if (event.key === 'Enter' && input.value.trim()) { send(); } }); wrapper.addEventListener('submit', (event) => { event.preventDefault(); event.stopPropagation(); send(); }); sendButton?.addEventListener('pointerdown', (event) => event.stopPropagation()); this.chatInput = this.add.dom(1410, 930, wrapper).setDepth(20); } private playMusic(): void { this.bgm = utils.safeAddSound(this, 'main_table_bgm', { volume: 0.24, loop: true }); this.bgm?.play(); } private refreshAll(): void { window.__MAFIA_VIEW__ = this.view; this.bannerText.setText(this.view.scene.title); this.subText.setText(this.view.scene.subtitle); this.aliveText.setText(`Alive: ${this.view.aliveCount}/7 • Day ${this.view.day} • ${this.view.mode} • Your role: ${this.view.human.role}`); this.refreshSeats(); this.refreshLedger(); this.refreshChat(); this.refreshSuggestions(); this.playNewEventSounds(); this.renderActions(); this.scheduleAutonomousModeration(); if (this.view.winner) this.goEndgame(); } private refreshSeats(): void { const votes = this.view.votes ?? {}; const voteCounts = new Map(); Object.values(votes).forEach((target) => voteCounts.set(target, (voteCounts.get(target) ?? 0) + 1)); this.view.players.forEach((player) => { const seat = this.seats[player.id]; if (!seat) return; seat.nameText.setText(player.isHuman ? `${player.name} (You)` : player.name); const votesOn = voteCounts.get(player.id); seat.badgeText.setText(this.seatStatus(player, votesOn)); seat.deadText.setVisible(!player.alive); seat.container.setAlpha(player.alive ? 1 : 0.5); if (!player.alive && seat.portrait instanceof Phaser.GameObjects.Image) { const prefix = this.texturePrefixFor(player); const deadKey = `${prefix}_dead`; if (utils.textureExists(this, deadKey)) seat.portrait.setTexture(deadKey); seat.portrait.setTint(0x8b8791); } if (this.view.hotSeatTarget === player.id) { seat.ring.setVisible(true); } else { seat.ring.setVisible(false); } }); } private refreshLedger(): void { const claimItems = this.view.players .filter((player) => player.claimedRole) .map((player) => ({ kind: 'claim', text: `${player.name}: claims ${player.claimedRole}` })); const voteItems = [ ...this.view.players .filter((player) => player.lastVote) .map((player) => ({ kind: 'vote', text: `${player.name} voted ${actorName(this.view, player.lastVote)}` })), ...this.view.events .filter((event) => ['accusation_started', 'vote_resolved', 'vote_tied'].includes(event.type)) .slice(-5) .map((event) => ({ kind: event.type, text: this.describeEvent(event) })), ]; const deathItems = [ ...this.view.players .filter((player) => !player.alive) .map((player) => ({ kind: 'death', text: `${player.name} eliminated` })), ...this.view.events .filter((event) => ['dawn_announced', 'doctor_save'].includes(event.type)) .slice(-4) .map((event) => ({ kind: event.type, text: this.describeEvent(event) })), ]; if (this.ledgerDom?.node instanceof HTMLElement) { this.ledgerDom.node.innerHTML = [ this.renderLedgerSection('Claims', claimItems), this.renderLedgerSection('Votes', voteItems), this.renderLedgerSection('Deaths', deathItems), ].join(''); return; } this.ledgerText.setText( [...claimItems, ...voteItems, ...deathItems].map((item) => `• ${item.text}`).join('\n') || '• No status updates yet.', ); } private refreshChat(): void { const lines = this.view.events .filter((event) => event.type === 'player_message' || event.type === 'moderator_cue' || event.type === 'private_moderator_cue') .slice(-13) .map((event) => { const moderator = event.type === 'moderator_cue' || event.type === 'private_moderator_cue'; const isPrivate = Boolean(event.payload.private); const who = moderator ? 'Moderator' : actorName(this.view, event.actor); const message = String(event.payload.message ?? ''); const tone = isPrivate ? 'private' : ''; return { who, message, tone, moderator, private: isPrivate, actor: event.actor, type: event.type }; }) .filter((line, index, all) => { const previous = all[index - 1]; return !previous || previous.actor !== line.actor || previous.type !== line.type || previous.message !== line.message || previous.private !== line.private; }); if (this.chatDom?.node instanceof HTMLElement) { this.chatDom.node.innerHTML = lines.length ? lines.map((line) => `
${this.escape(line.who)}${line.tone ? `${this.escape(line.tone)}` : ''}

${this.escape(line.message)}

`).join('') : '
The room is waiting for the first words.
'; this.chatDom.node.scrollTop = this.chatDom.node.scrollHeight; return; } this.chatText.setText(lines.map((line) => `${line.who}${line.tone ? ` [${line.tone}]` : ''}\n ${line.message}`).join('\n\n') || 'The room is waiting for the first words.'); } private refreshSuggestions(): void { const objective = this.view.scene.objective; const renderDom = (html: string) => { if (this.suggestionDom?.node instanceof HTMLElement) { this.suggestionDom.node.innerHTML = html; return true; } return false; }; if (!this.view.human.alive) { if (renderDom(`
${this.escape('You are eliminated. Watch the remaining players finish the game.')}
`)) return; this.suggestionText.setText('You are eliminated. Watch the remaining players finish the game.'); return; } if (renderDom(`
Objective
${this.escape(objective)}
`)) return; this.suggestionText.setText(objective); } private playNewEventSounds(): void { const latestSeq = Math.max(0, ...this.view.events.map((event) => event.seq)); if (!this.lastSoundSeq) { this.lastSoundSeq = latestSeq; return; } const fresh = this.view.events.filter((event) => event.seq > this.lastSoundSeq); this.lastSoundSeq = latestSeq; if (!fresh.length) return; if (fresh.some((event) => event.type === 'vote_resolved' || event.type === 'player_eliminated')) { utils.safeAddSound(this, 'damage_sfx', { volume: 0.5 })?.play(); return; } if (fresh.some((event) => event.type === 'vote_cast' || event.type === 'accusation_started')) { utils.safeAddSound(this, 'vote_sfx', { volume: 0.46 })?.play(); return; } if (fresh.some((event) => ['player_message', 'moderator_cue', 'private_moderator_cue'].includes(event.type))) { utils.safeAddSound(this, 'click_sfx', { volume: 0.28 })?.play(); } } private renderActions(): void { this.clearButtons(); this.chatInput?.setVisible(this.view.human.alive && this.view.human.legalActions.includes('message')); if (this.busy) { this.showButtons([{ label: 'Moderating...', onClick: () => undefined }]); return; } if (!this.view.human.alive) { this.showButtons([{ label: 'Watch table advance', onClick: () => this.advance(4) }]); return; } const legal = new Set(this.view.human.legalActions); const buttons: { label: string; onClick: () => void; color?: number }[] = []; if (legal.has('kill') || legal.has('check') || legal.has('protect')) { const action = legal.has('kill') ? 'Choose victim' : legal.has('check') ? 'Investigate' : 'Protect'; buttons.push({ label: action, color: legal.has('kill') ? 0x4a1414 : legal.has('check') ? 0x14304a : 0x144a2a, onClick: () => this.chooseTarget(action) }); } if (legal.has('sleep')) buttons.push({ label: 'Sleep through night', onClick: () => this.advance(4) }); if (this.view.phase === 'dawn' || this.view.phase === 'resolution') buttons.push({ label: 'Continue', onClick: () => this.advance(2) }); if (legal.has('message') && this.view.pendingHumanFloor) { buttons.push({ label: 'Done speaking', onClick: () => this.passFloor(), }); } if (legal.has('claim')) buttons.push({ label: 'Claim role', onClick: () => this.chooseRoleClaim() }); if (legal.has('accuse')) buttons.push({ label: 'Accuse', color: 0x4a1414, onClick: () => this.chooseTarget('Accuse') }); if (legal.has('vote')) buttons.push({ label: 'Vote', color: 0x4a1414, onClick: () => this.chooseTarget('Vote') }); if (!buttons.length) buttons.push({ label: 'Continue', onClick: () => this.advance(1) }); this.showButtons(buttons.slice(0, 6)); } private showButtons(buttons: { label: string; onClick: () => void; color?: number }[]): void { const btnW = 170; const gap = 14; const perRow = buttons.length > 4 ? 3 : buttons.length; const rowCount = Math.ceil(buttons.length / Math.max(1, perRow)); const firstY = rowCount > 1 ? 922 : 960; buttons.forEach((button, index) => { const row = Math.floor(index / perRow); const rowButtons = buttons.slice(row * perRow, row * perRow + perRow); const col = index % perRow; const totalW = rowButtons.length * btnW + (rowButtons.length - 1) * gap; const x = 768 - totalW / 2 + btnW / 2 + col * (btnW + gap); const y = firstY + row * 66; const c = this.add.container(x, y).setDepth(30); const bg = this.add.rectangle(0, 0, btnW, 58, button.color ?? 0x2a1f14, 0.96).setStrokeStyle(2, 0x6b5326).setInteractive({ useHandCursor: true }); const label = this.add.text(0, 0, button.label, { fontFamily: 'monospace', fontSize: '15px', color: '#f4dfbd', stroke: '#000', strokeThickness: 3, wordWrap: { width: btnW - 14 }, align: 'center', }).setOrigin(0.5); bg.on('pointerover', () => bg.setFillStyle(0x7a2828, 0.96)); bg.on('pointerout', () => bg.setFillStyle(button.color ?? 0x2a1f14, 0.96)); bg.on('pointerdown', () => { this.playClick(0.35); button.onClick(); }); c.add([bg, label]); this.actionButtons.push(c); }); } private chooseTarget(intent: string): void { const allowSelf = intent === 'Protect'; const targets = this.view.targetChoices.filter((choice) => allowSelf || choice.id !== this.view.human.id); this.showOverlay(`${intent}: choose a living player`, targets.map((target) => ({ label: target.label, onClick: () => { this.clearOverlay(); if (intent === 'Accuse') void this.commitAction('accuse', () => this.client.accuse(this.view.gameId, target.id)); else if (intent === 'Vote') void this.commitAction('vote', () => this.client.vote(this.view.gameId, target.id)); else void this.commitAction('night action', () => this.client.nightAction(this.view.gameId, target.id)); }, }))); } private chooseRoleClaim(): void { const roles = (this.view.roleChoices ?? ['Villager', 'Doctor', 'Detective', 'Mafia']) as Role[]; this.showOverlay('Claim a role publicly', roles.map((role) => ({ label: role, color: ROLE_COLORS[role], onClick: () => { this.clearOverlay(); void this.commitAction('claim', () => this.client.claim(this.view.gameId, role)); }, }))); } private showOverlay(title: string, options: { label: string; onClick: () => void; color?: string }[]): void { this.clearOverlay(); const overlay = this.add.container(768, 520).setDepth(80); overlay.add(this.add.rectangle(0, 0, 560, 460, 0x0c0a12, 0.94).setStrokeStyle(2, 0x6b5326)); overlay.add(this.add.text(0, -190, title, { fontFamily: 'Georgia', fontSize: '26px', color: '#e8c87a' }).setOrigin(0.5)); let y = -126; options.slice(0, 8).forEach((option) => { const bg = this.add.rectangle(0, y, 430, 42, option.color ? parseInt(option.color.slice(1), 16) : 0x2a1f14, 0.96).setStrokeStyle(1, 0x6b5326).setInteractive({ useHandCursor: true }); const text = this.add.text(0, y, option.label, { fontFamily: 'monospace', fontSize: '16px', color: '#f4dfbd' }).setOrigin(0.5); bg.on('pointerdown', () => { this.playClick(); option.onClick(); }); overlay.add([bg, text]); y += 52; }); const cancel = this.add.rectangle(0, 180, 220, 42, 0x1b1712, 0.96).setStrokeStyle(1, 0x6b5326).setInteractive({ useHandCursor: true }); const cancelText = this.add.text(0, 180, 'Back', { fontFamily: 'monospace', fontSize: '16px', color: '#e8dcc0' }).setOrigin(0.5); cancel.on('pointerdown', () => { this.playClick(); this.clearOverlay(); }); overlay.add([cancel, cancelText]); this.overlay = overlay; } private async sendSuggestion(suggestion: Suggestion): Promise { await this.commitAction(suggestion.intent, () => this.client.approveSuggestion(this.view.gameId, suggestion), 'Suggested message sent to the moderator queue.'); } private async advance(maxSteps: number): Promise { await this.commitAction('advance', () => this.client.advance(this.view.gameId, maxSteps)); } private async passFloor(): Promise { await this.commitAction('wait', () => this.client.passFloor(this.view.gameId), 'You waited. The moderator is moving the floor.'); } private scheduleAutonomousModeration(): void { this.autoAdvanceTimer?.remove(false); this.autoAdvanceTimer = undefined; if (this.busy || this.view.winner || !this.view.human.alive || !this.shouldAutonomouslyModerate()) return; this.autoAdvanceTimer = this.time.delayedCall(3600, () => { this.autoAdvanceTimer = undefined; if (!this.busy && this.shouldAutonomouslyModerate()) void this.advance(1); }); } private shouldAutonomouslyModerate(): boolean { if (this.view.pendingHumanFloor) return false; if (this.view.phase !== 'discussion' && this.view.phase !== 'hot_seat') return false; const legal = new Set(this.view.human.legalActions); return !legal.has('kill') && !legal.has('check') && !legal.has('protect') && !legal.has('vote'); } private async commitAction(label: string, action: () => Promise, successMessage?: string): Promise { if (this.busy) return; this.busy = true; this.bannerText.setText(label === 'advance' ? 'Moderating...' : 'Committing action...'); this.renderActions(); let committed = false; try { const next = await action(); this.view = next; committed = true; const sound = this.view.winner ? 'victory_sfx' : label === 'vote' ? 'vote_sfx' : label === 'night action' && this.view.human.role === 'Mafia' ? 'damage_sfx' : 'click_sfx'; utils.safeAddSound(this, sound, { volume: sound === 'damage_sfx' ? 0.45 : 0.35 })?.play(); } catch (error) { this.subText.setText(error instanceof Error ? error.message : String(error)); utils.safeAddSound(this, 'wrong_sfx', { volume: 0.35 })?.play(); } finally { this.busy = false; this.refreshAll(); if (committed && successMessage) this.subText.setText(successMessage); } } private describeEvent(event: EventView): string { if (event.type === 'dawn_announced') return String(event.payload.message ?? 'Dawn was announced.'); if (event.type === 'accusation_started') return `${actorName(this.view, event.actor)} accused ${actorName(this.view, String(event.payload.target ?? ''))}.`; if (event.type === 'vote_resolved') return `Vote resolved: ${actorName(this.view, String(event.payload.eliminated ?? ''))} was eliminated.`; if (event.type === 'vote_tied') return 'The vote tied. No one was eliminated.'; if (event.type === 'doctor_save') return 'No one was eliminated during the night.'; return event.type; } private seatStatus(player: PlayerView, votesOn?: number): string { if (!player.alive) return 'eliminated'; if (this.view.hotSeatTarget === player.id) return 'on the hot seat'; if (votesOn) return `${votesOn} vote${votesOn > 1 ? 's' : ''} on them`; if (player.claimedRole) return `claims ${player.claimedRole}`; return 'watching'; } private clearButtons(): void { this.actionButtons.forEach((button) => button.destroy()); this.actionButtons = []; } private clearOverlay(): void { this.overlay?.destroy(); this.overlay = undefined; } private playClick(volume = 0.32): void { utils.safeAddSound(this, 'click_sfx', { volume })?.play(); } private goEndgame(): void { this.clearButtons(); this.clearOverlay(); this.chatInput?.destroy(); this.cameras.main.fadeOut(500, 0, 0, 0); this.time.delayedCall(520, () => { this.bgm?.stop(); this.scene.start('EndgameScene'); }); } private cleanup(): void { this.bgm?.stop(); this.autoAdvanceTimer?.remove(false); this.autoAdvanceTimer = undefined; this.chatInput?.destroy(); this.ledgerDom?.destroy(); this.chatDom?.destroy(); this.suggestionDom?.destroy(); this.clearButtons(); this.clearOverlay(); } private escape(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } private texturePrefixFor(player: PlayerView): string { if (player.id === this.view.human.id) { return window.__MAFIA_AVATAR_ID__ ?? this.view.humanAvatar ?? player.avatar ?? 'player'; } return seatTexturePrefix[player.id] ?? 'player'; } private playerColor(playerId: string | null, moderator: boolean): string { if (moderator) return PLAYER_COLORS.moderator; return playerId ? PLAYER_COLORS[playerId] ?? '#e8c87a' : '#e8c87a'; } private renderLedgerSection(title: string, items: { kind: string; text: string }[]): string { const rows = items.slice(-6).map((item) => `

${this.escape(item.text)}

`).join(''); return `

${this.escape(title)}

${rows || '
No updates yet.
'}
`; } }