Combat encounters: Add pre-encounter config modal, targeting fixes, and tracker integration

- Add pre-encounter narrative configuration modal with combat/summary style settings
- Change POV fields to text inputs (default: narrator) for custom character names
- Fix targeting system for enemies with spaces in names (e.g., 'Gilded Thrall 1')
- Display character-specific sprites/avatars in targeting modal instead of generic emojis
- Add combat difficulty scaling guidance to prevent trivial god defeats or endless wolf battles
- Integrate tracker updates in combat summary generation (together mode)
- Update auto-save logs description to clarify file storage vs chat history
- Apply extension theming to Close Combat Window button
This commit is contained in:
Spicy_Marinara
2025-12-27 16:06:06 +01:00
parent 436f3495f8
commit 3caa74fbf8
9 changed files with 3805 additions and 19 deletions
+134 -1
View File
@@ -128,6 +128,7 @@ import { ensureHtmlCleaningRegex, detectConflictingRegexScripts } from './src/sy
import { setupMemoryRecollectionButton, updateMemoryRecollectionButton } from './src/systems/features/memoryRecollection.js'; import { setupMemoryRecollectionButton, updateMemoryRecollectionButton } from './src/systems/features/memoryRecollection.js';
import { initLorebookLimiter } from './src/systems/features/lorebookLimiter.js'; import { initLorebookLimiter } from './src/systems/features/lorebookLimiter.js';
import { DEFAULT_HTML_PROMPT } from './src/systems/generation/promptBuilder.js'; import { DEFAULT_HTML_PROMPT } from './src/systems/generation/promptBuilder.js';
import { openEncounterModal } from './src/systems/ui/encounterUI.js';
// Integration modules // Integration modules
import { import {
@@ -402,6 +403,122 @@ async function initUI() {
togglePlotButtons(); 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() { $('#rpg-toggle-animations').on('change', function() {
extensionSettings.enableAnimations = $(this).prop('checked'); extensionSettings.enableAnimations = $(this).prop('checked');
saveSettings(); saveSettings();
@@ -524,6 +641,22 @@ async function initUI() {
$('#rpg-custom-html-prompt').val(extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT); $('#rpg-custom-html-prompt').val(extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT);
$('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); $('#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); $('#rpg-toggle-animations').prop('checked', extensionSettings.enableAnimations);
// Initialize avatar options // Initialize avatar options
@@ -580,7 +713,7 @@ async function initUI() {
setupSettingsPopup(); setupSettingsPopup();
initTrackerEditor(); initTrackerEditor();
addDiceQuickReply(); addDiceQuickReply();
setupPlotButtons(sendPlotProgression); setupPlotButtons(sendPlotProgression, openEncounterModal);
setupMobileKeyboardHandling(); setupMobileKeyboardHandling();
setupContentEditableScrolling(); setupContentEditableScrolling();
initInventoryEventListeners(); initInventoryEventListeners();
+130
View File
@@ -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);
}
+16 -1
View File
@@ -11,8 +11,9 @@ import { Generate } from '../../../../../../../script.js';
/** /**
* Sets up the plot progression buttons inside the send form area. * Sets up the plot progression buttons inside the send form area.
* @param {Function} handlePlotClick - Callback function to handle plot button clicks * @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 // Remove existing buttons if any
$('#rpg-plot-buttons').remove(); $('#rpg-plot-buttons').remove();
@@ -50,6 +51,19 @@ export function setupPlotButtons(handlePlotClick) {
" tabindex="0" role="button"> " tabindex="0" role="button">
<i class="fa-solid fa-forward"></i> Natural Plot <i class="fa-solid fa-forward"></i> Natural Plot
</button> </button>
<button id="rpg-encounter-button" class="menu_button interactable" style="
background-color: #cc3333;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin: 0 4px;
display: inline-block;
" tabindex="0" role="button" title="Enter combat encounter">
<i class="fa-solid fa-fire"></i> Enter Encounter
</button>
</span> </span>
`; `;
@@ -59,6 +73,7 @@ export function setupPlotButtons(handlePlotClick) {
// Add event handlers for buttons // Add event handlers for buttons
$('#rpg-plot-random').on('click', () => handlePlotClick('random')); $('#rpg-plot-random').on('click', () => handlePlotClick('random'));
$('#rpg-plot-natural').on('click', () => handlePlotClick('natural')); $('#rpg-plot-natural').on('click', () => handlePlotClick('natural'));
$('#rpg-encounter-button').on('click', () => handleEncounterClick());
// Show/hide based on setting // Show/hide based on setting
togglePlotButtons(); togglePlotButtons();
+714
View File
@@ -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<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 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<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;
}
}
+18 -14
View File
@@ -205,10 +205,10 @@ export function renderInfoBox() {
data.weatherEmoji = emoji; data.weatherEmoji = emoji;
data.weatherForecast = text; data.weatherForecast = text;
} else if (weatherStr.includes(',')) { } else if (weatherStr.includes(',')) {
// Fallback to comma split if emoji detection failed // Fallback to comma split if emoji detection failed - split only on FIRST comma
const weatherParts = weatherStr.split(',').map(p => p.trim()); const firstCommaIndex = weatherStr.indexOf(',');
data.weatherEmoji = weatherParts[0] || ''; data.weatherEmoji = weatherStr.substring(0, firstCommaIndex).trim();
data.weatherForecast = weatherParts[1] || ''; data.weatherForecast = weatherStr.substring(firstCommaIndex + 1).trim();
} else { } else {
// No clear separation - assume it's all forecast text // No clear separation - assume it's all forecast text
data.weatherEmoji = '🌤️'; // Default emoji data.weatherEmoji = '🌤️'; // Default emoji
@@ -608,14 +608,16 @@ export function updateInfoBoxField(field, value) {
if (line.startsWith('Weather:')) { if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast // New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim(); const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim()); // Split only on first comma to get emoji and rest
const forecast = parts[1] || 'Weather'; const firstCommaIndex = weatherContent.indexOf(',');
const forecast = firstCommaIndex > 0 ? weatherContent.substring(firstCommaIndex + 1).trim() : 'Weather';
return `Weather: ${value}, ${forecast}`; return `Weather: ${value}, ${forecast}`;
} else { } else {
// Legacy format: emoji: forecast // Legacy format: emoji: forecast
const parts = line.split(':'); const firstColonIndex = line.indexOf(':');
if (parts.length >= 2) { if (firstColonIndex >= 0) {
return `${value}: ${parts.slice(1).join(':').trim()}`; const forecast = line.substring(firstColonIndex + 1).trim();
return `${value}: ${forecast}`;
} }
} }
} else if (field === 'weatherForecast' && index === weatherLineIndex) { } else if (field === 'weatherForecast' && index === weatherLineIndex) {
@@ -623,14 +625,16 @@ export function updateInfoBoxField(field, value) {
if (line.startsWith('Weather:')) { if (line.startsWith('Weather:')) {
// New format: Weather: emoji, forecast // New format: Weather: emoji, forecast
const weatherContent = line.replace('Weather:', '').trim(); const weatherContent = line.replace('Weather:', '').trim();
const parts = weatherContent.split(',').map(p => p.trim()); // Split only on first comma to get emoji and rest
const emoji = parts[0] || '🌤️'; const firstCommaIndex = weatherContent.indexOf(',');
const emoji = firstCommaIndex > 0 ? weatherContent.substring(0, firstCommaIndex).trim() : '🌤️';
return `Weather: ${emoji}, ${value}`; return `Weather: ${emoji}, ${value}`;
} else { } else {
// Legacy format: emoji: forecast // Legacy format: emoji: forecast
const parts = line.split(':'); const firstColonIndex = line.indexOf(':');
if (parts.length >= 2) { if (firstColonIndex >= 0) {
return `${parts[0].trim()}: ${value}`; const emoji = line.substring(0, firstColonIndex).trim();
return `${emoji}: ${value}`;
} }
} }
} else if (field === 'temperature' && (line.includes('🌡️:') || line.startsWith('Temperature:'))) { } else if (field === 'temperature' && (line.includes('🌡️:') || line.startsWith('Temperature:'))) {
File diff suppressed because it is too large Load Diff
+7
View File
@@ -20,6 +20,13 @@ import { i18n } from '../../core/i18n.js';
export function togglePlotButtons() { export function togglePlotButtons() {
if (extensionSettings.enablePlotButtons && extensionSettings.enabled) { if (extensionSettings.enablePlotButtons && extensionSettings.enabled) {
$('#rpg-plot-buttons').show(); $('#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 { } else {
$('#rpg-plot-buttons').hide(); $('#rpg-plot-buttons').hide();
} }
+1366 -3
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -268,6 +268,32 @@
</div> </div>
</div> </div>
<div class="rpg-settings-group">
<h4><i class="fa-solid fa-swords" aria-hidden="true"></i> Combat Encounters</h4>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-encounters" />
<span>Enable Combat Encounters</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Show the "Start Encounter" button above chat input for interactive combat
</small>
<div class="rpg-setting-row" style="margin-top: 12px;">
<label for="rpg-encounter-history-depth">Chat History Depth:</label>
<input type="number" id="rpg-encounter-history-depth" min="1" max="20" value="8" class="rpg-input" />
<small>Number of recent messages to include in combat initialization</small>
</div>
<label class="checkbox_label">
<input type="checkbox" id="rpg-toggle-autosave-logs" />
<span>Auto-save Combat Logs</span>
</label>
<small style="display: block; margin-left: 24px; margin-top: -8px; color: #888; font-size: 11px;">
Save detailed combat logs to file for future reference and analysis
</small>
</div>
<div class="rpg-settings-group"> <div class="rpg-settings-group">
<h4 data-i18n-key="template.settingsModal.advancedTitle"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Advanced</h4> <h4 data-i18n-key="template.settingsModal.advancedTitle"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Advanced</h4>