/** * 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', 'markup-parser']; this.socketClient = null; this.markupParser = null; this.container = null; this.choices = []; this.inputMode = 'none'; 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', 'renderChoiceText' ]); } async initialize() { this.socketClient = this.getModule('socket-client'); this.markupParser = this.getModule('markup-parser'); this.setupContainer(); this.addEventListener(document, 'story:choices', (event) => { this.handleChoices(event.detail || []); }); this.addEventListener(document, 'story:input-mode', (event) => { this.handleInputMode(event.detail || 'none'); }); 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', 'none'].includes(inputMode) ? inputMode : 'none'; 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, 36).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, optional: this.hasTag(tags, 'optional'), letter: '', templateCell: this.getTemplateCell({ ...choice, tags, category }) }; })); } assignLetters(choices) { const keySequence = '1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const reserved = new Set(); choices.forEach((choice) => { const explicit = String(choice.letter || this.getTagValue(choice.tags, 'letter') || '') .trim() .charAt(0) .toUpperCase(); const keyExplicit = String(choice.letter || this.getTagValue(choice.tags, 'key') || '') .trim() .charAt(0) .toUpperCase(); const reservedLetter = explicit || keyExplicit; if (keySequence.includes(reservedLetter) && !reserved.has(reservedLetter)) { choice.letter = reservedLetter; reserved.add(reservedLetter); } }); let nextLetterIndex = 0; choices.forEach((choice) => { if (choice.letter) return; while (nextLetterIndex < keySequence.length && reserved.has(keySequence[nextLetterIndex])) { nextLetterIndex += 1; } if (nextLetterIndex < keySequence.length) { choice.letter = keySequence[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; } hasTag(tags, key) { const normalizedKey = String(key).toLowerCase(); return Array.isArray(tags) && tags.some((item) => String(item?.key || '').toLowerCase() === normalizedKey); } 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.classList.toggle('choice-optional', Boolean(choice.optional)); 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'; const renderedText = this.renderChoiceText(choice.text); const displayKey = this.formatChoiceKey(choice.letter); button.innerHTML = `${this.escapeHtml(displayKey)}${choice.optional ? `${renderedText}` : renderedText}`; 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, '"'); } renderChoiceText(text) { if (!this.markupParser) { this.markupParser = this.getModule('markup-parser'); } if (this.markupParser && typeof this.markupParser.markdownToHtml === 'function') { return this.markupParser.markdownToHtml(String(text || '')); } return this.escapeHtml(text) .replace(/\*\*\*([^*]+?)\*\*\*/g, '$1') .replace(/___([^_]+?)___/g, '$1') .replace(/\*\*([^*]+?)\*\*/g, '$1') .replace(/__([^_]+?)__/g, '$1') .replace(/\*([^*\s][^*]*?)\*/g, '$1') .replace(/_([^_\s][^_]*?)_/g, '$1'); } formatChoiceKey(key) { const value = String(key || '').trim().charAt(0); return /^[A-Z]$/.test(value) ? value.toLowerCase() : value; } } const choiceDisplay = new ChoiceDisplayModule(); export { choiceDisplay as ChoiceDisplay }; if (window.moduleRegistry) { window.moduleRegistry.register(choiceDisplay); } window.ChoiceDisplay = choiceDisplay;