v3.6.1: Dynamic combat actions and bug fixes

- Added dynamic action updates: AI can now modify available attacks/items based on combat state
- Items decrease when used, abilities change based on status effects
- Fixed event delegation for encounter buttons to work reliably on mobile
- Fixed multiple JSON parsing validation errors
- Added proper dialogue handling in combat summaries
- UI now re-renders action buttons when actions change
- Improved prompt instructions for item quantities and dynamic actions
This commit is contained in:
Spicy_Marinara
2026-01-13 19:21:49 +01:00
parent c14250e467
commit fd8afba7f2
7 changed files with 214 additions and 73 deletions
+4 -8
View File
@@ -7,15 +7,11 @@ An immersive RPG extension for browsers that tracks character stats, scene infor
## 🆕 What's New ## 🆕 What's New
### v3.6.0 ### v3.6.1
- You can now choose whether stats are displayed as percentages or numbers. - Fixed the bugs in the encounter system where you couldn't use the buttons after performing any custom action.
- Added collapsed strip widgets for desktop. - Improved combat actions and made them dynamic, depending on the current situation.
- Added new effects for the dynamic weather. - Added Russian as a supported language.
- Changed the displayed clock format in the Info Box.
- Fixed customized status field to work.
- Fixed date format toggles.
- Minor CSS and bug fixes.
**Special thanks to all the other contributors for this project:** **Special thanks to all the other contributors for this project:**
Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610. Paperboygold, Munimunigamer, Subarashimo, Lilminzyu, Claude, IDeathByte, Chungchandev, Joenunezb, Amauragis, and Tomt610.
+1 -1
View File
@@ -6,6 +6,6 @@
"js": "index.js", "js": "index.js",
"css": "style.css", "css": "style.css",
"author": "Marinara", "author": "Marinara",
"version": "3.6.0", "version": "3.6.1",
"homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern" "homePage": "https://github.com/SpicyMarinara/rpg-companion-sillytavern"
} }
+1 -1
View File
@@ -48,7 +48,7 @@
</div> </div>
<div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;"> <div style="margin-top: 10px; text-align: center; opacity: 0.6; font-size: 0.85em;">
v3.6.0 v3.6.1
</div> </div>
</div> </div>
</div> </div>
+70 -14
View File
@@ -121,7 +121,7 @@ export async function buildEncounterInitPrompt() {
// console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length }); // console.log('[RPG Companion] World info result:', { worldInfoString, length: worldInfoString?.length });
if (worldInfoString && worldInfoString.trim()) { if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim(); systemMessage += worldInfoString.trim();
worldInfoAdded = true; worldInfoAdded = true;
// console.log('[RPG Companion] ✅ Added world info from getWorldInfoPrompt'); // console.log('[RPG Companion] ✅ Added world info from getWorldInfoPrompt');
@@ -258,6 +258,7 @@ export async function buildEncounterInitPrompt() {
initInstruction += `The combat starts now.\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 += `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 += `FORMAT:\n`;
initInstruction += `{\n`; initInstruction += `{\n`;
initInstruction += ` "party": [\n`; initInstruction += ` "party": [\n`;
initInstruction += ` {\n`; initInstruction += ` {\n`;
@@ -268,7 +269,7 @@ export async function buildEncounterInitPrompt() {
initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`; initInstruction += ` {"name": "Attack", "type": "single-target|AoE|both"},\n`;
initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`; initInstruction += ` {"name": "Skill1", "type": "single-target|AoE|both"}\n`;
initInstruction += ` ],\n`; initInstruction += ` ],\n`;
initInstruction += ` "items": ["Item1", "Item2"],\n`; initInstruction += ` "items": ["Item Name x3", "Another Item x1"],\n`;
initInstruction += ` "statuses": [],\n`; initInstruction += ` "statuses": [],\n`;
initInstruction += ` "isPlayer": true\n`; initInstruction += ` "isPlayer": true\n`;
initInstruction += ` }\n`; initInstruction += ` }\n`;
@@ -302,11 +303,14 @@ export async function buildEncounterInitPrompt() {
initInstruction += ` - "single-target": Can only target one character (enemy or ally)\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 += ` - "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 += ` - "both": Player can choose to target a single enemy OR use as AoE\n`;
initInstruction += `- For items array: Include quantities using format "Item Name xN" (e.g., "Health Potion x3", "Bomb x1")\n`;
initInstruction += ` - If only one item exists, you can use "Item Name x1" or just "Item Name"\n`;
initInstruction += ` - Items will be consumed when used - the quantity will decrease in future turns\n`;
initInstruction += `- Statuses array: May start empty, but don't have to if characters applied them before the combat\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 += ` - Each status has a format: {"name": "Status Name", "emoji": "💀", "duration": X}\n`;
initInstruction += ` - Examples: Poisoned (🧪), Burning (🔥), Blessed (✨), Stunned (💫), Weakened (⬇️), Strengthened (⬆️)\n\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 += `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 += `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 WITH QUANTITIES (e.g., "Health Potion x3"). 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.`; 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 // Only add the instruction if it has meaningful content
@@ -364,7 +368,7 @@ export async function buildCombatActionPrompt(action, combatStats) {
const result = await getWorldInfoFn(chatForWI, 8000, false); const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result; const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) { if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim(); systemMessage += worldInfoString.trim();
worldInfoAdded = true; worldInfoAdded = true;
} }
@@ -483,12 +487,25 @@ export async function buildCombatActionPrompt(action, combatStats) {
stateMessage += `Party Members:\n`; stateMessage += `Party Members:\n`;
combatStats.party.forEach(member => { combatStats.party.forEach(member => {
stateMessage += `- ${member.name}${member.isPlayer ? ' (Player)' : ''}: ${member.hp}/${member.maxHp} HP\n`; 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`; // For the player, use playerActions if available, otherwise fall back to member data
} if (member.isPlayer && currentEncounter.playerActions) {
if (member.items && member.items.length > 0) { if (currentEncounter.playerActions.attacks && currentEncounter.playerActions.attacks.length > 0) {
stateMessage += ` Items: ${member.items.join(', ')}\n`; stateMessage += ` Attacks: ${currentEncounter.playerActions.attacks.map(a => typeof a === 'string' ? a : a.name).join(', ')}\n`;
}
if (currentEncounter.playerActions.items && currentEncounter.playerActions.items.length > 0) {
stateMessage += ` Items: ${currentEncounter.playerActions.items.join(', ')}\n`;
}
} else {
// For non-player party members, use their own data
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) { if (member.statuses && member.statuses.length > 0) {
const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name)); const validStatuses = member.statuses.filter(s => s && (s.emoji || s.name));
if (validStatuses.length > 0) { if (validStatuses.length > 0) {
@@ -515,11 +532,39 @@ export async function buildCombatActionPrompt(action, combatStats) {
}); });
stateMessage += `\n${userName}'s Action: ${action}\n\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 += `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\n`;
stateMessage += `IMPORTANT - Update ${userName}'s attacks and items arrays based on what happens in combat:\n`;
stateMessage += `- ${userName}'s action is already specified above - do NOT regenerate it. Only update ${userName}'s attacks/items arrays if their action consumed resources (used item, lost ability, etc.).\n`;
stateMessage += `- If they use an item, decrement its quantity ("Health Potion x3" becomes "Health Potion x2"). If quantity reaches 0, remove the item entirely.\n`;
stateMessage += `- If they gain or lose an ability due to status effects, add or remove it from their attacks array.\n`;
stateMessage += ` Examples: Disarmed → remove weapon attacks. Bound → remove all attacks or set to []. Freed → restore attacks.\n`;
stateMessage += `- If they pick up a weapon/item during combat, add it to their items or attacks array.\n`;
stateMessage += `- If environmental changes enable new actions (near water → "Splash Attack"), add them. If they disable actions (fire goes out → remove "Ignite"), remove them.\n`;
stateMessage += `- Status effects should persist and decrease duration each turn. Remove statuses when duration reaches 0.\n\n`;
stateMessage += `FORMAT:\n`;
stateMessage += `{\n`; stateMessage += `{\n`;
stateMessage += ` "combatStats": {\n`; stateMessage += ` "combatStats": {\n`;
stateMessage += ` "party": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }],\n`; stateMessage += ` "party": [\n`;
stateMessage += ` "enemies": [{ "name": "Name", "hp": X, "maxHp": X, "statuses": [...] }]\n`; stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}],\n`;
stateMessage += ` "isPlayer": true|false\n`;
stateMessage += ` }\n`;
stateMessage += ` ],\n`;
stateMessage += ` "enemies": [\n`;
stateMessage += ` {\n`;
stateMessage += ` "name": "Name",\n`;
stateMessage += ` "hp": X,\n`;
stateMessage += ` "maxHp": X,\n`;
stateMessage += ` "statuses": [{"name": "Status", "emoji": "💀", "duration": X}]\n`;
stateMessage += ` }\n`;
stateMessage += ` ]\n`;
stateMessage += ` },\n`;
stateMessage += ` "playerActions": {\n`;
stateMessage += ` "attacks": [{"name": "Attack", "type": "single-target|AoE|both"}],\n`;
stateMessage += ` "items": ["Item Name x3", "Another Item x1"]\n`;
stateMessage += ` },\n`; stateMessage += ` },\n`;
stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`; stateMessage += ` "enemyActions": [{ "enemyName": "Name", "action": "what they do", "target": "target" }],\n`;
stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`; stateMessage += ` "partyActions": [{ "memberName": "Name", "action": "what they do", "target": "target" }],\n`;
@@ -587,7 +632,7 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
const result = await getWorldInfoFn(chatForWI, 8000, false); const result = await getWorldInfoFn(chatForWI, 8000, false);
const worldInfoString = result?.worldInfoString || result; const worldInfoString = result?.worldInfoString || result;
if (worldInfoString && worldInfoString.trim()) { if (worldInfoString && typeof worldInfoString === 'string' && worldInfoString.trim()) {
systemMessage += worldInfoString.trim(); systemMessage += worldInfoString.trim();
worldInfoAdded = true; worldInfoAdded = true;
} }
@@ -659,7 +704,9 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
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 += `\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 += `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 += `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`; summaryMessage += `Dialogue Guidelines:\n`;
summaryMessage += `- Include ALL dialogue lines spoken by enemies and NPC party members during the encounter in direct quotes.\n`;
summaryMessage += `- Never quote ${userName} directly. Express their actions and dialogue using ONLY indirect speech (e.g., "${userName} swung their sword" or "${userName} asked for help").\n\n`;
// If in Together mode and trackers are enabled, add tracker update instructions // If in Together mode and trackers are enabled, add tracker update instructions
if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) { if (extensionSettings.generationMode === 'together' && (extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts)) {
@@ -721,6 +768,12 @@ export async function buildCombatSummaryPrompt(combatLog, result) {
*/ */
export function parseEncounterJSON(response) { export function parseEncounterJSON(response) {
try { try {
// Ensure response is a string
if (!response || typeof response !== 'string') {
console.error('[RPG Companion] parseEncounterJSON received non-string input:', typeof response);
return null;
}
// Remove code blocks if present // Remove code blocks if present
let cleaned = response.trim(); let cleaned = response.trim();
@@ -736,6 +789,9 @@ export function parseEncounterJSON(response) {
if (firstBrace !== -1 && lastBrace !== -1) { if (firstBrace !== -1 && lastBrace !== -1) {
cleaned = cleaned.substring(firstBrace, lastBrace + 1); cleaned = cleaned.substring(firstBrace, lastBrace + 1);
} else {
console.error('[RPG Companion] No JSON object found in response');
return null;
} }
// Try to parse directly first // Try to parse directly first
+10 -2
View File
@@ -198,7 +198,9 @@ export function parseResponse(responseText) {
if (depth === 0) { if (depth === 0) {
// Found complete JSON object // Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim(); const jsonContent = cleanedResponse.substring(i, j).trim();
extractedObjects.push(jsonContent); if (jsonContent) {
extractedObjects.push(jsonContent);
}
i = j; i = j;
} else { } else {
i++; i++;
@@ -307,6 +309,9 @@ export function parseResponse(responseText) {
for (let idx = 0; idx < jsonMatches.length; idx++) { for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx]; const match = jsonMatches[idx];
const jsonContent = match[1].trim(); const jsonContent = match[1].trim();
if (!jsonContent) continue;
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...'); // console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent); const parsed = repairJSON(jsonContent);
@@ -363,6 +368,9 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] Found JSON blocks within XML tags'); debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) { for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim(); const jsonContent = match[1].trim();
if (!jsonContent) continue;
const parsed = repairJSON(jsonContent); const parsed = repairJSON(jsonContent);
if (parsed) { if (parsed) {
@@ -524,7 +532,7 @@ export function parseUserStats(statsText) {
// Check if this is v3 JSON format - try to parse it first // Check if this is v3 JSON format - try to parse it first
let statsData = null; let statsData = null;
const trimmed = statsText.trim(); const trimmed = statsText.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) { if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
statsData = repairJSON(statsText); statsData = repairJSON(statsText);
if (statsData) { if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format'); debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
+117 -41
View File
@@ -397,7 +397,7 @@ export class EncounterModal {
</div> </div>
<!-- Player Controls --> <!-- Player Controls -->
${this.renderPlayerControls(combatData.party)} ${this.renderPlayerControls(combatData.party, currentEncounter.playerActions)}
</div> </div>
`; `;
@@ -599,7 +599,7 @@ export class EncounterModal {
if (member.isPlayer && user_avatar) { if (member.isPlayer && user_avatar) {
avatarIcon = `<img src="${getSafeThumbnailUrl('persona', user_avatar)}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`; avatarIcon = `<img src="${getSafeThumbnailUrl('persona', user_avatar)}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`;
} else { } else {
const avatarUrl = this.getPartyMemberAvatar(member.name); const avatarUrl = this.getCharacterAvatar(member.name);
if (avatarUrl) { if (avatarUrl) {
avatarIcon = `<img src="${avatarUrl}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`; avatarIcon = `<img src="${avatarUrl}" alt="${member.name}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">`;
} }
@@ -657,12 +657,16 @@ export class EncounterModal {
* @param {Array} party - Party data * @param {Array} party - Party data
* @returns {string} HTML for controls * @returns {string} HTML for controls
*/ */
renderPlayerControls(party) { renderPlayerControls(party, playerActions = null) {
const player = party.find(m => m.isPlayer); const player = party.find(m => m.isPlayer);
if (!player || player.hp <= 0) { if (!player || player.hp <= 0) {
return '<div class="rpg-encounter-controls"><p class="rpg-encounter-defeated">You have been defeated...</p></div>'; return '<div class="rpg-encounter-controls"><p class="rpg-encounter-defeated">You have been defeated...</p></div>';
} }
// Use playerActions if provided, otherwise fall back to player data
const attacks = playerActions?.attacks || player.attacks || [];
const items = playerActions?.items || player.items || [];
return ` return `
<div class="rpg-encounter-controls"> <div class="rpg-encounter-controls">
<h3><i class="fa-solid fa-hand-fist"></i> Your Actions</h3> <h3><i class="fa-solid fa-hand-fist"></i> Your Actions</h3>
@@ -670,7 +674,7 @@ export class EncounterModal {
<div class="rpg-encounter-action-buttons"> <div class="rpg-encounter-action-buttons">
<div class="rpg-encounter-button-group"> <div class="rpg-encounter-button-group">
<h4>Attacks</h4> <h4>Attacks</h4>
${player.attacks.map(attack => { ${attacks.map(attack => {
// Support both old string format and new object format // Support both old string format and new object format
const attackName = typeof attack === 'string' ? attack : attack.name; const attackName = typeof attack === 'string' ? attack : attack.name;
const attackType = typeof attack === 'string' ? 'single-target' : (attack.type || 'single-target'); const attackType = typeof attack === 'string' ? 'single-target' : (attack.type || 'single-target');
@@ -688,10 +692,10 @@ export class EncounterModal {
}).join('')} }).join('')}
</div> </div>
${player.items && player.items.length > 0 ? ` ${items && items.length > 0 ? `
<div class="rpg-encounter-button-group"> <div class="rpg-encounter-button-group">
<h4>Items</h4> <h4>Items</h4>
${player.items.map(item => ` ${items.map(item => `
<button class="rpg-encounter-action-btn rpg-encounter-item-btn" data-action="item" data-value="${item}"> <button class="rpg-encounter-action-btn rpg-encounter-item-btn" data-action="item" data-value="${item}">
<i class="fa-solid fa-flask"></i> ${item} <i class="fa-solid fa-flask"></i> ${item}
</button> </button>
@@ -718,21 +722,27 @@ export class EncounterModal {
* @param {Array} party - Party data for reference * @param {Array} party - Party data for reference
*/ */
attachControlListeners(party) { attachControlListeners(party) {
// Attack and item buttons // Only attach once - event delegation on the modal means listeners persist
this.modal.querySelectorAll('.rpg-encounter-action-btn').forEach(btn => { if (this._listenersAttached) {
btn.addEventListener('click', async (e) => { return;
const actionType = e.currentTarget.dataset.action; }
const value = e.currentTarget.dataset.value;
const attackType = e.currentTarget.dataset.attackType; // Store handlers as instance properties so we can remove them if needed
this._actionHandler = async (e) => {
// Handle action buttons (attack/item)
const actionBtn = e.target.closest('.rpg-encounter-action-btn');
if (actionBtn && !actionBtn.disabled && !this.isProcessing) {
const actionType = actionBtn.dataset.action;
const value = actionBtn.dataset.value;
const attackType = actionBtn.dataset.attackType;
const context = getContext(); const context = getContext();
const userName = context.name1; const userName = context.name1;
let actionText = ''; let actionText = '';
if (actionType === 'attack') { if (actionType === 'attack') {
// Show target selection for attacks
const target = await this.showTargetSelection(attackType, currentEncounter.combatStats); const target = await this.showTargetSelection(attackType, currentEncounter.combatStats);
if (!target) return; // User cancelled if (!target) return;
if (target === 'all-enemies') { if (target === 'all-enemies') {
actionText = `${userName} uses ${value} targeting all enemies!`; actionText = `${userName} uses ${value} targeting all enemies!`;
@@ -740,40 +750,46 @@ export class EncounterModal {
actionText = `${userName} uses ${value} on ${target}!`; actionText = `${userName} uses ${value} on ${target}!`;
} }
} else if (actionType === 'item') { } else if (actionType === 'item') {
// Show target selection for items (default to single-target)
const target = await this.showTargetSelection('single-target', currentEncounter.combatStats); const target = await this.showTargetSelection('single-target', currentEncounter.combatStats);
if (!target) return; // User cancelled if (!target) return;
actionText = `${userName} uses ${value} on ${target}!`; actionText = `${userName} uses ${value} on ${target}!`;
} }
await this.processCombatAction(actionText); await this.processCombatAction(actionText);
}); return;
}); }
// Custom action submit // Handle custom submit button
const customInput = this.modal.querySelector('#rpg-encounter-custom-input'); const submitBtn = e.target.closest('#rpg-encounter-custom-submit');
const customSubmit = this.modal.querySelector('#rpg-encounter-custom-submit'); if (submitBtn && !submitBtn.disabled && !this.isProcessing) {
const input = this.modal.querySelector('#rpg-encounter-custom-input');
const submitCustomAction = async () => { if (input) {
const action = customInput.value.trim(); const action = input.value.trim();
if (!action) return; if (action) {
await this.processCombatAction(action);
await this.processCombatAction(action); input.value = '';
customInput.value = ''; }
}
}
}; };
if (customSubmit) { this._keypressHandler = async (e) => {
customSubmit.addEventListener('click', submitCustomAction); const input = e.target.closest('#rpg-encounter-custom-input');
} if (input && e.key === 'Enter' && !this.isProcessing) {
const action = input.value.trim();
if (customInput) { if (action) {
customInput.addEventListener('keypress', (e) => { await this.processCombatAction(action);
if (e.key === 'Enter') { input.value = '';
submitCustomAction();
} }
}); }
} };
// Attach to the modal itself (which never gets replaced)
this.modal.addEventListener('click', this._actionHandler);
this.modal.addEventListener('keypress', this._keypressHandler);
this._listenersAttached = true;
} }
/** /**
@@ -820,7 +836,8 @@ export class EncounterModal {
// Update encounter state // Update encounter state
updateCurrentEncounter({ updateCurrentEncounter({
combatStats: result.combatStats combatStats: result.combatStats,
playerActions: result.playerActions
}); });
// Collect log entries in order: enemy actions, party actions, then narration // Collect log entries in order: enemy actions, party actions, then narration
@@ -935,16 +952,75 @@ export class EncounterModal {
} }
}); });
// Re-render controls if player died // Re-render controls if player died OR if player's actions changed
const player = combatStats.party.find(m => m.isPlayer); const player = combatStats.party.find(m => m.isPlayer);
const controlsContainer = this.modal.querySelector('.rpg-encounter-controls');
if (player && player.hp <= 0) { if (player && player.hp <= 0) {
const controlsContainer = this.modal.querySelector('.rpg-encounter-controls');
if (controlsContainer) { if (controlsContainer) {
controlsContainer.innerHTML = '<p class="rpg-encounter-defeated">You have been defeated...</p>'; controlsContainer.innerHTML = '<p class="rpg-encounter-defeated">You have been defeated...</p>';
} }
} else if (currentEncounter.playerActions && controlsContainer) {
// Check if actions have changed by comparing with previous state
const actionsChanged = this.haveActionsChanged(currentEncounter.playerActions);
if (actionsChanged) {
// Store the new actions for next comparison
this._previousPlayerActions = {
attacks: currentEncounter.playerActions.attacks ? JSON.parse(JSON.stringify(currentEncounter.playerActions.attacks)) : [],
items: currentEncounter.playerActions.items ? [...currentEncounter.playerActions.items] : []
};
// Re-render the entire controls section with new actions
const newControlsHTML = this.renderPlayerControls(combatStats.party, currentEncounter.playerActions);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newControlsHTML;
const newControls = tempDiv.firstElementChild;
if (newControls) {
controlsContainer.replaceWith(newControls);
}
}
} }
} }
/**
* Checks if player's available actions have changed
* @param {Object} playerActions - Current player actions data with attacks and items
* @returns {boolean} True if actions changed
*/
haveActionsChanged(playerActions) {
if (!this._previousPlayerActions) {
// First time - store initial actions
this._previousPlayerActions = {
attacks: playerActions.attacks ? JSON.parse(JSON.stringify(playerActions.attacks)) : [],
items: playerActions.items ? [...playerActions.items] : []
};
return false;
}
const currentAttacks = playerActions.attacks || [];
const currentItems = playerActions.items || [];
const prevAttacks = this._previousPlayerActions.attacks || [];
const prevItems = this._previousPlayerActions.items || [];
// Check if attacks changed
if (currentAttacks.length !== prevAttacks.length) return true;
for (let i = 0; i < currentAttacks.length; i++) {
const curr = typeof currentAttacks[i] === 'string' ? currentAttacks[i] : currentAttacks[i].name;
const prev = typeof prevAttacks[i] === 'string' ? prevAttacks[i] : prevAttacks[i].name;
if (curr !== prev) return true;
}
// Check if items changed
if (currentItems.length !== prevItems.length) return true;
for (let i = 0; i < currentItems.length; i++) {
if (currentItems[i] !== prevItems[i]) return true;
}
return false;
}
/** /**
* Adds multiple log entries sequentially with animation * Adds multiple log entries sequentially with animation
* @param {Array} entries - Array of {message, type} objects * @param {Array} entries - Array of {message, type} objects
+11 -6
View File
@@ -11,13 +11,17 @@
* @returns {object|null} Repaired JSON object or null if repair fails * @returns {object|null} Repaired JSON object or null if repair fails
*/ */
export function repairJSON(jsonString) { export function repairJSON(jsonString) {
if (!jsonString || typeof jsonString !== 'string') { if (typeof jsonString !== 'string') {
console.warn('[RPG JSON Repair] Invalid input:', typeof jsonString); console.warn('[RPG JSON Repair] Invalid input type:', typeof jsonString);
return null; return null;
} }
let cleaned = jsonString.trim(); let cleaned = jsonString.trim();
if (!cleaned) {
return null;
}
// Remove markdown code fences // Remove markdown code fences
cleaned = cleaned.replace(/```json\s*/gi, ''); cleaned = cleaned.replace(/```json\s*/gi, '');
cleaned = cleaned.replace(/```\s*/g, ''); cleaned = cleaned.replace(/```\s*/g, '');
@@ -147,7 +151,8 @@ export function extractJSONFromText(text) {
// Try to extract from ```json code fence // Try to extract from ```json code fence
const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i); const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i);
if (fenceMatch && fenceMatch[1]) { if (fenceMatch && fenceMatch[1]) {
return fenceMatch[1].trim(); const trimmed = fenceMatch[1].trim();
if (trimmed) return trimmed;
} }
// Try to extract from ``` code fence (without json label) // Try to extract from ``` code fence (without json label)
@@ -155,20 +160,20 @@ export function extractJSONFromText(text) {
if (genericFenceMatch && genericFenceMatch[1]) { if (genericFenceMatch && genericFenceMatch[1]) {
const content = genericFenceMatch[1].trim(); const content = genericFenceMatch[1].trim();
// Check if it looks like JSON (starts with { or [) // Check if it looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) { if (content && (content.startsWith('{') || content.startsWith('['))) {
return content; return content;
} }
} }
// Try to find standalone JSON object // Try to find standalone JSON object
const objectMatch = text.match(/\{[\s\S]*\}/); const objectMatch = text.match(/\{[\s\S]*\}/);
if (objectMatch) { if (objectMatch && objectMatch[0].trim()) {
return objectMatch[0]; return objectMatch[0];
} }
// Try to find standalone JSON array // Try to find standalone JSON array
const arrayMatch = text.match(/\[[\s\S]*\]/); const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) { if (arrayMatch && arrayMatch[0].trim()) {
return arrayMatch[0]; return arrayMatch[0];
} }