/** * Encounter UI Module * Manages the combat encounter modal window and interactions */ import { getContext } from '../../../../../../extensions.js'; import { generateRaw, chat, saveChatDebounced, characters, this_chid, user_avatar } from '../../../../../../../script.js'; import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js'; import { executeSlashCommandsOnChatInput } from '../../../../../../../scripts/slash-commands.js'; import { extensionSettings } from '../../core/state.js'; import { saveSettings } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { currentEncounter, updateCurrentEncounter, resetEncounter, addCombatMessage, addEncounterLogEntry, saveEncounterLog } from '../features/encounterState.js'; import { buildEncounterInitPrompt, buildCombatActionPrompt, buildCombatSummaryPrompt, parseEncounterJSON } from '../generation/encounterPrompts.js'; /** * EncounterModal class * Manages the combat encounter UI */ export class EncounterModal { constructor() { this.modal = null; this.isInitializing = false; this.isProcessing = false; this.lastRequest = null; // Store last request for regeneration } /** * Opens the encounter modal and initializes combat */ async open() { if (this.isInitializing) return; // Always show configuration modal (it will pre-populate with saved values if they exist) const configured = await this.showNarrativeConfigModal(); if (!configured) { // User cancelled return; } // Proceed with encounter initialization await this.initialize(); } /** * Initializes the encounter */ async initialize() { if (this.isInitializing) return; this.isInitializing = true; try { // Create modal if it doesn't exist if (!this.modal) { this.createModal(); } // Show loading state this.showLoadingState('Initializing combat encounter...'); // Open the modal this.modal.classList.add('is-open'); // Generate initial combat state const initPrompt = await buildEncounterInitPrompt(); // Store request for potential regeneration this.lastRequest = { type: 'init', prompt: initPrompt }; const response = await generateRaw({ prompt: initPrompt, quietToLoud: false }); if (!response) { this.showErrorWithRegenerate('No response received from AI. The model may be unavailable.'); return; } // Parse the combat stats const combatData = parseEncounterJSON(response); if (!combatData || !combatData.party || !combatData.enemies) { this.showErrorWithRegenerate('Invalid JSON format detected. The AI returned malformed data. Ensure the Max Response Length is set to at least 2048 tokens, otherwise the model might run out of tokens and produce unfinished structures.'); return; } // Update encounter state updateCurrentEncounter({ active: true, initialized: true, combatStats: combatData }); // Add to combat history addCombatMessage('system', 'Combat initialized'); addCombatMessage('assistant', JSON.stringify(combatData)); // Apply visual styling from styleNotes if (combatData.styleNotes) { this.applyEnvironmentStyling(combatData.styleNotes); } // Render the combat UI this.renderCombatUI(combatData); } catch (error) { console.error('[RPG Companion] Error initializing encounter:', error); this.showErrorWithRegenerate(`Failed to initialize combat: ${error.message}`); } finally { this.isInitializing = false; } } /** * Shows narrative configuration modal before starting encounter * @returns {Promise} True if configured, false if cancelled */ async showNarrativeConfigModal() { return new Promise((resolve) => { // Get current values or defaults const combatDefaults = extensionSettings.encounterSettings?.combatNarrative || {}; const summaryDefaults = extensionSettings.encounterSettings?.summaryNarrative || {}; const configHTML = `

Configure Combat Narrative

`; document.body.insertAdjacentHTML('beforeend', configHTML); const configModal = document.getElementById('rpg-narrative-config-modal'); // Show modal setTimeout(() => configModal.classList.add('is-open'), 10); // Handle proceed configModal.querySelector('#config-proceed').addEventListener('click', () => { // Get values const combatNarrative = { tense: configModal.querySelector('#config-combat-tense').value, person: configModal.querySelector('#config-combat-person').value, narration: configModal.querySelector('#config-combat-narration').value, pov: configModal.querySelector('#config-combat-pov').value.trim() || 'narrator' }; const summaryNarrative = { tense: configModal.querySelector('#config-summary-tense').value, person: configModal.querySelector('#config-summary-person').value, narration: configModal.querySelector('#config-summary-narration').value, pov: configModal.querySelector('#config-summary-pov').value.trim() || 'narrator' }; const remember = configModal.querySelector('#config-remember').checked; // Save to settings if (!extensionSettings.encounterSettings) { extensionSettings.encounterSettings = {}; } extensionSettings.encounterSettings.combatNarrative = combatNarrative; extensionSettings.encounterSettings.summaryNarrative = summaryNarrative; // Set narrativeConfigured based on checkbox state extensionSettings.encounterSettings.narrativeConfigured = remember; // Save settings saveSettings(); // Clean up configModal.remove(); resolve(true); }); // Handle cancel configModal.querySelector('#config-cancel').addEventListener('click', () => { configModal.remove(); resolve(false); }); // Handle overlay click configModal.querySelector('.rpg-encounter-overlay').addEventListener('click', () => { configModal.remove(); resolve(false); }); }); } /** * Creates the modal DOM structure */ createModal() { const modalHTML = `

Combat Encounter

Initializing combat...

`; document.body.insertAdjacentHTML('beforeend', modalHTML); this.modal = document.getElementById('rpg-encounter-modal'); // Add event listeners this.modal.querySelector('#rpg-encounter-conclude').addEventListener('click', () => { if (confirm('Conclude this encounter early and generate a summary?')) { this.concludeEncounter(); } }); this.modal.querySelector('#rpg-encounter-close').addEventListener('click', () => { if (confirm('Are you sure you want to end this combat encounter?')) { this.close(); } }); // Close on overlay click this.modal.querySelector('.rpg-encounter-overlay').addEventListener('click', () => { if (confirm('Are you sure you want to end this combat encounter?')) { this.close(); } }); } /** * Renders the combat UI with party, enemies, and controls * @param {object} combatData - Combat data including party and enemies */ renderCombatUI(combatData) { const mainContent = this.modal.querySelector('#rpg-encounter-main'); const loadingContent = this.modal.querySelector('#rpg-encounter-loading'); loadingContent.style.display = 'none'; mainContent.style.display = 'block'; const context = getContext(); const userName = context.name1; let html = `

${combatData.environment || 'Battle Arena'}

Enemies

${this.renderEnemies(combatData.enemies)}

Party

${this.renderParty(combatData.party)}

Combat Log

Combat begins!
${this.renderPlayerControls(combatData.party)}
`; mainContent.innerHTML = html; // Add event listeners for controls this.attachControlListeners(combatData.party); } /** * Renders enemy cards * @param {Array} enemies - Array of enemy data * @returns {string} HTML for enemies */ renderEnemies(enemies) { return enemies.map((enemy, index) => { const hpPercent = (enemy.hp / enemy.maxHp) * 100; const isDead = enemy.hp <= 0; // Try to find avatar for enemy (they might be a character from the chat or Present Characters) const avatarUrl = this.getCharacterAvatar(enemy.name); const sprite = enemy.sprite || '👹'; // Fallback SVG if no avatar found const fallbackSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2NjY2NjYyIgb3BhY2l0eT0iMC4zIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjNjY2IiBmb250LXNpemU9IjQwIj4/PC90ZXh0Pjwvc3ZnPg=='; return `
${avatarUrl ? `${enemy.name}` : sprite}

${enemy.name}

${enemy.hp}/${enemy.maxHp} HP
${enemy.statuses && enemy.statuses.length > 0 ? `
${enemy.statuses.map(status => `${status.emoji}`).join('')}
` : ''} ${enemy.description ? `

${enemy.description}

` : ''}
`; }).join(''); } /** * Renders party member cards * @param {Array} party - Array of party member data * @returns {string} HTML for party */ renderParty(party) { const context = getContext(); return party.map((member, index) => { const hpPercent = (member.hp / member.maxHp) * 100; const isDead = member.hp <= 0; // Get avatar for party member let avatarUrl = ''; if (member.isPlayer) { // Get user/persona avatar using user_avatar like userStats does if (user_avatar) { avatarUrl = getSafeThumbnailUrl('persona', user_avatar); } } else { // Try to find character avatar by name avatarUrl = this.getCharacterAvatar(member.name); } // Fallback SVG if no avatar found const fallbackSvg = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2NjY2NjYyIgb3BhY2l0eT0iMC4zIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjNjY2IiBmb250LXNpemU9IjQwIj4/PC90ZXh0Pjwvc3ZnPg=='; return `
${member.name}

${member.name} ${member.isPlayer ? '(You)' : ''}

${member.hp}/${member.maxHp} HP
${member.statuses && member.statuses.length > 0 ? `
${member.statuses.map(status => `${status.emoji}`).join('')}
` : ''}
`; }).join(''); } /** * Gets avatar for a character by name (works for party members, enemies, and NPCs) * @param {string} name - Character name * @returns {string} Avatar URL or null */ getCharacterAvatar(name) { // Priority 1: Check custom uploaded avatars first (from Present Characters panel) if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) { return extensionSettings.npcAvatars[name]; } // Priority 2: Check if character is in the current group if (selected_group) { const groupMembers = getGroupMembers(selected_group); if (groupMembers && groupMembers.length > 0) { const matchingMember = groupMembers.find(member => member && member.name && member.name.toLowerCase() === name.toLowerCase() ); if (matchingMember && matchingMember.avatar) { return getSafeThumbnailUrl('avatar', matchingMember.avatar); } } } // Priority 3: Search all loaded characters if (characters && Array.isArray(characters)) { const matchingChar = characters.find(char => char && char.name && char.name.toLowerCase() === name.toLowerCase() ); if (matchingChar && matchingChar.avatar) { return getSafeThumbnailUrl('avatar', matchingChar.avatar); } } // Priority 4: Check if it's the current character if (this_chid !== undefined && characters && characters[this_chid]) { const currentChar = characters[this_chid]; if (currentChar.name && currentChar.name.toLowerCase() === name.toLowerCase()) { return getSafeThumbnailUrl('avatar', currentChar.avatar); } } // No avatar found return null; } /** * Shows target selection modal for attacks * @param {string} attackType - Type of attack (single-target, AoE, both) * @param {Object} combatStats - Current combat state * @returns {Promise} Selected target name or null if cancelled */ async showTargetSelection(attackType, combatStats) { return new Promise((resolve) => { const targetModal = document.createElement('div'); targetModal.className = 'rpg-target-selection-overlay'; let targetOptions = ''; // Build target options based on attack type if (attackType === 'AoE') { targetOptions = `
💥
All Enemies
Area of Effect
`; } else if (attackType === 'both') { targetOptions = `
💥
All Enemies
Area of Effect
OR
`; } // Add individual targets (enemies and allies) if (attackType !== 'AoE') { // Add enemies combatStats.enemies.forEach((enemy, index) => { if (enemy.hp > 0) { targetOptions += `
${enemy.sprite || '👹'}
${enemy.name}
${enemy.hp}/${enemy.maxHp} HP
`; } }); // Add party members (for heals/buffs) combatStats.party.forEach((member, index) => { if (member.hp > 0) { const isPlayer = member.isPlayer ? ' (You)' : ''; // Get avatar for party member let avatarIcon = '✨'; if (member.isPlayer && user_avatar) { avatarIcon = `${member.name}`; } else { const avatarUrl = this.getPartyMemberAvatar(member.name); if (avatarUrl) { avatarIcon = `${member.name}`; } } targetOptions += `
${avatarIcon}
${member.name}${isPlayer}
${member.hp}/${member.maxHp} HP
`; } }); } targetModal.innerHTML = `

Select Target

${targetOptions}
`; document.body.appendChild(targetModal); // Handle target selection targetModal.querySelectorAll('.rpg-target-option').forEach(option => { option.addEventListener('click', () => { const target = option.dataset.target; document.body.removeChild(targetModal); resolve(target); }); }); // Handle cancel targetModal.querySelector('.rpg-target-cancel').addEventListener('click', () => { document.body.removeChild(targetModal); resolve(null); }); // Handle overlay click targetModal.addEventListener('click', (e) => { if (e.target === targetModal) { document.body.removeChild(targetModal); resolve(null); } }); }); } /** * Renders player action controls * @param {Array} party - Party data * @returns {string} HTML for controls */ renderPlayerControls(party) { const player = party.find(m => m.isPlayer); if (!player || player.hp <= 0) { return '

You have been defeated...

'; } return `

Your Actions

Attacks

${player.attacks.map(attack => { // Support both old string format and new object format const attackName = typeof attack === 'string' ? attack : attack.name; const attackType = typeof attack === 'string' ? 'single-target' : (attack.type || 'single-target'); const typeIcon = attackType === 'AoE' ? '💥' : attackType === 'both' ? '⚡' : '🎯'; return ` `; }).join('')}
${player.items && player.items.length > 0 ? `

Items

${player.items.map(item => ` `).join('')}
` : ''}

Custom Action

`; } /** * Attaches event listeners to control buttons * @param {Array} party - Party data for reference */ attachControlListeners(party) { // Attack and item buttons this.modal.querySelectorAll('.rpg-encounter-action-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const actionType = e.currentTarget.dataset.action; const value = e.currentTarget.dataset.value; const attackType = e.currentTarget.dataset.attackType; const context = getContext(); const userName = context.name1; let actionText = ''; if (actionType === 'attack') { // Show target selection for attacks const target = await this.showTargetSelection(attackType, currentEncounter.combatStats); if (!target) return; // User cancelled if (target === 'all-enemies') { actionText = `${userName} uses ${value} targeting all enemies!`; } else { actionText = `${userName} uses ${value} on ${target}!`; } } else if (actionType === 'item') { // Show target selection for items (default to single-target) const target = await this.showTargetSelection('single-target', currentEncounter.combatStats); if (!target) return; // User cancelled actionText = `${userName} uses ${value} on ${target}!`; } await this.processCombatAction(actionText); }); }); // Custom action submit const customInput = this.modal.querySelector('#rpg-encounter-custom-input'); const customSubmit = this.modal.querySelector('#rpg-encounter-custom-submit'); const submitCustomAction = async () => { const action = customInput.value.trim(); if (!action) return; await this.processCombatAction(action); customInput.value = ''; }; if (customSubmit) { customSubmit.addEventListener('click', submitCustomAction); } if (customInput) { customInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { submitCustomAction(); } }); } } /** * Processes a combat action * @param {string} action - The action description */ async processCombatAction(action) { if (this.isProcessing) return; this.isProcessing = true; try { // Disable all buttons this.modal.querySelectorAll('.rpg-encounter-action-btn, #rpg-encounter-custom-submit').forEach(btn => { btn.disabled = true; }); // Add action to log this.addToLog(`You: ${action}`, 'player-action'); // Build and send combat action prompt const actionPrompt = await buildCombatActionPrompt(action, currentEncounter.combatStats); // Store request for potential regeneration this.lastRequest = { type: 'action', action, prompt: actionPrompt }; const response = await generateRaw({ prompt: actionPrompt, quietToLoud: false }); if (!response) { this.showErrorWithRegenerate('No response received from AI. The model may be unavailable.'); return; } // Parse response const result = parseEncounterJSON(response); if (!result || !result.combatStats) { this.showErrorWithRegenerate('Invalid JSON format detected. The AI returned malformed data. Ensure the Max Response Length is set to at least 2048 tokens, otherwise the model might run out of tokens and produce unfinished structures.'); return; } // Update encounter state updateCurrentEncounter({ combatStats: result.combatStats }); // Collect log entries in order: enemy actions, party actions, then narration const logEntries = []; // Add enemy actions first if (result.enemyActions) { result.enemyActions.forEach(enemyAction => { logEntries.push({ message: `${enemyAction.enemyName}: ${enemyAction.action}`, type: 'enemy-action' }); }); } // Add party actions second if (result.partyActions) { result.partyActions.forEach(partyAction => { logEntries.push({ message: `${partyAction.memberName}: ${partyAction.action}`, type: 'party-action' }); }); } // Add narrative last - split by newlines for line-by-line display if (result.narrative) { const narrativeLines = result.narrative.split('\n').filter(line => line.trim()); narrativeLines.forEach(line => { logEntries.push({ message: line, type: 'narrative' }); }); } // Display log entries sequentially with animation await this.addLogsSequentially(logEntries); // Add to encounter log for summary - include all actions let fullActionLog = action; if (result.enemyActions && result.enemyActions.length > 0) { result.enemyActions.forEach(enemyAction => { fullActionLog += `\n${enemyAction.enemyName}: ${enemyAction.action}`; }); } if (result.partyActions && result.partyActions.length > 0) { result.partyActions.forEach(partyAction => { fullActionLog += `\n${partyAction.memberName}: ${partyAction.action}`; }); } addEncounterLogEntry(fullActionLog, result.narrative || 'Action resolved'); // Update UI this.updateCombatUI(result.combatStats); // Check if combat ended if (result.combatEnd) { await this.endCombat(result.result || 'unknown'); return; } // Re-enable buttons this.modal.querySelectorAll('.rpg-encounter-action-btn, #rpg-encounter-custom-submit').forEach(btn => { btn.disabled = false; }); } catch (error) { console.error('[RPG Companion] Error processing combat action:', error); this.showErrorWithRegenerate(`Error processing action: ${error.message}`); // Re-enable buttons this.modal.querySelectorAll('.rpg-encounter-action-btn, #rpg-encounter-custom-submit').forEach(btn => { btn.disabled = false; }); } finally { this.isProcessing = false; } } /** * Updates the combat UI with new stats * @param {object} combatStats - Updated combat statistics */ updateCombatUI(combatStats) { // Update enemies combatStats.enemies.forEach((enemy, index) => { const card = this.modal.querySelector(`[data-enemy-index="${index}"]`); if (card) { const hpPercent = (enemy.hp / enemy.maxHp) * 100; const isDead = enemy.hp <= 0; if (isDead) { card.classList.add('rpg-encounter-dead'); } const hpBar = card.querySelector('.rpg-encounter-hp-fill'); const hpText = card.querySelector('.rpg-encounter-hp-text'); if (hpBar) hpBar.style.width = `${hpPercent}%`; if (hpText) hpText.textContent = `${enemy.hp}/${enemy.maxHp} HP`; } }); // Update party combatStats.party.forEach((member, index) => { const card = this.modal.querySelector(`[data-party-index="${index}"]`); if (card) { const hpPercent = (member.hp / member.maxHp) * 100; const isDead = member.hp <= 0; if (isDead) { card.classList.add('rpg-encounter-dead'); } const hpBar = card.querySelector('.rpg-encounter-hp-fill'); const hpText = card.querySelector('.rpg-encounter-hp-text'); if (hpBar) hpBar.style.width = `${hpPercent}%`; if (hpText) hpText.textContent = `${member.hp}/${member.maxHp} HP`; } }); // Re-render controls if player died const player = combatStats.party.find(m => m.isPlayer); if (player && player.hp <= 0) { const controlsContainer = this.modal.querySelector('.rpg-encounter-controls'); if (controlsContainer) { controlsContainer.innerHTML = '

You have been defeated...

'; } } } /** * Adds multiple log entries sequentially with animation * @param {Array} entries - Array of {message, type} objects * @param {number} delay - Delay between entries in ms */ async addLogsSequentially(entries, delay = 400) { for (const entry of entries) { this.addToLog(entry.message, entry.type); if (entries.indexOf(entry) < entries.length - 1) { await new Promise(resolve => setTimeout(resolve, delay)); } } } /** * Adds an entry to the combat log * @param {string} message - Log message * @param {string} type - Log entry type (for styling) */ addToLog(message, type = '') { const logContainer = this.modal.querySelector('#rpg-encounter-log'); if (!logContainer) return; const entry = document.createElement('div'); entry.className = `rpg-encounter-log-entry ${type}`; entry.style.whiteSpace = 'pre-wrap'; entry.textContent = message; logContainer.appendChild(entry); logContainer.scrollTop = logContainer.scrollHeight; } /** * Concludes the encounter early (user-initiated) */ async concludeEncounter() { if (!currentEncounter.active) { console.warn('[RPG Companion] No active encounter to conclude'); return; } // End combat with "interrupted" result await this.endCombat('interrupted'); } /** * Ends the combat and generates summary * @param {string} result - Combat result ('victory', 'defeat', 'fled', 'interrupted') */ async endCombat(result) { try { // Show combat over screen this.showCombatOverScreen(result); // Generate summary const summaryPrompt = await buildCombatSummaryPrompt(currentEncounter.encounterLog, result); const summaryResponse = await generateRaw({ prompt: summaryPrompt, quietToLoud: false }); if (summaryResponse) { // Extract summary (remove [FIGHT CONCLUDED] tag) const summary = summaryResponse.replace(/\[FIGHT CONCLUDED\]\s*/i, '').trim(); // Determine which character should speak the summary const speakerName = this.getCombatNarrator(); // Use /sendas command to safely add summary to chat // This handles group chats properly and won't delete chat history try { await executeSlashCommandsOnChatInput( `/sendas name="${speakerName}" ${summary}`, { clearChatInput: false } ); // console.log(`[RPG Companion] Added combat summary to chat as "${speakerName}"`); // Update combat over screen this.updateCombatOverScreen(true, speakerName); } catch (sendError) { console.error('[RPG Companion] Error using /sendas command:', sendError); // Fallback: try appending to last message if (chat && chat.length > 0) { const lastMessage = chat[chat.length - 1]; if (lastMessage) { lastMessage.mes += '\n\n' + summary; saveChatDebounced(); } } this.updateCombatOverScreen(true, 'chat'); } // Save encounter log const context = getContext(); if (context.chatId) { saveEncounterLog(context.chatId, { log: currentEncounter.encounterLog, summary: summary, result: result }); } } else { this.updateCombatOverScreen(false); } } catch (error) { console.error('[RPG Companion] Error ending combat:', error); this.updateCombatOverScreen(false); } } /** * Determines which character should narrate the combat summary * Priority: Narrator character > First active group member > Current character * @returns {string} Character name to use for /sendas */ getCombatNarrator() { // Check if in group chat if (selected_group) { const group = groups.find(g => g.id === selected_group); const groupMembers = getGroupMembers(selected_group); if (groupMembers && groupMembers.length > 0) { const disabledMembers = group?.disabled_members || []; // First priority: Look for a character named "Narrator" or "GM" const narrator = groupMembers.find(member => member && member.name && !disabledMembers.includes(member.avatar) && (member.name.toLowerCase() === 'narrator' || member.name.toLowerCase() === 'gm' || member.name.toLowerCase() === 'game master') ); if (narrator) { return narrator.name; } // Second priority: First active (non-muted) group member const firstActive = groupMembers.find(member => member && member.name && !disabledMembers.includes(member.avatar) ); if (firstActive) { return firstActive.name; } } } // Fallback: Use current character if (this_chid !== undefined && characters && characters[this_chid]) { return characters[this_chid].name; } // Last resort: Generic narrator return 'Narrator'; } /** * Shows the combat over screen * @param {string} result - Combat result ('victory', 'defeat', 'fled', 'interrupted') */ showCombatOverScreen(result) { const mainContent = this.modal.querySelector('#rpg-encounter-main'); if (!mainContent) return; const resultIcons = { victory: 'fa-trophy', defeat: 'fa-skull-crossbones', fled: 'fa-person-running', interrupted: 'fa-flag-checkered' }; const resultColors = { victory: '#4caf50', defeat: '#e94560', fled: '#ff9800', interrupted: '#888' }; const icon = resultIcons[result] || 'fa-flag-checkered'; const color = resultColors[result] || '#888'; mainContent.innerHTML = `

${result}

Generating combat summary...

Please wait...
`; } /** * Updates the combat over screen after summary is added * @param {boolean} success - Whether summary was added successfully * @param {string} speakerName - Name of character who narrated (optional) */ updateCombatOverScreen(success, speakerName = '') { const mainContent = this.modal.querySelector('#rpg-encounter-main'); if (!mainContent) return; const overScreen = mainContent.querySelector('.rpg-encounter-over'); if (!overScreen) return; if (success) { overScreen.querySelector('p').textContent = speakerName ? `Combat summary has been added to the chat by ${speakerName}.` : 'Combat summary has been added to the chat.'; overScreen.querySelector('.rpg-encounter-loading').innerHTML = ` `; // Add click handler for close button const closeBtn = overScreen.querySelector('#rpg-encounter-close-final'); if (closeBtn) { closeBtn.addEventListener('click', () => { this.close(); }); } } else { overScreen.querySelector('p').textContent = 'Error generating combat summary.'; overScreen.querySelector('.rpg-encounter-loading').innerHTML = `

Failed to create summary. You can close this window.

`; // Add click handler for close button const closeBtn = overScreen.querySelector('#rpg-encounter-close-final'); if (closeBtn) { closeBtn.addEventListener('click', () => { this.close(); }); } } } /** * Shows a loading state * @param {string} message - Loading message */ showLoadingState(message) { const loadingContent = this.modal.querySelector('#rpg-encounter-loading'); const mainContent = this.modal.querySelector('#rpg-encounter-main'); if (loadingContent) { loadingContent.querySelector('p').textContent = message; loadingContent.style.display = 'flex'; } if (mainContent) { mainContent.style.display = 'none'; } } /** * Shows an error message * @param {string} message - Error message */ showError(message) { const loadingContent = this.modal.querySelector('#rpg-encounter-loading'); if (loadingContent) { loadingContent.innerHTML = `

${message}

`; } } /** * Shows an error message with a regenerate button * @param {string} message - Error message to display */ showErrorWithRegenerate(message) { const loadingContent = this.modal.querySelector('#rpg-encounter-loading'); const combatContent = this.modal.querySelector('#rpg-encounter-content'); // Hide combat content if visible if (combatContent) { combatContent.style.display = 'none'; } // Show error in loading area if (loadingContent) { loadingContent.style.display = 'flex'; loadingContent.innerHTML = `

Wrong Format Detected

${message}

`; // Add event listeners const regenerateBtn = loadingContent.querySelector('#rpg-error-regenerate'); const closeBtn = loadingContent.querySelector('#rpg-error-close'); if (regenerateBtn) { regenerateBtn.addEventListener('click', () => this.regenerateLastRequest()); } if (closeBtn) { closeBtn.addEventListener('click', () => this.close()); } } } /** * Regenerates the last failed request */ async regenerateLastRequest() { if (!this.lastRequest) { console.warn('[RPG Companion] No request to regenerate'); return; } // console.log('[RPG Companion] Regenerating request:', this.lastRequest.type); if (this.lastRequest.type === 'init') { // Retry initialization this.isInitializing = true; await this.initialize(); } else if (this.lastRequest.type === 'action') { // Retry action this.isProcessing = true; await this.processCombatAction(this.lastRequest.action); } } /** * Apply environment-based visual styling to the modal * @param {object} styleNotes - Style information from the AI */ applyEnvironmentStyling(styleNotes) { if (!styleNotes || typeof styleNotes !== 'object') return; const { environmentType, atmosphere, timeOfDay, weather } = styleNotes; // Apply environment attribute if (environmentType) { this.modal.setAttribute('data-environment', environmentType.toLowerCase()); } // Apply atmosphere attribute if (atmosphere) { this.modal.setAttribute('data-atmosphere', atmosphere.toLowerCase()); } // Apply time attribute if (timeOfDay) { this.modal.setAttribute('data-time', timeOfDay.toLowerCase()); } // Apply weather attribute if (weather) { this.modal.setAttribute('data-weather', weather.toLowerCase()); } // console.log('[RPG Companion] Applied environment styling:', styleNotes); } /** * Closes the modal and resets encounter state */ close() { if (this.modal) { this.modal.classList.remove('is-open'); resetEncounter(); } } } // Export singleton instance export const encounterModal = new EncounterModal(); /** * Opens the encounter modal */ export function openEncounterModal() { encounterModal.open(); }