/** * 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.currentTurnId = 0; this.autoTurnCounter = 0; this.lastAutoTurn = new Map(); this.template = { cells: { default: { label: '', match: () => true } }, fallbackCell: 'default' }; this.bindMethods([ 'initialize', 'setupContainer', 'handleChoices', 'handleInputMode', 'handleProcessState', 'handleKeyDown', 'render', 'clear', 'normalizeChoices', 'orderChoicesForPresentation', 'shuffleChoices', 'randomInt', 'assignLetters', 'selectAutoChoice', 'isAutoChoiceReady', 'getAutoChoiceConfig', 'getAutoChoiceKey', 'getAutoChoiceDelay', 'markAutoChoiceTriggered', 'selectChoice', 'getTagValue', 'getTag', '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', (event) => { const turnId = Number(event.detail?.turnId); if (turnId === 1) { this.currentTurnId = 0; this.autoTurnCounter = 0; this.lastAutoTurn.clear(); } this.processState = 'waiting-generating'; this.render(); }); this.addEventListener(document, 'story:turn-complete', (event) => { const turnId = Number(event.detail?.turnId); if (Number.isInteger(turnId) && turnId !== this.currentTurnId) { this.currentTurnId = turnId; this.autoTurnCounter += 1; } }); 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.auto && item.letter === letter); if (!choice) { return; } event.preventDefault(); this.selectChoice(choice.index); } normalizeChoices(choices) { const normalized = 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, sourceOrder: order, optional: this.hasTag(tags, 'optional'), letter: '', templateCell: this.getTemplateCell({ ...choice, tags, category }), auto: this.hasTag(tags, 'auto') }; }); const autoChoices = normalized .filter((choice) => choice.auto) .sort((a, b) => a.sourceOrder - b.sourceOrder); const visibleChoices = normalized.filter((choice) => !choice.auto); return [ ...autoChoices, ...this.assignLetters(this.orderChoicesForPresentation(visibleChoices)) ]; } orderChoicesForPresentation(choices) { const groupOrder = []; const grouped = new Map(); const ungrouped = []; choices.forEach((choice) => { const group = String(choice.category || '').trim(); if (!group) { ungrouped.push(choice); return; } if (!grouped.has(group)) { grouped.set(group, []); groupOrder.push(group); } grouped.get(group).push(choice); }); const ordered = []; groupOrder.forEach((group) => { ordered.push(...this.shuffleChoices(grouped.get(group) || [])); }); if (ungrouped.length > 0) { ordered.push(...this.shuffleChoices(ungrouped)); } return ordered; } shuffleChoices(choices) { const shuffled = choices.slice(); for (let index = shuffled.length - 1; index > 0; index -= 1) { const swapIndex = this.randomInt(index + 1); [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]; } return shuffled; } randomInt(exclusiveMax) { const max = Math.max(1, Number(exclusiveMax) || 1); if (window.crypto && typeof window.crypto.getRandomValues === 'function') { const values = new Uint32Array(1); window.crypto.getRandomValues(values); return values[0] % max; } return Math.floor(Math.random() * max); } 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 tag = this.getTag(tags, key); return tag?.value; } getTag(tags, key) { const normalizedKey = String(key).toLowerCase(); return Array.isArray(tags) ? tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey) : undefined; } 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'; if (readyForChoices && this.selectAutoChoice()) { return; } const visibleChoices = this.choices.filter((choice) => !choice.auto); const hasVisibleChoices = visibleChoices.length > 0; this.container.hidden = !readyForChoices || !hasVisibleChoices; this.container.dataset.choiceReady = readyForChoices && hasVisibleChoices ? 'true' : 'false'; if (this.container.hidden) { return; } const list = document.createElement('ol'); list.className = 'choice-list choice-template-default'; visibleChoices.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); } selectAutoChoice() { const autoChoice = this.choices.find((choice) => choice.auto && this.isAutoChoiceReady(choice)); if (!autoChoice) { return false; } this.markAutoChoiceTriggered(autoChoice); this.selectChoice(autoChoice.index); return true; } isAutoChoiceReady(choice) { const config = this.getAutoChoiceConfig(choice); if (!config) { return false; } if (config.delay <= 0) { return true; } const lastTurn = this.lastAutoTurn.get(config.key); if (!Number.isFinite(lastTurn)) { return true; } return (this.autoTurnCounter - lastTurn) >= config.delay; } getAutoChoiceConfig(choice) { const autoTag = this.getTag(choice.tags, 'auto'); if (!autoTag) { return null; } return { key: this.getAutoChoiceKey(autoTag), delay: this.getAutoChoiceDelay(autoTag) }; } getAutoChoiceKey(autoTag) { const value = String(autoTag?.value || '').trim(); return value || '__global'; } getAutoChoiceDelay(autoTag) { const candidates = [autoTag?.param, autoTag?.value]; for (const candidate of candidates) { const text = String(candidate || '').trim(); if (!text) continue; const namedMatch = text.match(/(?:turns?|delay|cooldown)\s*=\s*(\d+)/i); const plainMatch = text.match(/^\d+$/); const value = namedMatch ? Number(namedMatch[1]) : plainMatch ? Number(text) : NaN; if (Number.isFinite(value)) { return Math.max(0, Math.floor(value)); } } return 0; } markAutoChoiceTriggered(choice) { const config = this.getAutoChoiceConfig(choice); if (!config) { return; } this.lastAutoTurn.set(config.key, this.autoTurnCounter); } 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;