/** * Choice Display Module * Renders choice-mode interactions from TurnResult choices. */ import { BaseModule } from './base-module.js'; class ChoiceDisplayModule extends BaseModule { constructor() { super('choice-display', 'Choice Display'); this.dependencies = ['socket-client']; this.socketClient = null; this.container = null; this.choices = []; this.inputMode = 'text'; this.processState = document.documentElement.dataset.processState || 'loading'; this.template = { cells: { default: { label: '', match: () => true } }, fallbackCell: 'default' }; this.bindMethods([ 'initialize', 'setupContainer', 'handleChoices', 'handleInputMode', 'handleProcessState', 'handleKeyDown', 'render', 'clear', 'normalizeChoices', 'assignLetters', 'selectChoice', 'getTagValue', 'getTemplateCell' ]); } async initialize() { this.socketClient = this.getModule('socket-client'); this.setupContainer(); this.addEventListener(document, 'story:choices', (event) => { this.handleChoices(event.detail || []); }); this.addEventListener(document, 'story:input-mode', (event) => { this.handleInputMode(event.detail || 'text'); }); this.addEventListener(document, 'story:process-state', (event) => { this.handleProcessState(event.detail?.state || 'ready'); }); this.addEventListener(document, 'story:turn-start', () => { this.processState = 'waiting-generating'; this.render(); }); this.addEventListener(document, 'story:history-restoring', (event) => { document.documentElement.dataset.historyRestoring = event.detail?.active ? 'true' : 'false'; this.render(); }); this.addEventListener(document, 'keydown', this.handleKeyDown); this.reportProgress(100, 'Choice display ready'); return true; } setupContainer() { const choicesRoot = document.getElementById('choices'); if (!choicesRoot) { return; } this.container = document.getElementById('story_choices'); if (!this.container) { this.container = document.createElement('div'); this.container.id = 'story_choices'; this.container.className = 'story-choices'; } const commandInput = document.getElementById('command_input'); if (this.container.parentElement !== choicesRoot) { choicesRoot.insertBefore(this.container, commandInput || null); } else if (commandInput && this.container.nextElementSibling !== commandInput) { choicesRoot.insertBefore(this.container, commandInput); } else if (!commandInput && this.container !== choicesRoot.lastElementChild) { choicesRoot.appendChild(this.container); } this.container.hidden = true; this.container.dataset.choiceReady = 'false'; } handleChoices(choices) { this.choices = this.normalizeChoices(Array.isArray(choices) ? choices : []); this.render(); } handleInputMode(inputMode) { this.inputMode = ['text', 'choice', 'end'].includes(inputMode) ? inputMode : 'text'; this.render(); } handleProcessState(state) { this.processState = state || 'ready'; this.render(); } handleKeyDown(event) { if (this.inputMode !== 'choice' || !this.choices.length) { return; } const optionsModal = document.getElementById('options-modal'); if (optionsModal && optionsModal.style.display !== 'none') { return; } if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) { return; } const letter = event.key.toUpperCase(); const choice = this.choices.find((item) => item.letter === letter); if (!choice) { return; } event.preventDefault(); this.selectChoice(choice.index); } normalizeChoices(choices) { return this.assignLetters(choices.slice(0, 26).map((choice, order) => { const tags = Array.isArray(choice.tags) ? choice.tags : []; const category = choice.category || this.getTagValue(tags, 'action'); return { index: Number.isInteger(choice.index) ? choice.index : order, text: String(choice.text || ''), tags, category, letter: '', templateCell: this.getTemplateCell({ ...choice, tags, category }) }; })); } assignLetters(choices) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const reserved = new Set(); choices.forEach((choice) => { const explicit = String(choice.letter || this.getTagValue(choice.tags, 'letter') || '') .trim() .charAt(0) .toUpperCase(); if (alphabet.includes(explicit) && !reserved.has(explicit)) { choice.letter = explicit; reserved.add(explicit); } }); let nextLetterIndex = 0; choices.forEach((choice) => { if (choice.letter) return; while (nextLetterIndex < alphabet.length && reserved.has(alphabet[nextLetterIndex])) { nextLetterIndex += 1; } if (nextLetterIndex < alphabet.length) { choice.letter = alphabet[nextLetterIndex]; reserved.add(choice.letter); nextLetterIndex += 1; } }); return choices; } getTemplateCell(choice) { const entries = Object.entries(this.template.cells); const match = entries.find(([cellName, cell]) => { if (cellName === this.template.fallbackCell) return false; return typeof cell.match === 'function' && cell.match(choice); }); return match ? match[0] : this.template.fallbackCell; } getTagValue(tags, key) { const normalizedKey = String(key).toLowerCase(); const tag = tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey); return tag?.value; } render() { this.setupContainer(); if (!this.container) return; this.container.innerHTML = ''; const restoringHistory = document.documentElement.dataset.historyRestoring === 'true'; const gameRunning = document.body?.dataset?.gameRunning === 'true'; const readyForChoices = gameRunning && !restoringHistory && this.inputMode === 'choice' && this.choices.length > 0 && this.processState === 'ready'; this.container.hidden = !readyForChoices; this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false'; if (this.container.hidden) { return; } const list = document.createElement('ol'); list.className = 'choice-list choice-template-default'; this.choices.forEach((choice) => { const item = document.createElement('li'); item.className = 'choice-list-item'; item.dataset.choiceIndex = String(choice.index); item.dataset.choiceLetter = choice.letter; item.dataset.templateCell = choice.templateCell; const button = document.createElement('button'); button.type = 'button'; button.className = 'choice-button'; button.innerHTML = `${choice.letter}${this.escapeHtml(choice.text)}`; button.addEventListener('click', () => this.selectChoice(choice.index)); item.appendChild(button); list.appendChild(item); }); this.container.appendChild(list); } async selectChoice(index) { if (!this.socketClient) { this.socketClient = this.getModule('socket-client'); } if (!this.socketClient || typeof this.socketClient.chooseChoice !== 'function') { console.error('ChoiceDisplay: Socket client cannot choose choices'); return; } this.clear(); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index } })); await this.socketClient.chooseChoice(index); } clear() { this.choices = []; if (this.container) { this.container.innerHTML = ''; this.container.hidden = true; } } escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } } const choiceDisplay = new ChoiceDisplayModule(); export { choiceDisplay as ChoiceDisplay }; if (window.moduleRegistry) { window.moduleRegistry.register(choiceDisplay); } window.ChoiceDisplay = choiceDisplay;