Files
rpg-companion-sillytavern/src/systems/generation/encounterPrompts.js
T

722 lines
34 KiB
JavaScript

/**
* 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<string>} 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 += `<character${characterIndex}="${member.name}">\n`;
if (member.description) {
characterInfo += `${member.description}\n`;
}
if (member.personality) {
characterInfo += `${member.personality}\n`;
}
characterInfo += `</character${characterIndex}>\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 += `<character="${character.name}">\n`;
if (character.description) {
characterInfo += `${character.description}\n`;
}
if (character.personality) {
characterInfo += `${character.personality}\n`;
}
characterInfo += `</character>\n\n`;
}
return characterInfo;
}
/**
* Builds the initial encounter setup prompt
* This asks the model to generate all combat stats and setup data
* @returns {Promise<Array>} 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 += `<setting>\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</setting>\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 += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona information
systemMessage += `Here are details about the user's ${userName}:\n`;
systemMessage += `<persona>\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</persona>\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 += `<history>\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 = `</history>\n\n`;
// Wrap RPG Companion panel data in context tags
initInstruction += `Here is some additional tracked context for the scene:\n`;
initInstruction += `<context>\n`;
initInstruction += userStatsInfo;
initInstruction += partyInfo;
initInstruction += `</context>\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 += `<setting>\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</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona info
if (context.name1) {
systemMessage += `The protagonist is:\n`;
systemMessage += `<persona>\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 += `</persona>\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<Array>} 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 += `<setting>\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</setting>\n\n`;
// Add character information
const charactersInfo = await getCharactersInfo();
if (charactersInfo) {
systemMessage += `Here is the information available to you about the characters:\n`;
systemMessage += `<characters>\n${charactersInfo}</characters>\n\n`;
}
// Add persona information
systemMessage += `Here are details about ${userName}:\n`;
systemMessage += `<persona>\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</persona>\n\n`;
// Add the message that triggered the encounter
if (currentEncounter.encounterStartMessage) {
systemMessage += `Here is the last message before combat started:\n`;
systemMessage += `<trigger>\n${currentEncounter.encounterStartMessage}\n</trigger>\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 += `<previous>\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 += `</previous>\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;
}
}