521 lines
18 KiB
JavaScript
521 lines
18 KiB
JavaScript
/**
|
|
* 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', 'layout-renderer'];
|
|
this.socketClient = null;
|
|
this.markupParser = null;
|
|
this.layoutRenderer = null;
|
|
this.container = null;
|
|
this.choices = [];
|
|
this.currentGlossaryEntries = [];
|
|
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',
|
|
'applyChoiceGlossary'
|
|
]);
|
|
}
|
|
|
|
async initialize() {
|
|
this.socketClient = this.getModule('socket-client');
|
|
this.markupParser = this.getModule('markup-parser');
|
|
this.layoutRenderer = this.getModule('layout-renderer');
|
|
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) {
|
|
const detail = Array.isArray(choices)
|
|
? { choices, glossaryEntries: [] }
|
|
: {
|
|
choices: Array.isArray(choices?.choices) ? choices.choices : [],
|
|
glossaryEntries: Array.isArray(choices?.glossaryEntries) ? choices.glossaryEntries : []
|
|
};
|
|
this.currentGlossaryEntries = detail.glossaryEntries;
|
|
this.choices = this.normalizeChoices(detail.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 = `<kbd>${this.escapeHtml(displayKey)}</kbd><span>${choice.optional ? `<em>${renderedText}</em>` : renderedText}</span>`;
|
|
this.applyChoiceGlossary(button.querySelector('span'), choice);
|
|
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, '>')
|
|
.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, '<strong><em>$1</em></strong>')
|
|
.replace(/___([^_]+?)___/g, '<strong><em>$1</em></strong>')
|
|
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/__([^_]+?)__/g, '<strong>$1</strong>')
|
|
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
|
|
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
|
|
}
|
|
|
|
applyChoiceGlossary(label, choice = null) {
|
|
if (!label) return;
|
|
if (!this.layoutRenderer) {
|
|
this.layoutRenderer = this.getModule('layout-renderer');
|
|
}
|
|
if (!this.markupParser) {
|
|
this.markupParser = this.getModule('markup-parser');
|
|
}
|
|
const choiceEntries = this.markupParser && typeof this.markupParser.extractGlossaryTags === 'function'
|
|
? this.markupParser.extractGlossaryTags(choice?.tags || [])
|
|
: [];
|
|
const entries = [
|
|
...(Array.isArray(this.currentGlossaryEntries) ? this.currentGlossaryEntries : []),
|
|
...choiceEntries
|
|
];
|
|
if (entries.length === 0) return;
|
|
if (this.layoutRenderer && typeof this.layoutRenderer.applyGlossaryEntriesToInline === 'function') {
|
|
this.layoutRenderer.applyGlossaryEntriesToInline(label, entries);
|
|
}
|
|
}
|
|
|
|
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;
|