/** * 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 { repairJSON } from '../../utils/jsonRepair.js'; import { buildInventorySummary, generateTrackerInstructions, generateTrackerExample } from './promptBuilder.js'; import { applyLocks } from './lockManager.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: `; const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false; const levelStr = showLevel ? `, LVL ${extensionSettings.level}` : ''; userStatsInfo += `STR ${stats.str}, DEX ${stats.dex}, CON ${stats.con}, INT ${stats.int}, WIS ${stats.wis}, CHA ${stats.cha}${levelStr}\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|spaceship|mansion",\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; const config = extensionSettings.trackerConfig?.userStats; const rpgAttributes = (config?.rpgAttributes && config.rpgAttributes.length > 0) ? config.rpgAttributes : [ { id: 'str', name: 'STR', enabled: true }, { id: 'dex', name: 'DEX', enabled: true }, { id: 'con', name: 'CON', enabled: true }, { id: 'int', name: 'INT', enabled: true }, { id: 'wis', name: 'WIS', enabled: true }, { id: 'cha', name: 'CHA', enabled: true } ]; const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id); const attributeStrings = enabledAttributes.map(attr => `${attr.name} ${stats[attr.id] || 10}`); systemMessage += `\nAttributes: ${attributeStrings.join(', ')}, 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 the exact JSON object as below, containing ONLY these specified values. Remember to consider the user's party and their moves. 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`; // Use custom combat narrative prompt if available const customCombatPrompt = extensionSettings.customCombatNarrativePrompt; if (customCombatPrompt) { stateMessage += customCombatPrompt.replace(/{userName}/g, userName) + '\n'; } else { 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) { const statsJSON = typeof committedTrackerData.userStats === 'object' ? JSON.stringify(committedTrackerData.userStats, null, 2) : committedTrackerData.userStats; summaryMessage += statsJSON + '\n'; } if (committedTrackerData.infoBox) { const infoBoxJSON = typeof committedTrackerData.infoBox === 'object' ? JSON.stringify(committedTrackerData.infoBox, null, 2) : committedTrackerData.infoBox; summaryMessage += infoBoxJSON + '\n'; } if (committedTrackerData.characterThoughts) { const charactersJSON = typeof committedTrackerData.characterThoughts === 'object' ? JSON.stringify(committedTrackerData.characterThoughts, null, 2) : committedTrackerData.characterThoughts; summaryMessage += charactersJSON + '\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, ```markdown, and ``` markers (more comprehensive) cleaned = cleaned.replace(/```(?:json|markdown)?\s*/gi, ''); // Remove any remaining backticks cleaned = cleaned.replace(/`/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); } // Try to parse directly first try { return JSON.parse(cleaned); } catch (initialError) { // If direct parsing fails, try JSON repair console.warn('[RPG Companion] Initial parse failed, attempting JSON repair...'); const repaired = repairJSON(cleaned); if (repaired) { // console.log('[RPG Companion] โœ“ Successfully repaired encounter JSON'); return repaired; } // If repair also failed, throw the original error throw initialError; } } catch (error) { console.error('[RPG Companion] Failed to parse encounter JSON:', error); console.error('[RPG Companion] Response was:', response); return null; } }