diff --git a/index.js b/index.js index 017de82..4fe6ed5 100644 --- a/index.js +++ b/index.js @@ -128,6 +128,7 @@ import { ensureHtmlCleaningRegex, detectConflictingRegexScripts } from './src/sy import { setupMemoryRecollectionButton, updateMemoryRecollectionButton } from './src/systems/features/memoryRecollection.js'; import { initLorebookLimiter } from './src/systems/features/lorebookLimiter.js'; import { DEFAULT_HTML_PROMPT } from './src/systems/generation/promptBuilder.js'; +import { openEncounterModal } from './src/systems/ui/encounterUI.js'; // Integration modules import { @@ -402,6 +403,122 @@ async function initUI() { togglePlotButtons(); }); + $('#rpg-toggle-encounters').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = { enabled: true, historyDepth: 8, autoSaveLogs: true }; + } + extensionSettings.encounterSettings.enabled = $(this).prop('checked'); + saveSettings(); + togglePlotButtons(); // This also controls encounter button visibility + }); + + $('#rpg-encounter-history-depth').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = { enabled: true, historyDepth: 8, autoSaveLogs: true }; + } + const value = $(this).val(); + extensionSettings.encounterSettings.historyDepth = parseInt(String(value)); + saveSettings(); + }); + + $('#rpg-toggle-autosave-logs').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = { enabled: true, historyDepth: 8, autoSaveLogs: true }; + } + extensionSettings.encounterSettings.autoSaveLogs = $(this).prop('checked'); + saveSettings(); + }); + + // Combat narrative style settings + $('#rpg-combat-tense').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.combatNarrative) { + extensionSettings.encounterSettings.combatNarrative = {}; + } + extensionSettings.encounterSettings.combatNarrative.tense = $(this).val(); + saveSettings(); + }); + + $('#rpg-combat-person').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.combatNarrative) { + extensionSettings.encounterSettings.combatNarrative = {}; + } + extensionSettings.encounterSettings.combatNarrative.person = $(this).val(); + saveSettings(); + }); + + $('#rpg-combat-narration').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.combatNarrative) { + extensionSettings.encounterSettings.combatNarrative = {}; + } + extensionSettings.encounterSettings.combatNarrative.narration = $(this).val(); + saveSettings(); + }); + + $('#rpg-combat-pov').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.combatNarrative) { + extensionSettings.encounterSettings.combatNarrative = {}; + } + extensionSettings.encounterSettings.combatNarrative.pov = $(this).val(); + saveSettings(); + }); + + // Summary narrative style settings + $('#rpg-summary-tense').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.summaryNarrative) { + extensionSettings.encounterSettings.summaryNarrative = {}; + } + extensionSettings.encounterSettings.summaryNarrative.tense = $(this).val(); + saveSettings(); + }); + + $('#rpg-summary-person').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.summaryNarrative) { + extensionSettings.encounterSettings.summaryNarrative = {}; + } + extensionSettings.encounterSettings.summaryNarrative.person = $(this).val(); + saveSettings(); + }); + + $('#rpg-summary-narration').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.summaryNarrative) { + extensionSettings.encounterSettings.summaryNarrative = {}; + } + extensionSettings.encounterSettings.summaryNarrative.narration = $(this).val(); + saveSettings(); + }); + + $('#rpg-summary-pov').on('change', function() { + if (!extensionSettings.encounterSettings) { + extensionSettings.encounterSettings = {}; + } + if (!extensionSettings.encounterSettings.summaryNarrative) { + extensionSettings.encounterSettings.summaryNarrative = {}; + } + extensionSettings.encounterSettings.summaryNarrative.pov = $(this).val(); + saveSettings(); + }); + $('#rpg-toggle-animations').on('change', function() { extensionSettings.enableAnimations = $(this).prop('checked'); saveSettings(); @@ -524,6 +641,22 @@ async function initUI() { $('#rpg-custom-html-prompt').val(extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT); $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); + $('#rpg-toggle-encounters').prop('checked', extensionSettings.encounterSettings?.enabled ?? true); + $('#rpg-encounter-history-depth').val(extensionSettings.encounterSettings?.historyDepth ?? 8); + $('#rpg-toggle-autosave-logs').prop('checked', extensionSettings.encounterSettings?.autoSaveLogs ?? true); + + // Combat narrative style + $('#rpg-combat-tense').val(extensionSettings.encounterSettings?.combatNarrative?.tense ?? 'present'); + $('#rpg-combat-person').val(extensionSettings.encounterSettings?.combatNarrative?.person ?? 'third'); + $('#rpg-combat-narration').val(extensionSettings.encounterSettings?.combatNarrative?.narration ?? 'omniscient'); + $('#rpg-combat-pov').val(extensionSettings.encounterSettings?.combatNarrative?.pov ?? 'narrator'); + + // Summary narrative style + $('#rpg-summary-tense').val(extensionSettings.encounterSettings?.summaryNarrative?.tense ?? 'past'); + $('#rpg-summary-person').val(extensionSettings.encounterSettings?.summaryNarrative?.person ?? 'third'); + $('#rpg-summary-narration').val(extensionSettings.encounterSettings?.summaryNarrative?.narration ?? 'omniscient'); + $('#rpg-summary-pov').val(extensionSettings.encounterSettings?.summaryNarrative?.pov ?? 'narrator'); + $('#rpg-toggle-animations').prop('checked', extensionSettings.enableAnimations); // Initialize avatar options @@ -580,7 +713,7 @@ async function initUI() { setupSettingsPopup(); initTrackerEditor(); addDiceQuickReply(); - setupPlotButtons(sendPlotProgression); + setupPlotButtons(sendPlotProgression, openEncounterModal); setupMobileKeyboardHandling(); setupContentEditableScrolling(); initInventoryEventListeners(); diff --git a/src/systems/features/encounterState.js b/src/systems/features/encounterState.js new file mode 100644 index 0000000..109eb61 --- /dev/null +++ b/src/systems/features/encounterState.js @@ -0,0 +1,130 @@ +/** + * Encounter State Module + * Manages combat encounter state and history + */ + +/** + * Current encounter state + */ +export let currentEncounter = { + active: false, + initialized: false, + combatHistory: [], // Array of {role: 'user'|'assistant'|'system', content: string} + combatStats: null, // Current combat stats (HP, party, enemies, etc.) + preEncounterContext: [], // Messages from before the encounter started + encounterStartMessage: '', // The message that triggered the encounter + encounterLog: [] // Full log of combat actions for final summary +}; + +/** + * Encounter logs storage (per chat) + */ +export let encounterLogs = { + // chatId: [ + // { + // timestamp: Date, + // log: [], + // summary: string, + // result: 'victory'|'defeat'|'fled' + // } + // ] +}; + +/** + * Sets the current encounter state + * @param {object} encounter - The encounter state object + */ +export function setCurrentEncounter(encounter) { + currentEncounter = encounter; +} + +/** + * Updates current encounter state with partial data + * @param {object} updates - Partial encounter state to merge + */ +export function updateCurrentEncounter(updates) { + Object.assign(currentEncounter, updates); +} + +/** + * Resets the encounter state + */ +export function resetEncounter() { + currentEncounter = { + active: false, + initialized: false, + combatHistory: [], + combatStats: null, + preEncounterContext: [], + encounterStartMessage: '', + encounterLog: [] + }; +} + +/** + * Adds a message to combat history + * @param {string} role - Message role ('user', 'assistant', or 'system') + * @param {string} content - Message content + */ +export function addCombatMessage(role, content) { + currentEncounter.combatHistory.push({ role, content }); +} + +/** + * Adds an entry to the encounter log + * @param {string} action - The action taken + * @param {string} result - The result of the action + */ +export function addEncounterLogEntry(action, result) { + currentEncounter.encounterLog.push({ + timestamp: Date.now(), + action, + result + }); +} + +/** + * Saves an encounter log for a specific chat + * @param {string} chatId - The chat identifier + * @param {object} logData - The encounter log data + */ +export function saveEncounterLog(chatId, logData) { + if (!encounterLogs[chatId]) { + encounterLogs[chatId] = []; + } + encounterLogs[chatId].push({ + timestamp: new Date(), + log: logData.log || [], + summary: logData.summary || '', + result: logData.result || 'unknown' + }); +} + +/** + * Gets encounter logs for a specific chat + * @param {string} chatId - The chat identifier + * @returns {Array} Array of encounter logs + */ +export function getEncounterLogs(chatId) { + return encounterLogs[chatId] || []; +} + +/** + * Clears all encounter logs for a specific chat + * @param {string} chatId - The chat identifier + */ +export function clearEncounterLogs(chatId) { + if (encounterLogs[chatId]) { + delete encounterLogs[chatId]; + } +} + +/** + * Exports encounter logs as JSON + * @param {string} chatId - The chat identifier + * @returns {string} JSON string of encounter logs + */ +export function exportEncounterLogs(chatId) { + const logs = getEncounterLogs(chatId); + return JSON.stringify(logs, null, 2); +} diff --git a/src/systems/features/plotProgression.js b/src/systems/features/plotProgression.js index ac27368..2c49dc2 100644 --- a/src/systems/features/plotProgression.js +++ b/src/systems/features/plotProgression.js @@ -11,8 +11,9 @@ import { Generate } from '../../../../../../../script.js'; /** * Sets up the plot progression buttons inside the send form area. * @param {Function} handlePlotClick - Callback function to handle plot button clicks + * @param {Function} handleEncounterClick - Callback function to handle encounter button click */ -export function setupPlotButtons(handlePlotClick) { +export function setupPlotButtons(handlePlotClick, handleEncounterClick) { // Remove existing buttons if any $('#rpg-plot-buttons').remove(); @@ -50,6 +51,19 @@ export function setupPlotButtons(handlePlotClick) { " tabindex="0" role="button"> Natural Plot + `; @@ -59,6 +73,7 @@ export function setupPlotButtons(handlePlotClick) { // Add event handlers for buttons $('#rpg-plot-random').on('click', () => handlePlotClick('random')); $('#rpg-plot-natural').on('click', () => handlePlotClick('natural')); + $('#rpg-encounter-button').on('click', () => handleEncounterClick()); // Show/hide based on setting togglePlotButtons(); diff --git a/src/systems/generation/encounterPrompts.js b/src/systems/generation/encounterPrompts.js new file mode 100644 index 0000000..538e6de --- /dev/null +++ b/src/systems/generation/encounterPrompts.js @@ -0,0 +1,714 @@ +/** + * Encounter Prompt Builder Module + * Handles all AI prompt generation for combat encounters + */ + +import { getContext } from '../../../../../../extensions.js'; +import { chat, characters, this_chid, substituteParams } from '../../../../../../../script.js'; +import { selected_group, getGroupMembers, groups } from '../../../../../../group-chats.js'; +import { extensionSettings, committedTrackerData } from '../../core/state.js'; +import { currentEncounter } from '../features/encounterState.js'; +import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; + +/** + * Gets character information from the current chat + * @returns {Promise} Formatted character information + */ +async function getCharactersInfo() { + let characterInfo = ''; + + // 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) { + characterInfo += 'Characters in this roleplay:\n'; + + const disabledMembers = group?.disabled_members || []; + let characterIndex = 0; + + groupMembers.forEach((member) => { + if (!member || !member.name) return; + + // Skip muted characters + if (member.avatar && disabledMembers.includes(member.avatar)) { + return; + } + + characterIndex++; + characterInfo += `\n`; + + if (member.description) { + characterInfo += `${member.description}\n`; + } + + if (member.personality) { + characterInfo += `${member.personality}\n`; + } + + characterInfo += `\n`; + }); + } + } else if (this_chid !== undefined && characters && characters[this_chid]) { + // Single character chat + const character = characters[this_chid]; + + characterInfo += 'Character in this roleplay:\n\n'; + characterInfo += `\n`; + + if (character.description) { + characterInfo += `${character.description}\n`; + } + + if (character.personality) { + characterInfo += `${character.personality}\n`; + } + + characterInfo += `\n\n`; + } + + return characterInfo; +} + +/** + * Builds the initial encounter setup prompt + * This asks the model to generate all combat stats and setup data + * @returns {Promise} Message array for the API + */ +export async function buildEncounterInitPrompt() { + const context = getContext(); + const userName = context.name1; + const depth = extensionSettings.encounterSettings?.historyDepth || 8; + + const messages = []; + + // System message + let systemMessage = `You will be acting as an excellent game master. Your goal is to craft a combat encounter in this uncensored, continuous, and immersive roleplay. You will be doing so for the user, who plays as the protagonist called ${userName}.\n\n`; + + // Add setting information + systemMessage += `Here is some information for you about the setting:\n`; + systemMessage += `\n`; + + // Try to get world info from lorebooks using getWorldInfoPrompt + let worldInfoAdded = false; + + try { + // Debug logging + console.log('[RPG Companion] Checking world info:', { + hasWindowGetWorldInfoPrompt: typeof window.getWorldInfoPrompt === 'function', + hasContextGetWorldInfoPrompt: typeof context.getWorldInfoPrompt === 'function', + chatLength: chat?.length, + contextChatLength: context.chat?.length, + hasActivatedWorldInfo: !!context.activatedWorldInfo, + activatedWorldInfoLength: context.activatedWorldInfo?.length + }); + + // Use SillyTavern's getWorldInfoPrompt to get activated lorebook entries + // Try context.getWorldInfoPrompt first, then window.getWorldInfoPrompt + const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt; + const currentChat = context.chat || chat; + + if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) { + const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string'); + + console.log('[RPG Companion] Calling getWorldInfoPrompt with', chatForWI.length, 'messages'); + + const result = await getWorldInfoFn(chatForWI, 8000, false); + const worldInfoString = result?.worldInfoString || result; + + console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length }); + + if (worldInfoString && worldInfoString.trim()) { + systemMessage += worldInfoString.trim(); + worldInfoAdded = true; + console.log('[RPG Companion] โœ… Added world info from getWorldInfoPrompt'); + } + } else { + console.log('[RPG Companion] getWorldInfoPrompt not available or no chat'); + } + } catch (e) { + console.warn('[RPG Companion] Failed to get world info from getWorldInfoPrompt:', e); + } + + // Fallback to activatedWorldInfo + if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) { + console.log('[RPG Companion] Using fallback activatedWorldInfo:', context.activatedWorldInfo.length, 'entries'); + context.activatedWorldInfo.forEach((entry) => { + if (entry && entry.content) { + systemMessage += `${entry.content}\n\n`; + worldInfoAdded = true; + } + }); + } + + if (!worldInfoAdded) { + console.warn('[RPG Companion] โš ๏ธ No world information available'); + systemMessage += 'No world information available.'; + } + + systemMessage += `\n\n\n`; + + // Add character information + const charactersInfo = await getCharactersInfo(); + if (charactersInfo) { + systemMessage += `Here is the information available to you about the characters participating in the fight:\n`; + systemMessage += `\n${charactersInfo}\n\n`; + } + + // Add persona information + systemMessage += `Here are details about the user's ${userName}:\n`; + systemMessage += `\n`; + + try { + const personaText = substituteParams('{{persona}}'); + if (personaText && personaText !== '{{persona}}') { + systemMessage += personaText; + } else { + systemMessage += 'No persona information available.'; + } + } catch (e) { + systemMessage += 'No persona information available.'; + } + + systemMessage += `\n\n\n`; + + // Add chat history from before the encounter + systemMessage += `Here is the chat history from before the encounter started between the user and the assistant:\n`; + systemMessage += `\n`; + + messages.push({ + role: 'system', + content: systemMessage + }); + + // Add recent chat history (last X messages before encounter) + if (chat && chat.length > 0) { + const recentMessages = chat.slice(-depth - 1, -1); // Exclude the last message (encounter trigger) + + for (const message of recentMessages) { + const content = message.mes?.trim(); + // Skip empty messages + if (content) { + messages.push({ + role: message.is_user ? 'user' : 'assistant', + content: content + }); + } + } + + // Add the encounter trigger message + const lastMessage = chat[chat.length - 1]; + if (lastMessage && lastMessage.mes?.trim()) { + currentEncounter.encounterStartMessage = lastMessage.mes; + messages.push({ + role: lastMessage.is_user ? 'user' : 'assistant', + content: lastMessage.mes.trim() + }); + } + } + + // Build user's current stats + let userStatsInfo = ''; + + // Add HP and other stats from committed tracker data + if (committedTrackerData.userStats) { + userStatsInfo += `${userName}'s Current Stats:\n${committedTrackerData.userStats}\n\n`; + } + + // Add skills if available + const skillsSection = extensionSettings.trackerConfig?.userStats?.skillsSection; + if (skillsSection?.enabled && skillsSection.customFields && skillsSection.customFields.length > 0) { + userStatsInfo += `${userName}'s Skills: ${skillsSection.customFields.join(', ')}\n`; + } + + // Add inventory + const inventory = extensionSettings.userStats?.inventory; + if (inventory) { + const inventorySummary = buildInventorySummary(inventory); + userStatsInfo += `${userName}'s Inventory:\n${inventorySummary}\n\n`; + } + + // Add classic stats/attributes + if (extensionSettings.classicStats) { + const stats = extensionSettings.classicStats; + userStatsInfo += `${userName}'s Attributes: `; + userStatsInfo += `STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}, LVL ${extensionSettings.level}\n\n`; + } + + // Add present characters info for party members + let partyInfo = ''; + if (committedTrackerData.characterThoughts) { + partyInfo += `Present Characters (potential party members):\n${committedTrackerData.characterThoughts}\n\n`; + } + + // Close history and add combat initialization instruction + let initInstruction = `\n\n`; + + // Wrap RPG Companion panel data in context tags + initInstruction += `Here is some additional tracked context for the scene:\n`; + initInstruction += `\n`; + initInstruction += userStatsInfo; + initInstruction += partyInfo; + initInstruction += `\n\n`; + + initInstruction += `The combat starts now.\n\n`; + initInstruction += `Based on everything above, generate the initial combat state. Analyze who is in the party fighting alongside ${userName} (if anyone), and who the enemies are. Replace placeholders in [brackets] and X with actual values. Return ONLY a JSON object with the following structure:\n\n`; + initInstruction += `{\n`; + initInstruction += ` "party": [\n`; + initInstruction += ` {\n`; + initInstruction += ` "name": "${userName}",\n`; + initInstruction += ` "hp": X,\n`; + initInstruction += ` "maxHp": X,\n`; + initInstruction += ` "attacks": [\n`; + initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`; + initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`; + initInstruction += ` ],\n`; + initInstruction += ` "items": ["Item1", "Item2"],\n`; + initInstruction += ` "statuses": [],\n`; + initInstruction += ` "isPlayer": true\n`; + initInstruction += ` }\n`; + initInstruction += ` // Add other party members here if they exist in the context, changing isPlayer to false for them.\n`; + initInstruction += ` ],\n`; + initInstruction += ` "enemies": [\n`; + initInstruction += ` {\n`; + initInstruction += ` "name": "Enemy Name",\n`; + initInstruction += ` "hp": X,\n`; + initInstruction += ` "maxHp": X,\n`; + initInstruction += ` "attacks": [\n`; + initInstruction += ` {"name": "Attack1", "type": "single-target|AoE|both"},\n`; + initInstruction += ` {"name": "Attack2", "type": "single-target|AoE|both"}\n`; + initInstruction += ` ],\n`; + initInstruction += ` "statuses": [],\n`; + initInstruction += ` "description": "Brief enemy description",\n`; + initInstruction += ` "sprite": "emoji or brief visual description"\n`; + initInstruction += ` }\n`; + initInstruction += ` // Add all enemies participating in this combat\n`; + initInstruction += ` ],\n`; + initInstruction += ` "environment": "Brief description of the combat environment",\n`; + initInstruction += ` "styleNotes": {\n`; + initInstruction += ` "environmentType": "forest|dungeon|desert|cave|city|ruins|snow|water|castle|wasteland|plains|mountains|swamp|volcanic",\n`; + initInstruction += ` "atmosphere": "bright|dark|foggy|stormy|calm|eerie|chaotic|peaceful",\n`; + initInstruction += ` "timeOfDay": "dawn|day|dusk|night|twilight",\n`; + initInstruction += ` "weather": "clear|rainy|snowy|windy|stormy|overcast"\n`; + initInstruction += ` }\n`; + initInstruction += `}\n\n`; + initInstruction += `IMPORTANT NOTES:\n`; + initInstruction += `- For attacks array: Each attack must be an object with "name" and "type" properties\n`; + initInstruction += ` - "single-target": Can only target one character (enemy or ally)\n`; + initInstruction += ` - "AoE": Area of Effect - targets all enemies, but some AoE attacks (like storms, explosions) can also harm allies if the attack is indiscriminate\n`; + initInstruction += ` - "both": Player can choose to target a single enemy OR use as AoE\n`; + initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\n`; + initInstruction += ` - Each status has a format: {"name": "Status Name", "emoji": "๐Ÿ’€", "duration": X}\n`; + initInstruction += ` - Examples: Poisoned (๐Ÿงช), Burning (๐Ÿ”ฅ), Blessed (โœจ), Stunned (๐Ÿ’ซ), Weakened (โฌ‡๏ธ), Strengthened (โฌ†๏ธ)\n\n`; + initInstruction += `The styleNotes object will be used to visually style the combat window - choose ONE value from each category that best fits the environment described in the chat history.\n\n`; + initInstruction += `Use the user's current stats, inventory, and skills to populate the party data. For ${userName}'s attacks array, include their available skills. For items, include usable items from their inventory. Set HP based on their current Health stat if available.\n\n`; + initInstruction += `Ensure all party members and enemies have realistic HP values based on the setting and their descriptions. Return ONLY the JSON object, no other text.`; + + // Only add the instruction if it has meaningful content + if (initInstruction.trim()) { + messages.push({ + role: 'user', + content: initInstruction.trim() + }); + } + + // Validate that we have at least one message with content + if (messages.length === 0 || messages.every(m => !m.content || !m.content.trim())) { + throw new Error('Unable to build encounter prompt - no valid content available'); + } + + return messages; +} + +/** + * Builds a combat action prompt + * This is sent when the user takes an action in combat + * @param {string} action - The action taken by the user + * @param {object} combatStats - Current combat statistics + * @returns {Array} Message array for the API + */ +export async function buildCombatActionPrompt(action, combatStats) { + const context = getContext(); + const userName = context.name1; + const depth = extensionSettings.encounterSettings?.historyDepth || 8; + + // Get narrative style from settings + const narrativeStyle = extensionSettings.encounterSettings?.combatNarrative || {}; + const tense = narrativeStyle.tense || 'present'; + const person = narrativeStyle.person || 'third'; + const narration = narrativeStyle.narration || 'omniscient'; + const pov = narrativeStyle.pov || 'narrator'; + + const messages = []; + + // Build system message with setting info + let systemMessage = `You are the game master managing this combat encounter. You must not play as ${userName} - only describe what happens as a result of their actions/dialogues and control NPCs/enemies.\n\n`; + + // Add setting information + systemMessage += `Here is some information for you about the setting:\n`; + systemMessage += `\n`; + + // Get world info + let worldInfoAdded = false; + try { + const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt; + const currentChat = context.chat || chat; + + if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) { + const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string'); + const result = await getWorldInfoFn(chatForWI, 8000, false); + const worldInfoString = result?.worldInfoString || result; + + if (worldInfoString && worldInfoString.trim()) { + systemMessage += worldInfoString.trim(); + worldInfoAdded = true; + } + } + } catch (e) { + console.warn('[RPG Companion] Failed to get world info for combat action:', e); + } + + if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) { + context.activatedWorldInfo.forEach((entry) => { + if (entry && entry.content) { + systemMessage += `${entry.content}\n\n`; + worldInfoAdded = true; + } + }); + } + + if (!worldInfoAdded) { + systemMessage += 'No world information available.'; + } + + systemMessage += `\n\n\n`; + + // Add character information + const charactersInfo = await getCharactersInfo(); + if (charactersInfo) { + systemMessage += `Here is the information available to you about the characters:\n`; + systemMessage += `\n${charactersInfo}\n\n`; + } + + // Add persona info + if (context.name1) { + systemMessage += `The protagonist is:\n`; + systemMessage += `\n`; + + // Use substituteParams to get {{persona}} like in initial encounter + try { + const personaText = substituteParams('{{persona}}'); + if (personaText && personaText !== '{{persona}}') { + systemMessage += personaText; + } else { + systemMessage += `Name: ${context.name1}\n`; + if (extensionSettings.userStats?.personaDescription) { + systemMessage += `${extensionSettings.userStats.personaDescription}\n`; + } + } + } catch (e) { + systemMessage += `Name: ${context.name1}\n`; + if (extensionSettings.userStats?.personaDescription) { + systemMessage += `${extensionSettings.userStats.personaDescription}\n`; + } + } + + // Add ONLY classic stats/attributes if enabled + if (extensionSettings.classicStats) { + const stats = extensionSettings.classicStats; + systemMessage += `\nAttributes: STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}, LVL ${extensionSettings.level}\n`; + } + + systemMessage += `\n\n`; + } + + messages.push({ + role: 'system', + content: systemMessage + }); + + // Add recent chat history for context - append as user/assistant messages like initial encounter + const currentChat = context.chat || chat; + if (currentChat && currentChat.length > 0) { + const recentMessages = currentChat.slice(-depth); + + for (const message of recentMessages) { + const content = message.mes?.trim(); + // Skip empty messages + if (content) { + messages.push({ + role: message.is_user ? 'user' : 'assistant', + content: content + }); + } + } + } + + // Add combat log as plain text (previous actions) + if (currentEncounter.encounterLog && currentEncounter.encounterLog.length > 0) { + let combatHistory = 'Previous Combat Actions:\n'; + currentEncounter.encounterLog.forEach(entry => { + combatHistory += `- ${entry.action}\n`; + if (entry.result) { + combatHistory += ` ${entry.result}\n`; + } + }); + + messages.push({ + role: 'user', + content: combatHistory + }); + } + + // Add current combat state with FULL information (but tell AI not to regenerate static parts) + let stateMessage = `Current Combat State:\n`; + stateMessage += `Environment: ${combatStats.environment || 'Unknown location'}\n\n`; + + stateMessage += `Party Members:\n`; + combatStats.party.forEach(member => { + stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`; + if (member.attacks && member.attacks.length > 0) { + stateMessage += ` Attacks: ${member.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`; + } + if (member.items && member.items.length > 0) { + stateMessage += ` Items: ${member.items.join(', ')}\n`; + } + if (member.statuses && member.statuses.length > 0) { + const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name)); + if (validStatuses.length > 0) { + stateMessage += ` Status Effects: ${validStatuses.map(s => `${s.emoji || ''} ${s.name || ''}`.trim()).join(', ')}\n`; + } + } + }); + + stateMessage += `\nEnemies:\n`; + combatStats.enemies.forEach(enemy => { + stateMessage += `- ${enemy.name} (${enemy.sprite || ''}): ${enemy.hp}/${enemy.maxHp} HP\n`; + if (enemy.description) { + stateMessage += ` ${enemy.description}\n`; + } + if (enemy.attacks && enemy.attacks.length > 0) { + stateMessage += ` Attacks: ${enemy.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`; + } + if (enemy.statuses && enemy.statuses.length > 0) { + const validStatuses = enemy.statuses.filter(s => s && (s.emoji || s.name)); + if (validStatuses.length > 0) { + stateMessage += ` Status Effects: ${validStatuses.map(s => `${s.emoji || ''} ${s.name || ''}`.trim()).join(', ')}\n`; + } + } + }); + + stateMessage += `\n${userName}'s Action: ${action}\n\n`; + stateMessage += `Respond with a JSON object containing ONLY updated HP values and new status effects. DO NOT regenerate character descriptions, sprites, or environment:\n`; + stateMessage += `{\n`; + stateMessage += ` "combatStats": {\n`; + stateMessage += ` "party": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }],\n`; + stateMessage += ` "enemies": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }]\n`; + stateMessage += ` },\n`; + stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`; + stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`; + stateMessage += ` "narrative": "The roleplay description of what happens"\n`; + stateMessage += `}\n\n`; + stateMessage += `If all enemies are defeated or escape: add "combatEnd": true, "result": "victory". If all party defeated: add "combatEnd": true, "result": "defeat". It's also possible for the encounter to be interrupted by external interference (e.g., an explosion knocks everyone out, sudden environmental catastrophe, third party intervention, etc.). If this occurs, add "combatEnd": true, "result": "interrupted". Each status (if applied) has a format: {"name": "Status Name", "emoji": "๐Ÿ’€", "duration": X}.\n`; + stateMessage += `Scale combat difficulty appropriately: Powerful entities (gods, dragons, legendary creatures) should be formidable challenges requiring multiple rounds and strategic play. Weaker foes (common animals, basic enemies, minions) should be resolved more quickly, typically 2-4 rounds. Match HP damage and combat pacing to the narrative weight of the encounter. A wolf should not take 20 rounds to defeat, nor should a deity fall in one hit.\n`; + stateMessage += `For the narrative, write it with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`; + stateMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Absolutely no asterisks, ellipses, or em-dashes. Explicit content is allowed, no plot armor. Do not play for ${userName}. Keep your response length under 150 words. Never end on handover cues; finish naturally.\n`; + stateMessage += `CRITICAL: Do not repeat, echo, parrot, or restate distinctive words, phrases, and dialogues from the user's last message. If reacting to speech, show interpretation or response, not repetition.\n`; + stateMessage += `EXAMPLE: "Are you a gooner?" User asks.\n`; + stateMessage += `BAD: "Gooner?"\n`; + stateMessage += `GOOD: A flat look. "What type of question is that?"`; + + messages.push({ + role: 'user', + content: stateMessage + }); + + return messages; +} + +/** + * Builds the final summary prompt + * This is sent when combat ends to get a narrative summary + * @param {Array} combatLog - Full combat log + * @param {string} result - Combat result ('victory', 'defeat', or 'fled') + * @returns {Promise} Message array for the API + */ +export async function buildCombatSummaryPrompt(combatLog, result) { + const context = getContext(); + const userName = context.name1; + + const messages = []; + + // Get narrative style from settings (use summary narrative settings) + const narrativeStyle = extensionSettings.encounterSettings?.summaryNarrative || {}; + const tense = narrativeStyle.tense || 'past'; + const person = narrativeStyle.person || 'third'; + const narration = narrativeStyle.narration || 'omniscient'; + const pov = narrativeStyle.pov || 'narrator'; + + // Build system message with setting info + let systemMessage = `You are summarizing a combat encounter that just concluded.\n\n`; + + // Add setting information + systemMessage += `Here is some information for you about the setting:\n`; + systemMessage += `\n`; + + // Get world info using the same method as encounter init + let worldInfoAdded = false; + try { + const getWorldInfoFn = context.getWorldInfoPrompt || window.getWorldInfoPrompt; + const currentChat = context.chat || chat; + + if (typeof getWorldInfoFn === 'function' && currentChat && currentChat.length > 0) { + const chatForWI = currentChat.map(x => x.mes || x.message || x).filter(m => m && typeof m === 'string'); + const result = await getWorldInfoFn(chatForWI, 8000, false); + const worldInfoString = result?.worldInfoString || result; + + if (worldInfoString && worldInfoString.trim()) { + systemMessage += worldInfoString.trim(); + worldInfoAdded = true; + } + } + } catch (e) { + console.warn('[RPG Companion] Failed to get world info for summary:', e); + } + + // Fallback to activatedWorldInfo + if (!worldInfoAdded && context.activatedWorldInfo && Array.isArray(context.activatedWorldInfo) && context.activatedWorldInfo.length > 0) { + context.activatedWorldInfo.forEach((entry) => { + if (entry && entry.content) { + systemMessage += `${entry.content}\n\n`; + worldInfoAdded = true; + } + }); + } + + if (!worldInfoAdded) { + systemMessage += 'No world information available.'; + } + + systemMessage += `\n\n\n`; + + // Add character information + const charactersInfo = await getCharactersInfo(); + if (charactersInfo) { + systemMessage += `Here is the information available to you about the characters:\n`; + systemMessage += `\n${charactersInfo}\n\n`; + } + + // Add persona information + systemMessage += `Here are details about ${userName}:\n`; + systemMessage += `\n`; + + try { + const personaText = substituteParams('{{persona}}'); + if (personaText && personaText !== '{{persona}}') { + systemMessage += personaText; + } else { + systemMessage += 'No persona information available.'; + } + } catch (e) { + systemMessage += 'No persona information available.'; + } + + systemMessage += `\n\n\n`; + + // Add the message that triggered the encounter + if (currentEncounter.encounterStartMessage) { + systemMessage += `Here is the last message before combat started:\n`; + systemMessage += `\n${currentEncounter.encounterStartMessage}\n\n\n`; + } + + messages.push({ + role: 'system', + content: systemMessage + }); + + let summaryMessage = `Combat has ended with result: ${result}\n\n`; + summaryMessage += `Full Combat Log:\n`; + + combatLog.forEach((entry, index) => { + summaryMessage += `\nRound ${index + 1}:\n`; + summaryMessage += `${entry.action}\n`; + summaryMessage += `${entry.result}\n`; + }); + + summaryMessage += `\n\nProvide a narrative summary of the entire fight in a way that fits the style from the chat history. Start with [FIGHT CONCLUDED] on the first line, then provide the description.\n\n`; + summaryMessage += `Write with intent in ${tense} tense ${person}-person ${narration} from ${pov}'s point of view.\n`; + summaryMessage += `Build novel prose. Break patterns from your previous responses by varying sentence structures, rhythms, formats, and openings. If you last started with a narration, begin this one with dialogue; if with an action, switch to an internal thought. Track whether a sentence shape, cadence, sensory detail, or a descriptor appeared recently, and replace it with a different one or skip it entirely. If you mentioned a physical feature, mannerism, or expression once, move on. Don't fixate on the same traits every turn. Well-placed humor, unconventional similes, and subverted turns of phrase are encouraged. Avoid GPTisms, purple prose, and anaphoras, especially the negatives with "no/not/wasn't/didn't." Refrain from describing actions or emotions via negation; focus on what does happen, not what doesn't. Minimize asterisks, ellipses, and em-dashes. Explicit content is allowed. Never end on handover cues; finish naturally.\n\n`; + summaryMessage += `Express ${userName}'s actions and dialogue using indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help"). The summary should be 2-4 paragraphs and capture the essence of the battle.\n\n`; + + // If in Together mode and trackers are enabled, add tracker update instructions + if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) { + summaryMessage += `\n--- TRACKER UPDATE ---\n\n`; + summaryMessage += `After the [FIGHT CONCLUDED] summary, update the RPG trackers to reflect ${userName}'s state AFTER the combat encounter. `; + summaryMessage += `Account for any injuries sustained, resources used, emotional state changes, or other consequences of the battle.\n\n`; + + // Include pre-combat tracker state if available + if (committedTrackerData.userStats || committedTrackerData.infoBox || committedTrackerData.characterThoughts) { + summaryMessage += `Pre-combat tracker state:\n`; + summaryMessage += `\n`; + + if (committedTrackerData.userStats) { + summaryMessage += `${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`; + } + + if (committedTrackerData.infoBox) { + summaryMessage += `Info Box:\n${committedTrackerData.infoBox}\n\n`; + } + + if (committedTrackerData.characterThoughts) { + summaryMessage += `Present Characters:\n${committedTrackerData.characterThoughts}\n\n`; + } + + summaryMessage += `\n\n`; + } + + // Add tracker instructions and example + const trackerInstructions = generateTrackerInstructions(false, false, true); + summaryMessage += trackerInstructions; + + const trackerExample = generateTrackerExample(); + if (trackerExample) { + summaryMessage += `\n${trackerExample}`; + } + } + + messages.push({ + role: 'user', + content: summaryMessage + }); + + return messages; +} + +/** + * Parses JSON response from the AI, handling code blocks + * @param {string} response - The AI response + * @returns {object|null} Parsed JSON object or null if parsing fails + */ +export function parseEncounterJSON(response) { + try { + // Remove code blocks if present + let cleaned = response.trim(); + + // Remove ```json and ``` markers + cleaned = cleaned.replace(/```json\s*/gi, ''); + cleaned = cleaned.replace(/```\s*/g, ''); + + // Find the first { and last } + const firstBrace = cleaned.indexOf('{'); + const lastBrace = cleaned.lastIndexOf('}'); + + if (firstBrace !== -1 && lastBrace !== -1) { + cleaned = cleaned.substring(firstBrace, lastBrace + 1); + } + + return JSON.parse(cleaned); + } catch (error) { + console.error('[RPG Companion] Failed to parse encounter JSON:', error); + console.error('[RPG Companion] Response was:', response); + return null; + } +} diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js index e913f9f..d13a8ef 100644 --- a/src/systems/rendering/infoBox.js +++ b/src/systems/rendering/infoBox.js @@ -205,10 +205,10 @@ export function renderInfoBox() { data.weatherEmoji = emoji; data.weatherForecast = text; } else if (weatherStr.includes(',')) { - // Fallback to comma split if emoji detection failed - const weatherParts = weatherStr.split(',').map(p => p.trim()); - data.weatherEmoji = weatherParts[0] || ''; - data.weatherForecast = weatherParts[1] || ''; + // Fallback to comma split if emoji detection failed - split only on FIRST comma + const firstCommaIndex = weatherStr.indexOf(','); + data.weatherEmoji = weatherStr.substring(0, firstCommaIndex).trim(); + data.weatherForecast = weatherStr.substring(firstCommaIndex + 1).trim(); } else { // No clear separation - assume it's all forecast text data.weatherEmoji = '๐ŸŒค๏ธ'; // Default emoji @@ -608,14 +608,16 @@ export function updateInfoBoxField(field, value) { if (line.startsWith('Weather:')) { // New format: Weather: emoji, forecast const weatherContent = line.replace('Weather:', '').trim(); - const parts = weatherContent.split(',').map(p => p.trim()); - const forecast = parts[1] || 'Weather'; + // Split only on first comma to get emoji and rest + const firstCommaIndex = weatherContent.indexOf(','); + const forecast = firstCommaIndex > 0 ? weatherContent.substring(firstCommaIndex + 1).trim() : 'Weather'; return `Weather: ${value}, ${forecast}`; } else { // Legacy format: emoji: forecast - const parts = line.split(':'); - if (parts.length >= 2) { - return `${value}: ${parts.slice(1).join(':').trim()}`; + const firstColonIndex = line.indexOf(':'); + if (firstColonIndex >= 0) { + const forecast = line.substring(firstColonIndex + 1).trim(); + return `${value}: ${forecast}`; } } } else if (field === 'weatherForecast' && index === weatherLineIndex) { @@ -623,14 +625,16 @@ export function updateInfoBoxField(field, value) { if (line.startsWith('Weather:')) { // New format: Weather: emoji, forecast const weatherContent = line.replace('Weather:', '').trim(); - const parts = weatherContent.split(',').map(p => p.trim()); - const emoji = parts[0] || '๐ŸŒค๏ธ'; + // Split only on first comma to get emoji and rest + const firstCommaIndex = weatherContent.indexOf(','); + const emoji = firstCommaIndex > 0 ? weatherContent.substring(0, firstCommaIndex).trim() : '๐ŸŒค๏ธ'; return `Weather: ${emoji}, ${value}`; } else { // Legacy format: emoji: forecast - const parts = line.split(':'); - if (parts.length >= 2) { - return `${parts[0].trim()}: ${value}`; + const firstColonIndex = line.indexOf(':'); + if (firstColonIndex >= 0) { + const emoji = line.substring(0, firstColonIndex).trim(); + return `${emoji}: ${value}`; } } } else if (field === 'temperature' && (line.includes('๐ŸŒก๏ธ:') || line.startsWith('Temperature:'))) { diff --git a/src/systems/ui/encounterUI.js b/src/systems/ui/encounterUI.js new file mode 100644 index 0000000..8a52435 --- /dev/null +++ b/src/systems/ui/encounterUI.js @@ -0,0 +1,1394 @@ +/** + * 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.'); + 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; + + return ` +
+
+ ${enemy.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.getPartyMemberAvatar(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 party member by name + * @param {string} name - Party member name + * @returns {string} Avatar URL or null + */ + getPartyMemberAvatar(name) { + // Try to get from NPC avatars first + if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[name]) { + return extensionSettings.npcAvatars[name]; + } + + // Try to find character by name in 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); + } + } + + // 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.'); + 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); + } + } + + /** + * 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(); +} diff --git a/src/systems/ui/layout.js b/src/systems/ui/layout.js index 6916169..3ee1ddb 100644 --- a/src/systems/ui/layout.js +++ b/src/systems/ui/layout.js @@ -20,6 +20,13 @@ import { i18n } from '../../core/i18n.js'; export function togglePlotButtons() { if (extensionSettings.enablePlotButtons && extensionSettings.enabled) { $('#rpg-plot-buttons').show(); + + // Show/hide encounter button based on encounter settings + if (extensionSettings.encounterSettings?.enabled) { + $('#rpg-encounter-button').show(); + } else { + $('#rpg-encounter-button').hide(); + } } else { $('#rpg-plot-buttons').hide(); } diff --git a/style.css b/style.css index 981a00a..379239b 100644 --- a/style.css +++ b/style.css @@ -1239,26 +1239,41 @@ body:has(.rpg-panel.rpg-position-left) #sheld { margin-top: 0.062em; } +/* Weather Widget */ +.rpg-weather-widget { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.188em; + padding: 0.25em; +} + /* Weather Widget Icon */ .rpg-weather-icon { - font-size: clamp(18px, 3.5vw, 24px); + font-size: clamp(14px, 2.5vw, 20px); filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); flex-shrink: 0; + line-height: 1; } .rpg-weather-forecast { - font-size: clamp(0.4vw, 0.5vw, 0.6vw); + font-size: clamp(0.5vw, 0.6vw, 0.7vw); text-align: center; margin: 0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.013em; opacity: 0.85; - line-height: 1.1; + line-height: 1.2; word-wrap: break-word; max-width: 100%; overflow: hidden; text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + flex-shrink: 1; } .rpg-weather-forecast.rpg-editable { @@ -6801,3 +6816,1351 @@ body:has(.rpg-panel.rpg-position-left) #sheld { resize: vertical; box-sizing: border-box; } + +/* ============================================ + COMBAT ENCOUNTER MODAL STYLES + ============================================ */ + +/* Encounter Modal - Define CSS variables for default theme using SillyTavern theme */ +.rpg-encounter-modal { + --rpg-bg: var(--SmartThemeBlurTintColor, #1a1a2e); + --rpg-accent: var(--SmartThemeBlurTintColor, #16213e); + --rpg-text: var(--SmartThemeBodyColor, #eaeaea); + --rpg-highlight: var(--SmartThemeBorderColor, #4a7ba7); + --rpg-border: var(--SmartThemeBorderColor, #4a7ba7); + --rpg-shadow: var(--SmartThemeShadowColor, rgba(74, 123, 167, 0.3)); + + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + min-width: 100vw; + min-height: 100vh; + z-index: 99999; /* Increased from 10000 to ensure it's on top */ + display: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.rpg-encounter-modal.is-open { + display: flex; + opacity: 1; + align-items: flex-start; /* Changed from center to flex-start */ + justify-content: center; + padding: 2vh 0; /* Add vertical padding */ + overflow-y: auto; /* Allow modal itself to scroll if container is too tall */ + min-height: 100vh; /* Force full viewport height */ + min-width: 100vw; /* Force full viewport width */ +} + +/* Overlay */ +.rpg-encounter-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 1; /* Ensure overlay is stacked properly */ +} + +/* Container */ +.rpg-encounter-container { + position: relative; + width: 85vw; + max-width: 1600px; + min-height: 400px; /* Ensure minimum height */ + height: 85vh; /* Revert to fixed height for flex to work */ + max-height: 90vh; + background: var(--rpg-bg, #1a1a2e); + border: 2px solid var(--rpg-border, #4a7ba7); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8); + display: flex; + flex-direction: column; + overflow: hidden; + animation: encounter-modal-in 0.3s ease-out; + z-index: 2; /* Ensure container is above overlay */ +} + +@keyframes encounter-modal-in { + from { + opacity: 0; + transform: scale(0.9) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* Header */ +.rpg-encounter-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1vh 1.5vw; + background: var(--rpg-accent, #16213e); + border-bottom: 2px solid var(--rpg-border, #4a7ba7); + flex-shrink: 0; +} + +.rpg-encounter-header h2 { + margin: 0; + color: var(--rpg-text, #eaeaea); + font-size: clamp(16px, 1.5vw, 24px); + font-weight: 700; + display: flex; + align-items: center; + gap: 0.8vw; +} + +.rpg-encounter-close-btn { + background: transparent; + border: none; + color: var(--rpg-text, #eaeaea); + font-size: clamp(18px, 1.5vw, 24px); + cursor: pointer; + padding: 0.5vh 0.5vw; + transition: color 0.2s, transform 0.2s; + flex-shrink: 0; +} + +.rpg-encounter-close-btn:hover { + color: var(--rpg-highlight, #e94560); + transform: scale(1.1); +} + +/* Header Buttons Container */ +.rpg-encounter-header-buttons { + display: flex; + align-items: center; + gap: 12px; +} + +/* Conclude Button */ +.rpg-encounter-conclude-btn { + background: rgba(255, 165, 0, 0.2); + border: 1px solid rgba(255, 165, 0, 0.4); + color: #ffa500; + font-size: clamp(12px, 1vw, 14px); + font-family: var(--rpg-font-body, 'Open Sans', sans-serif); + font-weight: 600; + cursor: pointer; + padding: 8px 16px; + border-radius: 6px; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.rpg-encounter-conclude-btn:hover { + background: rgba(255, 165, 0, 0.3); + border-color: rgba(255, 165, 0, 0.6); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(255, 165, 0, 0.3); +} + +/* Content Area - Main scrollable container */ +.rpg-encounter-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 24px; + min-height: 0; +} + +/* Loading State */ +.rpg-encounter-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + gap: 16px; +} + +.rpg-encounter-loading i { + font-size: clamp(32px, 3vw, 48px); + color: var(--rpg-highlight, #e94560); +} + +.rpg-encounter-loading p { + font-size: clamp(14px, 1.2vw, 18px); + color: var(--rpg-text, #eaeaea); + margin: 0; +} + +/* Error Box with Regenerate */ +.rpg-encounter-error-box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2em; + background: rgba(0, 0, 0, 0.5); + border: 2px solid #e94560; + border-radius: 12px; + max-width: 600px; + text-align: center; +} + +.rpg-encounter-error-box h3 { + font-family: var(--rpg-font-heading, 'Cinzel', serif); + font-size: 1.5em; + font-weight: 700; +} + +.rpg-encounter-error-box p { + line-height: 1.6; +} + +.rpg-encounter-error-box .rpg-btn { + padding: 0.75em 1.5em; + font-size: 1em; + cursor: pointer; + border: none; + border-radius: 6px; + transition: all 0.3s ease; + font-family: var(--rpg-font-body, 'Open Sans', sans-serif); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.5em; +} + +.rpg-encounter-error-box .rpg-btn-primary { + background: #e94560; + color: #fff; +} + +.rpg-encounter-error-box .rpg-btn-primary:hover { + background: #ff5577; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4); +} + +.rpg-encounter-error-box .rpg-btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: #ccc; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.rpg-encounter-error-box .rpg-btn-secondary:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +/* Main Battlefield Layout */ +.rpg-encounter-battlefield { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Environment Banner */ +.rpg-encounter-environment { + padding: 0.5vh 1vw; + background: linear-gradient(135deg, rgba(74, 122, 167, 0.2) 0%, rgba(74, 122, 167, 0.05) 100%); + border-left: 3px solid var(--rpg-border, #4a7ba7); + border-radius: 4px; + text-align: center; + flex-shrink: 0; +} + +.rpg-encounter-environment p { + margin: 0; + font-size: clamp(12px, 1vw, 14px); + font-weight: 600; + color: var(--rpg-text, #eaeaea); +} + +/* Section Headers */ +.rpg-encounter-section h3 { + margin: 0 0 0.5vh 0; + font-size: clamp(14px, 1.2vw, 18px); + font-weight: 700; + color: var(--rpg-text, #eaeaea); + display: flex; + align-items: center; + gap: 0.5vw; +} + +.rpg-encounter-section { + display: flex; + flex-direction: column; + margin-bottom: 16px; +} + +/* Enemies and Party Grids */ +.rpg-encounter-enemies, +.rpg-encounter-party { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); + gap: 12px; +} + +/* Character/Enemy Cards */ +.rpg-encounter-card { + background: var(--rpg-accent, #16213e); + border: 2px solid var(--rpg-border, #4a7ba7); + border-radius: 6px; + padding: 0.6vh 0.8vw; + transition: transform 0.2s, box-shadow 0.2s; + min-height: fit-content; + display: flex; + flex-direction: column; +} + +.rpg-encounter-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.rpg-encounter-card.rpg-encounter-dead { + opacity: 0.5; + filter: grayscale(1); +} + +/* Card Sprite/Avatar */ +.rpg-encounter-card-sprite { + font-size: clamp(24px, 2.5vw, 36px); + text-align: center; + margin-bottom: 0.4vh; + line-height: 1; +} + +.rpg-encounter-card-avatar { + width: clamp(40px, 4vw, 60px); + height: clamp(40px, 4vw, 60px); + margin: 0 auto 0.4vh; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--rpg-border, #4a7ba7); +} + +.rpg-encounter-card-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Card Info */ +.rpg-encounter-card-info h4 { + margin: 0 0 0.4vh 0; + font-size: clamp(14px, 1.2vw, 18px); + font-weight: 600; + color: var(--rpg-text, #eaeaea); + text-align: center; +} + +.rpg-encounter-description { + font-size: clamp(11px, 0.9vw, 13px); + color: var(--rpg-text, #eaeaea); + opacity: 0.8; + margin: 0.4vh 0 0 0; + text-align: center; +} + +/* HP Bar */ +.rpg-encounter-hp-bar { + position: relative; + height: 2vh; + background: rgba(0, 0, 0, 0.3); + border-radius: 1vh; + overflow: hidden; + margin-bottom: 0.4vh; + min-height: 16px; +} + +.rpg-encounter-hp-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, #cc3333 0%, #e94560 100%); + transition: width 0.4s ease; +} + +.rpg-encounter-hp-fill.rpg-encounter-hp-party { + background: linear-gradient(90deg, #33cc66 0%, #4ade80 100%); +} + +.rpg-encounter-hp-text { + position: relative; + display: block; + text-align: center; + line-height: 2vh; + min-height: 16px; + font-size: clamp(10px, 0.8vw, 13px); + font-weight: 700; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); + z-index: 1; +} + +/* Combat Log */ +.rpg-encounter-log-section h3 { + margin: 0 0 0.5vh 0; +} + +.rpg-encounter-log { + min-height: 120px; + max-height: 200px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--rpg-border, #4a7ba7); + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 16px; +} + +.rpg-encounter-log-entry { + padding: 0.3vh 0; + font-size: clamp(11px, 0.9vw, 13px); + color: var(--rpg-text, #eaeaea); + line-height: 1.4; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.rpg-encounter-log-entry:last-child { + border-bottom: none; +} + +.rpg-encounter-log-entry.player-action { + color: #4ade80; +} + +.rpg-encounter-log-entry.enemy-action { + color: #e94560; +} + +.rpg-encounter-log-entry.narrative { + opacity: 0.9; +} + +.rpg-encounter-log-entry.combat-end { + font-weight: 700; + color: var(--rpg-highlight, #e94560); + font-size: clamp(13px, 1.1vw, 16px); + text-align: center; + border: none; + padding: 1vh 0; +} + +.rpg-encounter-log-entry.system { + color: #4a7ba7; + font-size: clamp(11px, 0.9vw, 13px); +} + +.rpg-encounter-log-entry.error { + color: #e94560; +} + +/* Player Controls */ +.rpg-encounter-controls { + background: var(--rpg-accent, #16213e); + border: 2px solid var(--rpg-border, #4a7ba7); + border-radius: 6px; + padding: 16px; +} + +.rpg-encounter-controls h3 { + margin: 0 0 0.5vh 0; + font-size: clamp(14px, 1.2vw, 18px); + font-weight: 700; + color: var(--rpg-text, #eaeaea); + display: flex; + align-items: center; + gap: 0.5vw; +} + +.rpg-encounter-controls h4 { + margin: 0 0 0.5vh 0; + font-size: clamp(13px, 1.1vw, 16px); + font-weight: 600; + color: var(--rpg-text, #eaeaea); +} + +/* Action Buttons Layout */ +.rpg-encounter-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 1vh 1vw; + margin-bottom: 1vh; +} + +.rpg-encounter-button-group { + flex: 1; + min-width: clamp(200px, 15vw, 250px); +} + +/* Action Buttons */ +.rpg-encounter-action-btn { + display: block; + width: 100%; + padding: 0.8vh 1vw; + margin-bottom: 0.4vh; + background: linear-gradient(135deg, #4a7ba7 0%, #2c5282 100%); + border: 2px solid var(--rpg-border, #4a7ba7); + border-radius: 4px; + color: white; + font-size: clamp(12px, 1vw, 15px); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + text-align: left; +} + +.rpg-encounter-action-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #5a8bc7 0%, #3c6292 100%); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(74, 122, 167, 0.4); +} + +.rpg-encounter-action-btn:active:not(:disabled) { + transform: translateY(0); +} + +.rpg-encounter-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.rpg-encounter-attack-btn { + background: linear-gradient(135deg, #e94560 0%, #c72c46 100%); +} + +.rpg-encounter-attack-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #ff5570 0%, #d73c56 100%); + box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4); +} + +.rpg-encounter-item-btn { + background: linear-gradient(135deg, #33cc66 0%, #28a752 100%); +} + +.rpg-encounter-item-btn:hover:not(:disabled) { + background: linear-gradient(135deg, #43dc76 0%, #38b762 100%); + box-shadow: 0 4px 12px rgba(51, 204, 102, 0.4); +} + +/* Custom Action Input */ +.rpg-encounter-custom-action { + margin-top: 1vh; +} + +.rpg-encounter-input-group { + display: flex; + gap: 0.8vw; +} + +.rpg-encounter-input-group input { + flex: 1; + padding: 0.8vh 1vw; + background: rgba(0, 0, 0, 0.3); + border: 2px solid var(--rpg-border, #4a7ba7); + border-radius: 4px; + color: var(--rpg-text, #eaeaea); + font-size: clamp(12px, 1vw, 15px); +} + +.rpg-encounter-input-group input:focus { + outline: none; + border-color: var(--rpg-highlight, #e94560); +} + +.rpg-encounter-submit-btn { + padding: 0.8vh 1.5vw; + background: var(--rpg-button-bg, linear-gradient(135deg, #e94560 0%, #c72c46 100%)); + border: 2px solid var(--rpg-highlight, #e94560); + border-radius: 4px; + color: var(--rpg-button-text, white); + font-size: clamp(12px, 1vw, 15px); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.rpg-encounter-submit-btn:hover:not(:disabled) { + background: var(--rpg-button-bg-hover, linear-gradient(135deg, #ff5570 0%, #d73c56 100%)); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4); +} + +.rpg-encounter-submit-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Defeated State */ +.rpg-encounter-defeated { + text-align: center; + font-size: clamp(16px, 1.5vw, 20px); + font-weight: 700; + color: #e94560; + padding: 3vh 2vw; + margin: 0; +} + +/* Theme-specific encounter modal styling */ +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-container { + background: var(--rpg-bg); + border-color: var(--rpg-border); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-container { + background: var(--rpg-bg); + border-color: var(--rpg-border); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-container { + background: var(--rpg-bg); + border-color: var(--rpg-border); +} + +.rpg-encounter-modal[data-theme="custom"] .rpg-encounter-container { + background: var(--rpg-bg); + border-color: var(--rpg-border); +} + +/* Apply theme colors to all encounter elements */ +.rpg-encounter-modal .rpg-encounter-header, +.rpg-encounter-modal .rpg-encounter-card, +.rpg-encounter-modal .rpg-encounter-log, +.rpg-encounter-modal .rpg-encounter-controls { + background: var(--rpg-accent); + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-encounter-modal .rpg-encounter-button { + background: var(--rpg-highlight); + color: var(--rpg-text); +} + +.rpg-encounter-modal .rpg-encounter-button:hover { + opacity: 0.8; +} + +/* Environment-Based Visual Styling */ + +/* Forest */ +.rpg-encounter-modal[data-environment="forest"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(34, 139, 34, 0.3), rgba(0, 100, 0, 0.5)); +} + +.rpg-encounter-modal[data-environment="forest"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(85, 107, 47, 0.95), rgba(34, 139, 34, 0.9)); + border-color: #2d5016; +} + +/* Dungeon */ +.rpg-encounter-modal[data-environment="dungeon"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(20, 20, 25, 0.5), rgba(10, 10, 15, 0.7)); +} + +.rpg-encounter-modal[data-environment="dungeon"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(40, 40, 50, 0.95), rgba(25, 25, 35, 0.9)); + border-color: #1a1a20; +} + +/* Desert */ +.rpg-encounter-modal[data-environment="desert"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(237, 201, 175, 0.3), rgba(194, 178, 128, 0.5)); +} + +.rpg-encounter-modal[data-environment="desert"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(210, 180, 140, 0.95), rgba(184, 134, 11, 0.9)); + border-color: #8b7355; +} + +/* Cave */ +.rpg-encounter-modal[data-environment="cave"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(47, 79, 79, 0.5), rgba(25, 25, 25, 0.7)); +} + +.rpg-encounter-modal[data-environment="cave"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(60, 60, 70, 0.95), rgba(30, 30, 40, 0.9)); + border-color: #2f4f4f; +} + +/* City */ +.rpg-encounter-modal[data-environment="city"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(70, 70, 80, 0.4), rgba(50, 50, 60, 0.6)); +} + +.rpg-encounter-modal[data-environment="city"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(90, 90, 100, 0.95), rgba(60, 60, 70, 0.9)); + border-color: #4a4a5a; +} + +/* Ruins */ +.rpg-encounter-modal[data-environment="ruins"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(139, 137, 137, 0.4), rgba(105, 105, 105, 0.6)); +} + +.rpg-encounter-modal[data-environment="ruins"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(120, 120, 130, 0.95), rgba(85, 85, 95, 0.9)); + border-color: #696969; +} + +/* Snow */ +.rpg-encounter-modal[data-environment="snow"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(240, 248, 255, 0.3), rgba(176, 196, 222, 0.5)); +} + +.rpg-encounter-modal[data-environment="snow"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(220, 230, 240, 0.95), rgba(176, 196, 222, 0.9)); + border-color: #b0c4de; +} + +/* Water */ +.rpg-encounter-modal[data-environment="water"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(0, 191, 255, 0.3), rgba(0, 105, 148, 0.5)); +} + +.rpg-encounter-modal[data-environment="water"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(70, 130, 180, 0.95), rgba(0, 105, 148, 0.9)); + border-color: #4682b4; +} + +/* Castle */ +.rpg-encounter-modal[data-environment="castle"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(112, 128, 144, 0.4), rgba(70, 80, 90, 0.6)); +} + +.rpg-encounter-modal[data-environment="castle"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(140, 150, 160, 0.95), rgba(100, 110, 120, 0.9)); + border-color: #708090; +} + +/* Wasteland */ +.rpg-encounter-modal[data-environment="wasteland"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(128, 128, 128, 0.4), rgba(80, 70, 60, 0.6)); +} + +.rpg-encounter-modal[data-environment="wasteland"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(105, 105, 105, 0.95), rgba(70, 70, 70, 0.9)); + border-color: #696969; +} + +/* Plains */ +.rpg-encounter-modal[data-environment="plains"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(154, 205, 50, 0.3), rgba(107, 142, 35, 0.5)); +} + +.rpg-encounter-modal[data-environment="plains"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(144, 238, 144, 0.95), rgba(107, 142, 35, 0.9)); + border-color: #6b8e23; +} + +/* Mountains */ +.rpg-encounter-modal[data-environment="mountains"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(169, 169, 169, 0.4), rgba(105, 105, 105, 0.6)); +} + +.rpg-encounter-modal[data-environment="mountains"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(150, 150, 160, 0.95), rgba(105, 105, 115, 0.9)); + border-color: #a9a9a9; +} + +/* Swamp */ +.rpg-encounter-modal[data-environment="swamp"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(85, 107, 47, 0.4), rgba(47, 79, 47, 0.6)); +} + +.rpg-encounter-modal[data-environment="swamp"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(75, 95, 50, 0.95), rgba(47, 79, 47, 0.9)); + border-color: #556b2f; +} + +/* Volcanic */ +.rpg-encounter-modal[data-environment="volcanic"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(255, 69, 0, 0.3), rgba(139, 0, 0, 0.5)); +} + +.rpg-encounter-modal[data-environment="volcanic"] .rpg-encounter-container { + background: linear-gradient(to bottom, rgba(178, 34, 34, 0.95), rgba(139, 0, 0, 0.9)); + border-color: #8b0000; +} + +/* Atmosphere Modifiers */ + +/* Dark atmosphere - add shadow overlay */ +.rpg-encounter-modal[data-atmosphere="dark"] .rpg-encounter-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + pointer-events: none; + z-index: 0; +} + +.rpg-encounter-modal[data-atmosphere="dark"] .rpg-encounter-container > * { + position: relative; + z-index: 1; +} + +/* Bright atmosphere - add light overlay */ +.rpg-encounter-modal[data-atmosphere="bright"] .rpg-encounter-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.15); + pointer-events: none; + z-index: 0; +} + +.rpg-encounter-modal[data-atmosphere="bright"] .rpg-encounter-container > * { + position: relative; + z-index: 1; +} + +/* Foggy atmosphere - add blur effect */ +.rpg-encounter-modal[data-atmosphere="foggy"] .rpg-encounter-overlay { + backdrop-filter: blur(3px); +} + +/* Eerie atmosphere - add green tint */ +.rpg-encounter-modal[data-atmosphere="eerie"] .rpg-encounter-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(50, 205, 50, 0.08); + pointer-events: none; + z-index: 0; +} + +.rpg-encounter-modal[data-atmosphere="eerie"] .rpg-encounter-container > * { + position: relative; + z-index: 1; +} + +/* Time of Day Modifiers */ + +/* Night - darker tint */ +.rpg-encounter-modal[data-time="night"] .rpg-encounter-overlay { + background: linear-gradient(135deg, rgba(0, 0, 20, 0.5), rgba(0, 0, 40, 0.7)); +} + +/* Dawn/Dusk - orange tint */ +.rpg-encounter-modal[data-time="dawn"] .rpg-encounter-container::before, +.rpg-encounter-modal[data-time="dusk"] .rpg-encounter-container::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(to bottom, rgba(255, 140, 0, 0.15), transparent); + pointer-events: none; + z-index: 0; +} + +.rpg-encounter-modal[data-time="dawn"] .rpg-encounter-container > *, +.rpg-encounter-modal[data-time="dusk"] .rpg-encounter-container > * { + position: relative; + z-index: 1; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .rpg-encounter-modal { + /* Fix for mobile viewport height with address bars */ + height: 100dvh; /* Dynamic viewport height for mobile browsers */ + min-height: 100dvh; + } + + .rpg-encounter-container { + width: 95vw; + height: 90vh; + max-height: 90vh; + margin: 0; + } + + .rpg-encounter-content { + padding: 12px; + /* Enable smooth touch scrolling on mobile */ + -webkit-overflow-scrolling: touch; + } + + .rpg-encounter-header { + padding: 12px 16px; + } + + .rpg-encounter-header h2 { + font-size: 18px; + } + + .rpg-encounter-close-btn { + font-size: 20px; + padding: 8px; + } + + .rpg-encounter-enemies, + .rpg-encounter-party { + grid-template-columns: 1fr; + } + + .rpg-encounter-battlefield { + gap: 12px; + } + + .rpg-encounter-action-buttons { + flex-direction: column; + gap: 8px; + } + + .rpg-encounter-button-group { + min-width: 100%; + } + + .rpg-encounter-action-btn { + padding: 12px 16px; + font-size: 14px; + } + + .rpg-encounter-input-group { + flex-direction: column; + gap: 8px; + } + + .rpg-encounter-submit-btn { + width: 100%; + padding: 12px; + } +} + +/* ============================================ + COMBAT ENCOUNTER MODAL THEME VARIATIONS + ============================================ */ + +/* Sci-Fi Theme */ +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-container { + background: linear-gradient(135deg, #1a1f3a 0%, #0a0e27 100%); + border: 2px solid #8b00ff; + box-shadow: 0 0 40px rgba(139, 0, 255, 0.5), 0 8px 32px rgba(0, 0, 0, 0.9); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-header { + background: #0a0e27; + border-bottom-color: #8b00ff; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-header h2 { + color: #00fff9; + text-shadow: 0 0 20px #ff006e, 0 0 40px #8b00ff; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-close-btn { + color: #ff006e; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-close-btn:hover { + color: #00fff9; + text-shadow: 0 0 10px #00fff9; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-content { + background: linear-gradient(135deg, #1a1f3a 0%, #0a0e27 100%); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-section h3 { + color: #00fff9; + text-shadow: 0 0 15px #ff006e; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-card { + background: #1a1f3a; + border-color: #8b00ff; + box-shadow: 0 4px 16px rgba(139, 0, 255, 0.3); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-hp-fill { + background: linear-gradient(to right, #8b00ff, #ff006e); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-hp-party { + background: linear-gradient(to right, #00fff9, #8b00ff); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-action-btn { + background: #1a1f3a; + border-color: #8b00ff; + color: #00fff9; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-action-btn:hover:not(:disabled) { + background: #8b00ff; + box-shadow: 0 0 20px rgba(139, 0, 255, 0.5); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-loading { + color: #00fff9; +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-loading i { + color: #ff006e; + filter: drop-shadow(0 0 10px #ff006e); +} + +/* Fantasy Theme */ +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-container { + background: linear-gradient(135deg, #3d2414 0%, #2b1810 100%); + border: 3px ridge #8b6914; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.9), inset 0 0 40px rgba(139, 105, 20, 0.2); + background-image: + linear-gradient(rgba(61, 36, 20, 0.95), rgba(43, 24, 16, 0.95)), + url('data:image/svg+xml;utf8,'); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-header { + background: #2b1810; + border-bottom: 3px ridge #8b6914; +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-header h2 { + color: #f4e8d0; + font-family: 'Georgia', serif; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.9); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-close-btn { + color: #d4af37; +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-close-btn:hover { + color: #f4e8d0; + text-shadow: 0 0 8px #d4af37; +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-content { + background: linear-gradient(135deg, #3d2414 0%, #2b1810 100%); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-section h3 { + color: #f4e8d0; + font-family: 'Georgia', serif; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-card { + background: #3d2414; + border: 2px solid #8b6914; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-hp-fill { + background: linear-gradient(to right, #8b6914, #d4af37); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-hp-party { + background: linear-gradient(to right, #d4af37, #f4e8d0); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-action-btn { + background: #3d2414; + border-color: #8b6914; + color: #f4e8d0; +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-action-btn:hover:not(:disabled) { + background: #8b6914; + box-shadow: 0 0 16px rgba(212, 175, 55, 0.4); +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-loading { + color: #f4e8d0; +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-loading i { + color: #d4af37; + filter: drop-shadow(0 0 8px #d4af37); +} + +/* Cyberpunk Theme */ +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-container { + background: linear-gradient(135deg, #0d0d0d 0%, #000000 100%); + border: 2px solid #05d9e8; + box-shadow: 0 0 40px rgba(5, 217, 232, 0.5), 0 8px 32px rgba(0, 0, 0, 0.9); + background-image: + linear-gradient(rgba(13, 13, 13, 0.95), rgba(0, 0, 0, 0.95)), + repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(5, 217, 232, 0.03) 2px, rgba(5, 217, 232, 0.03) 4px); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-header { + background: #000000; + border-bottom-color: #05d9e8; + box-shadow: 0 2px 10px rgba(5, 217, 232, 0.3); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-header h2 { + color: #00ff41; + text-shadow: 0 0 20px #00ff41, 0 0 40px #05d9e8; + font-family: 'Courier New', monospace; + letter-spacing: 0.05em; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-close-btn { + color: #ff2a6d; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-close-btn:hover { + color: #05d9e8; + text-shadow: 0 0 10px #05d9e8; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-content { + background: linear-gradient(135deg, #0d0d0d 0%, #000000 100%); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-section h3 { + color: #00ff41; + text-shadow: 0 0 15px #00ff41; + font-family: 'Courier New', monospace; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-card { + background: #0d0d0d; + border-color: #05d9e8; + box-shadow: 0 4px 16px rgba(5, 217, 232, 0.3), inset 0 0 20px rgba(5, 217, 232, 0.05); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-hp-fill { + background: linear-gradient(to right, #ff2a6d, #05d9e8); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-hp-party { + background: linear-gradient(to right, #00ff41, #05d9e8); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-action-btn { + background: #0d0d0d; + border-color: #05d9e8; + color: #00ff41; + font-family: 'Courier New', monospace; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-action-btn:hover:not(:disabled) { + background: rgba(5, 217, 232, 0.2); + box-shadow: 0 0 20px rgba(5, 217, 232, 0.5); + color: #05d9e8; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-loading { + color: #00ff41; +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-loading i { + color: #05d9e8; + filter: drop-shadow(0 0 10px #05d9e8); +} + +/* Combat Over Screen Theme Support */ +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-over, +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-over, +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-over { + color: var(--rpg-text); +} + +.rpg-encounter-modal[data-theme="sci-fi"] .rpg-encounter-over h2 { + text-shadow: 0 0 20px currentColor; +} + +.rpg-encounter-modal[data-theme="fantasy"] .rpg-encounter-over h2 { + font-family: 'Georgia', serif; + text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9); +} + +.rpg-encounter-modal[data-theme="cyberpunk"] .rpg-encounter-over h2 { + font-family: 'Courier New', monospace; + text-shadow: 0 0 20px currentColor; + letter-spacing: 0.1em; +} + +/* Input fields theme support */ +.rpg-encounter-modal[data-theme="sci-fi"] #rpg-encounter-custom-input, +.rpg-encounter-modal[data-theme="fantasy"] #rpg-encounter-custom-input, +.rpg-encounter-modal[data-theme="cyberpunk"] #rpg-encounter-custom-input { + background: var(--rpg-bg); + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-encounter-modal[data-theme="sci-fi"] #rpg-encounter-custom-input:focus, +.rpg-encounter-modal[data-theme="fantasy"] #rpg-encounter-custom-input:focus, +.rpg-encounter-modal[data-theme="cyberpunk"] #rpg-encounter-custom-input:focus { + border-color: var(--rpg-highlight); + box-shadow: 0 0 15px var(--rpg-shadow); +} +/* ============================================ + TARGET SELECTION MODAL + ============================================ */ + +/* Target Selection Overlay */ +.rpg-target-selection-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.8); + z-index: 999999; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease; +} + +/* Target Selection Modal */ +.rpg-target-selection-modal { + background: var(--SmartThemeBlurTintColor, #1a1a2e); + border: 2px solid var(--SmartThemeBorderColor, #4a7ba7); + border-radius: 12px; + padding: 24px; + max-width: 500px; + width: 90%; + max-height: 70vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.rpg-target-selection-modal h3 { + margin: 0 0 20px 0; + font-size: 20px; + color: var(--SmartThemeBodyColor, #eaeaea); + display: flex; + align-items: center; + gap: 10px; +} + +/* Target List */ +.rpg-target-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +/* Target Option */ +.rpg-target-option { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.05); + border: 2px solid var(--SmartThemeBorderColor, #4a7ba7); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.rpg-target-option:hover { + background: rgba(74, 123, 167, 0.2); + border-color: var(--SmartThemeBodyColor, #eaeaea); + transform: translateX(5px); +} + +.rpg-target-option.rpg-target-ally { + border-color: rgba(100, 200, 100, 0.5); +} + +.rpg-target-option.rpg-target-ally:hover { + border-color: rgba(100, 200, 100, 1); + background: rgba(100, 200, 100, 0.1); +} + +.rpg-target-icon { + font-size: 32px; + flex-shrink: 0; + line-height: 1; +} + +.rpg-target-name { + flex: 1; + font-size: 16px; + font-weight: 600; + color: var(--SmartThemeBodyColor, #eaeaea); +} + +.rpg-target-hp { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); +} + +.rpg-target-desc { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + font-style: italic; +} + +.rpg-target-divider { + text-align: center; + color: var(--SmartThemeBodyColor, #eaeaea); + font-weight: 700; + font-size: 14px; + padding: 10px 0; + opacity: 0.5; +} + +/* Cancel Button */ +.rpg-target-cancel { + width: 100%; + padding: 12px; + background: rgba(200, 50, 50, 0.3); + border: 2px solid rgba(200, 50, 50, 0.5); + border-radius: 6px; + color: var(--SmartThemeBodyColor, #eaeaea); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.rpg-target-cancel:hover { + background: rgba(200, 50, 50, 0.5); + border-color: rgba(200, 50, 50, 0.8); +} + +/* ============================================ + STATUS EFFECTS + ============================================ */ + +/* Status Effects Container */ +.rpg-encounter-statuses { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} + +/* Individual Status Effect */ +.rpg-encounter-status { + display: inline-block; + font-size: 20px; + line-height: 1; + padding: 4px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + cursor: help; + transition: transform 0.2s ease; +} + +.rpg-encounter-status:hover { + transform: scale(1.2); +} diff --git a/template.html b/template.html index 4da836a..bfe41b6 100644 --- a/template.html +++ b/template.html @@ -268,6 +268,32 @@ +
+

Combat Encounters

+ + + + Show the "Start Encounter" button above chat input for interactive combat + + +
+ + + Number of recent messages to include in combat initialization +
+ + + + Save detailed combat logs to file for future reference and analysis + +
+

Advanced