Revert "All the features"

This commit is contained in:
Spicy Marinara
2025-12-05 22:43:56 +01:00
committed by GitHub
parent 275179fa7f
commit bfb63a34cd
35 changed files with 1389 additions and 5894 deletions
+65 -46
View File
@@ -10,14 +10,18 @@ import {
lastGeneratedData,
committedTrackerData,
isGenerating,
lastActionWasSwipe,
setIsGenerating,
setLastActionWasSwipe
} from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js';
import { generateSeparateUpdatePrompt } from './promptBuilder.js';
import { parseResponse, parseUserStats, parseSkills, tryParseJSONResponse } from './parser.js';
import { parseResponse, parseUserStats } from './parser.js';
import { renderUserStats } from '../rendering/userStats.js';
import { renderInfoBox } from '../rendering/infoBox.js';
import { renderThoughts } from '../rendering/thoughts.js';
import { renderInventory } from '../rendering/inventory.js';
import { renderQuests } from '../rendering/quests.js';
import { renderSkills } from '../rendering/skills.js';
import { i18n } from '../../core/i18n.js';
// Store the original preset name to restore after tracker generation
@@ -32,8 +36,12 @@ async function getCurrentPresetName() {
// Use /preset without arguments to get the current preset name
const result = await executeSlashCommandsOnChatInput('/preset', { quiet: true });
// console.log('[RPG Companion] /preset result:', result);
// The result should be an object with a 'pipe' property containing the preset name
if (result && typeof result === 'object' && result.pipe) {
const presetName = String(result.pipe).trim();
// console.log('[RPG Companion] Extracted preset name:', presetName);
return presetName || null;
}
@@ -54,7 +62,11 @@ async function getCurrentPresetName() {
*/
async function switchToPreset(presetName) {
try {
// Use the /preset slash command to switch presets
// This is the proper way to change presets in SillyTavern
await executeSlashCommandsOnChatInput(`/preset ${presetName}`, { quiet: true });
// console.log(`[RPG Companion] Switched to preset "${presetName}"`);
return true;
} catch (error) {
console.error('[RPG Companion] Error switching preset:', error);
@@ -75,6 +87,7 @@ async function switchToPreset(presetName) {
*/
export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory) {
if (isGenerating) {
// console.log('[RPG Companion] Already generating, skipping...');
return;
}
@@ -83,6 +96,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
}
if (extensionSettings.generationMode !== 'separate') {
// console.log('[RPG Companion] Not in separate mode, skipping manual update');
return;
}
@@ -118,27 +132,19 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
});
if (response) {
const jsonParsed = tryParseJSONResponse(response);
if (jsonParsed) {
// JSON parsing succeeded - render all sections
console.log('[RPG Companion] JSON parsing successful');
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderSkills();
saveChatData();
} else {
console.warn('[RPG Companion] JSON parsing failed, attempting legacy text parsing...');
const parsedData = parseResponse(response);
// console.log('[RPG Companion] Raw AI response:', response);
const parsedData = parseResponse(response);
// console.log('[RPG Companion] Parsed data:', parsedData);
// console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null');
extensionSettings.charactersData = [];
const parsedCharacterThoughts = parsedData.characterThoughts || '';
// DON'T update lastGeneratedData here - it should only reflect the data
// from the assistant message the user replied to, not auto-generated updates
// This ensures swipes/regenerations use consistent source data
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
if (lastMessage && !lastMessage.is_user) {
// Store RPG data for the last assistant message (separate mode)
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message');
if (lastMessage && !lastMessage.is_user) {
if (!lastMessage.extra) {
lastMessage.extra = {};
}
@@ -147,40 +153,50 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
}
const currentSwipeId = lastMessage.swipe_id || 0;
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedCharacterThoughts
};
lastMessage.extra.rpg_companion_swipes[currentSwipeId] = {
userStats: parsedData.userStats,
infoBox: parsedData.infoBox,
characterThoughts: parsedData.characterThoughts
};
if (parsedData.userStats) {
// console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId);
// Update lastGeneratedData for display AND future commit
if (parsedData.userStats) {
lastGeneratedData.userStats = parsedData.userStats;
parseUserStats(parsedData.userStats);
}
if (parsedData.skills) {
parseSkills(parsedData.skills);
}
if (parsedData.infoBox) {
lastGeneratedData.infoBox = parsedData.infoBox;
}
lastGeneratedData.characterThoughts = parsedCharacterThoughts;
if (parsedData.characterThoughts) {
lastGeneratedData.characterThoughts = parsedData.characterThoughts;
}
// console.log('[RPG Companion] 💾 SEPARATE MODE: Updated lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
const hasAnyCommittedContent = (
(committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') ||
(committedTrackerData.infoBox && committedTrackerData.infoBox.trim() !== '' && committedTrackerData.infoBox !== 'Info Box\n---\n') ||
(committedTrackerData.characterThoughts && committedTrackerData.characterThoughts.trim() !== '' && committedTrackerData.characterThoughts !== 'Present Characters\n---\n')
);
// Only auto-commit on TRULY first generation (no committed data exists at all)
// This prevents auto-commit after refresh when we have saved committed data
const hasAnyCommittedContent = (
(committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') ||
(committedTrackerData.infoBox && committedTrackerData.infoBox.trim() !== '' && committedTrackerData.infoBox !== 'Info Box\n---\n') ||
(committedTrackerData.characterThoughts && committedTrackerData.characterThoughts.trim() !== '' && committedTrackerData.characterThoughts !== 'Present Characters\n---\n')
);
if (!hasAnyCommittedContent) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedCharacterThoughts;
}
// Only commit if we have NO committed content at all (truly first time ever)
if (!hasAnyCommittedContent) {
committedTrackerData.userStats = parsedData.userStats;
committedTrackerData.infoBox = parsedData.infoBox;
committedTrackerData.characterThoughts = parsedData.characterThoughts;
// console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data');
}
// Render the updated data
renderUserStats();
renderInfoBox();
lastGeneratedData.characterThoughts = parsedCharacterThoughts;
renderThoughts();
renderInventory();
renderQuests();
@@ -191,15 +207,13 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
}
renderUserStats();
renderInfoBox();
lastGeneratedData.characterThoughts = parsedCharacterThoughts;
renderThoughts();
renderInventory();
renderQuests();
}
// Save to chat metadata
saveChatData();
}
// Save to chat metadata
saveChatData();
}
} catch (error) {
@@ -214,9 +228,14 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough
setIsGenerating(false);
// Restore button to original state
const $updateBtn = $('#rpg-manual-update');
const refreshText = i18n.getTranslation('template.mainPanel.refreshRpgInfo') || 'Refresh RPG Info';
$updateBtn.html(`<i class="fa-solid fa-sync"></i> ${refreshText}`).prop('disabled', false);
// Reset the flag after tracker generation completes
// This ensures the flag persists through both main generation AND tracker generation
// console.log('[RPG Companion] 🔄 Tracker generation complete - resetting lastActionWasSwipe to false');
setLastActionWasSwipe(false);
}
}
+114 -23
View File
@@ -10,26 +10,18 @@ import {
committedTrackerData,
lastGeneratedData,
isGenerating,
lastActionWasSwipe
lastActionWasSwipe,
setLastActionWasSwipe
} from '../../core/state.js';
import { evaluateSuppression } from './suppression.js';
import { parseUserStats } from './parser.js';
import {
generateJSONTrackerInstructions,
generateTrackerExample,
generateTrackerInstructions,
generateContextualSummary,
DEFAULT_HTML_PROMPT
} from './promptBuilder.js';
/**
* Gets tracker instructions (always uses JSON format)
* @param {boolean} includeHtmlPrompt
* @param {boolean} includeContinuation
* @returns {string}
*/
function getTrackerInstructions(includeHtmlPrompt, includeContinuation) {
return generateJSONTrackerInstructions(includeHtmlPrompt, includeContinuation);
}
/**
* Event handler for generation start.
* Manages tracker data commitment and prompt injection based on generation mode.
@@ -38,7 +30,15 @@ function getTrackerInstructions(includeHtmlPrompt, includeContinuation) {
* @param {Object} data - Event data
*/
export function onGenerationStarted(type, data) {
// console.log('[RPG Companion] onGenerationStarted called');
// console.log('[RPG Companion] enabled:', extensionSettings.enabled);
// console.log('[RPG Companion] generationMode:', extensionSettings.generationMode);
// console.log('[RPG Companion] ⚡ EVENT: onGenerationStarted - lastActionWasSwipe =', lastActionWasSwipe, '| isGenerating =', isGenerating);
// console.log('[RPG Companion] Committed Prompt:', committedTrackerData);
// Skip tracker injection for image generation requests
if (data?.quietImage) {
// console.log('[RPG Companion] Detected image generation (quietImage=true), skipping tracker injection');
return;
}
@@ -48,27 +48,74 @@ export function onGenerationStarted(type, data) {
const context = getContext();
const chat = context.chat;
// Detect if a guided generation is active (GuidedGenerations and similar extensions
// inject an ephemeral 'instruct' injection into chatMetadata.script_injects).
// If present, we should avoid injecting RPG tracker instructions that ask
// the model to include stats/etc. This prevents conflicts when guided prompts
// are used (e.g., GuidedGenerations Extension).
// Evaluate suppression using the shared helper
const suppression = evaluateSuppression(extensionSettings, context, data);
const { shouldSuppress, skipMode, isGuidedGeneration, isImpersonationGeneration, hasQuietPrompt } = suppression;
const { shouldSuppress, skipMode, isGuidedGeneration, isImpersonationGeneration, hasQuietPrompt, instructContent, quietPromptRaw, matchedPattern } = suppression;
if (shouldSuppress) {
// Debugging: indicate active suppression and which source triggered it
console.debug(`[RPG Companion] Suppression active (mode=${skipMode}). isGuided=${isGuidedGeneration}, isImpersonation=${isImpersonationGeneration}, hasQuietPrompt=${hasQuietPrompt} - skipping RPG tracker injections for this generation.`);
// Also clear any existing RPG Companion prompts so they do not leak into this generation
// (e.g., previously set extension prompts should not be used alongside a guided prompt)
setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null;
// For SEPARATE mode only: Check if we need to commit extension data
// BUT: Only do this for the MAIN generation, not the tracker update generation
// If isGenerating is true, this is the tracker update generation (second call), so skip flag logic
// console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts);
if (extensionSettings.generationMode === 'separate' && !isGenerating) {
if (!lastActionWasSwipe) {
// User sent a new message - commit lastGeneratedData before generation
// console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData');
// console.log('[RPG Companion] BEFORE commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', {
// userStats: lastGeneratedData.userStats ? 'exists' : 'null',
// infoBox: lastGeneratedData.infoBox ? 'exists' : 'null',
// characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null'
// });
committedTrackerData.userStats = lastGeneratedData.userStats;
committedTrackerData.infoBox = lastGeneratedData.infoBox;
committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts;
// console.log('[RPG Companion] AFTER commit - committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after committing (ready for next cycle)
} else {
// console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)');
// console.log('[RPG Companion] committedTrackerData:', {
// userStats: committedTrackerData.userStats ? 'exists' : 'null',
// infoBox: committedTrackerData.infoBox ? 'exists' : 'null',
// characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null'
// });
// Reset flag after using it (swipe generation complete, ready for next action)
}
}
// For TOGETHER mode: Check if we need to commit extension data
// Only commit when user sends a new message (not on swipes)
if (extensionSettings.generationMode === 'together') {
if (!lastActionWasSwipe) {
// User sent a new message - commit data from the last assistant message they replied to
// This ensures swipes use consistent data from before the first swipe
console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: New message - committing from last assistant message');
// Find the last assistant message (before the user's new message)
@@ -107,66 +154,110 @@ export function onGenerationStarted(type, data) {
}
}
// Use the committed tracker data as source for generation
// console.log('[RPG Companion] Using committedTrackerData for generation');
// console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats);
// Parse stats from committed data to update the extensionSettings for prompt generation
if (committedTrackerData.userStats) {
// console.log('[RPG Companion] Parsing committed userStats into extensionSettings');
parseUserStats(committedTrackerData.userStats);
// console.log('[RPG Companion] After parsing, extensionSettings.userStats:', JSON.stringify(extensionSettings.userStats));
}
if (extensionSettings.generationMode === 'together') {
const example = '';
const instructions = getTrackerInstructions(false, true);
// console.log('[RPG Companion] In together mode, generating prompts...');
const example = generateTrackerExample();
// Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes
const instructions = generateTrackerInstructions(false, true);
// Clear separate mode context injection - we don't use contextual summary in together mode
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
let lastAssistantDepth = -1;
// console.log('[RPG Companion] Example:', example ? 'exists' : 'empty');
// console.log('[RPG Companion] Chat length:', chat ? chat.length : 'chat is null');
// Find the last assistant message in the chat history
let lastAssistantDepth = -1; // -1 means not found
if (chat && chat.length > 0) {
// console.log('[RPG Companion] Searching for last assistant message...');
// Start from depth 1 (skip depth 0 which is usually user's message or prefill)
for (let depth = 1; depth < chat.length; depth++) {
const index = chat.length - 1 - depth;
const index = chat.length - 1 - depth; // Convert depth to index
const message = chat[index];
// console.log('[RPG Companion] Checking depth', depth, 'index', index, 'message properties:', Object.keys(message));
// Check for assistant message: not user and not system
if (!message.is_user && !message.is_system) {
// Found assistant message at this depth
// Inject at the SAME depth to prepend to this assistant message
lastAssistantDepth = depth;
// console.log('[RPG Companion] Found last assistant message at depth', depth, '-> injecting at same depth:', lastAssistantDepth);
break;
}
}
}
// If we have previous tracker data and found an assistant message, inject it as an assistant message
if (!shouldSuppress && example && lastAssistantDepth > 0) {
setExtensionPrompt('rpg-companion-example', example, extension_prompt_types.IN_CHAT, lastAssistantDepth, false, extension_prompt_roles.ASSISTANT);
// console.log('[RPG Companion] Injected tracker example as assistant message at depth:', lastAssistantDepth);
} else {
// console.log('[RPG Companion] NOT injecting example. example:', !!example, 'lastAssistantDepth:', lastAssistantDepth);
}
// Inject the instructions as a user message at depth 0 (right before generation)
// If this is a guided generation (user explicitly injected 'instruct'), skip adding
// our tracker instructions to avoid clobbering the guided prompt.
if (!shouldSuppress) {
setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER);
}
// console.log('[RPG Companion] Injected RPG tracking instructions at depth 0 (right before generation)');
// Inject HTML prompt separately at depth 0 if enabled (prevents duplication on swipes)
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n${htmlPromptText}`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for together mode');
} else {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
} else if (extensionSettings.generationMode === 'separate') {
const currentStateJSON = generateContextualSummary();
// In SEPARATE mode, inject the contextual summary for main roleplay generation
const contextSummary = generateContextualSummary();
if (currentStateJSON) {
const wrappedContext = `\nHere is {{user}}'s current state in JSON format. This is merely informative, it's not your job to update it:
if (contextSummary) {
const wrappedContext = `\nHere is context information about the current scene, and what follows is the last message in the chat history:
<context>
\`\`\`json
${currentStateJSON}
\`\`\`
${contextSummary}
Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses.
</context>\n\n`;
// Inject context at depth 1 (before last user message) as SYSTEM
// Skip when a guided generation injection is present to avoid conflicting instructions
if (!shouldSuppress) {
setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false);
}
// console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary);
} else {
// Clear if no data yet
setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false);
}
// Inject HTML prompt separately at depth 0 if enabled (same as together mode pattern)
if (extensionSettings.enableHtmlPrompt && !shouldSuppress) {
// Use custom HTML prompt if set, otherwise use default
const htmlPromptText = extensionSettings.customHtmlPrompt || DEFAULT_HTML_PROMPT;
const htmlPrompt = `\n${htmlPromptText}`;
setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false);
// console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate mode');
} else {
// Clear HTML prompt if disabled
setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false);
}
+2 -58
View File
@@ -3,8 +3,6 @@
* Extracts v2 inventory data from AI-generated text
*/
import { extensionSettings } from '../../core/state.js';
// Type imports
/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */
@@ -103,84 +101,30 @@ export function extractLegacyInventory(text) {
return null;
}
/**
* Extracts simplified inventory data (single "Inventory:" line).
* Used when useSimplifiedInventory setting is enabled.
*
* Expected format: "Inventory: Sword, Shield, 3x Potions, Gold coins"
*
* @param {string} text - Text that may contain simplified inventory
* @returns {InventoryV2|null} Parsed inventory or null
*/
export function extractSimplifiedInventory(text) {
if (!text || typeof text !== 'string') {
return null;
}
// Match simplified format: "Inventory: ..."
const match = text.match(/Inventory:\s*(.+?)(?:\n|$)/i);
if (match && match[1]) {
const inventoryText = match[1].trim();
// Return null for empty values like "None" or ""
if (!inventoryText || inventoryText.toLowerCase() === 'none') {
return null;
}
// Return v2 format with items stored in both 'items' (for simplified display)
// and 'onPerson' (for backward compatibility)
return {
version: 2,
onPerson: inventoryText,
stored: {},
assets: "None",
items: inventoryText, // Simplified inventory storage
simplified: true // Flag to indicate this is simplified format
};
}
return null;
}
/**
* Main inventory extraction function that tries v2 format first, then falls back to v1.
* Converts v1 format to v2 automatically if found.
* When useSimplifiedInventory is enabled, prioritizes simple "Inventory:" format.
*
* @param {string} statsText - Raw stats text from AI response
* @returns {InventoryV2|null} Parsed inventory in v2 format or null
*/
export function extractInventory(statsText) {
// If simplified inventory mode is enabled, try simplified format first
if (extensionSettings.useSimplifiedInventory) {
const simplifiedData = extractSimplifiedInventory(statsText);
if (simplifiedData) {
return simplifiedData;
}
}
// Try v2 format first
const v2Data = extractInventoryData(statsText);
if (v2Data) {
return v2Data;
}
// Fallback to v1/simplified format and convert to v2
// Fallback to v1 format and convert to v2
const v1Data = extractLegacyInventory(statsText);
if (v1Data) {
// Convert v1 string to v2 format (place in onPerson)
const result = {
return {
version: 2,
onPerson: v1Data,
stored: {},
assets: "None"
};
// If simplified mode, also store in items field
if (extensionSettings.useSimplifiedInventory) {
result.items = v1Data;
result.simplified = true;
}
return result;
}
// No inventory data found
+23 -632
View File
@@ -1,14 +1,11 @@
/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new JSON format
*/
import { extensionSettings, addDebugLog, lastGeneratedData, committedTrackerData } from '../../core/state.js';
import { saveSettings, saveChatData } from '../../core/persistence.js';
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { validateTrackerData } from '../../types/trackerData.js';
import { handleItemRemoved } from '../rendering/skills.js';
/**
* Helper to separate emoji from text in a string
@@ -136,501 +133,19 @@ function debugLog(message, data = null) {
}
}
/**
* Extracts JSON from a code block (handles ```json ... ``` format)
* @param {string} text - Text that may contain JSON code blocks
* @returns {Object|null} Parsed JSON object or null
*/
function extractJSONFromCodeBlock(text) {
if (!text) return null;
// Match ```json ... ``` or ``` ... ``` blocks
const jsonBlockRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
const matches = [...text.matchAll(jsonBlockRegex)];
for (const match of matches) {
const content = match[1].trim();
// Check if content looks like JSON (starts with { or [)
if (content.startsWith('{') || content.startsWith('[')) {
try {
return JSON.parse(content);
} catch (e) {
debugLog('[RPG Parser] JSON parse failed:', e.message);
// Try to fix common JSON issues
const fixed = tryFixJSON(content);
if (fixed) return fixed;
}
}
}
return null;
}
/**
* Attempts to fix common JSON formatting issues
* @param {string} jsonStr - Potentially malformed JSON string
* @returns {Object|null} Fixed JSON object or null
*/
function tryFixJSON(jsonStr) {
try {
// Remove trailing commas
let fixed = jsonStr.replace(/,(\s*[}\]])/g, '$1');
// Fix unquoted keys
fixed = fixed.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
return JSON.parse(fixed);
} catch (e) {
return null;
}
}
/**
* Parses JSON tracker data and applies it to extension settings
* @param {Object} jsonData - Parsed JSON tracker data
* @returns {boolean} Whether parsing was successful
*/
export function parseJSONTrackerData(jsonData) {
debugLog('[RPG Parser] ==================== JSON PARSING ====================');
const validation = validateTrackerData(jsonData);
if (!validation.valid) {
debugLog('[RPG Parser] JSON validation failed:', validation.errors);
return false;
}
const trackerConfig = extensionSettings.trackerConfig;
// Parse stats
if (jsonData.stats) {
debugLog('[RPG Parser] Parsing stats:', Object.keys(jsonData.stats));
const customStats = trackerConfig?.userStats?.customStats || [];
for (const [statName, value] of Object.entries(jsonData.stats)) {
// Find matching stat in config
const statConfig = customStats.find(s =>
s.name.toLowerCase() === statName.toLowerCase()
);
if (statConfig && typeof value === 'number') {
// Store in userStats using the stat id
extensionSettings.userStats[statConfig.id] = Math.max(0, Math.min(100, value));
debugLog(`[RPG Parser] Stat ${statConfig.name}: ${value}%`);
}
}
}
// Parse attributes (RPG attributes like STR, DEX, etc.)
// Only parse if allowAIUpdateAttributes is enabled
const allowAIUpdateAttributes = trackerConfig?.userStats?.allowAIUpdateAttributes !== false; // Default to true for backwards compatibility
if (jsonData.attributes && typeof jsonData.attributes === 'object' && allowAIUpdateAttributes) {
debugLog('[RPG Parser] Parsing attributes:', Object.keys(jsonData.attributes));
const rpgAttributes = trackerConfig?.userStats?.rpgAttributes || [
{ id: 'str', name: 'STR', description: '', enabled: true },
{ id: 'dex', name: 'DEX', description: '', enabled: true },
{ id: 'con', name: 'CON', description: '', enabled: true },
{ id: 'int', name: 'INT', description: '', enabled: true },
{ id: 'wis', name: 'WIS', description: '', enabled: true },
{ id: 'cha', name: 'CHA', description: '', enabled: true }
];
for (const [attrName, value] of Object.entries(jsonData.attributes)) {
// Find matching attribute in config (case-insensitive)
const attrConfig = rpgAttributes.find(a =>
a && a.name && a.name.toLowerCase() === attrName.toLowerCase()
);
if (attrConfig && typeof value === 'number') {
// Store in classicStats using the attribute id
extensionSettings.classicStats[attrConfig.id] = Math.max(1, value);
debugLog(`[RPG Parser] Attribute ${attrConfig.name}: ${value}`);
}
}
} else if (jsonData.attributes && !allowAIUpdateAttributes) {
debugLog('[RPG Parser] Attributes found in response but allowAIUpdateAttributes is disabled - skipping update');
}
// Parse level (only if allowAIUpdateAttributes is enabled)
if (jsonData.level !== undefined && typeof jsonData.level === 'number' && allowAIUpdateAttributes) {
extensionSettings.level = Math.max(1, jsonData.level);
debugLog(`[RPG Parser] Level: ${extensionSettings.level}`);
} else if (jsonData.level !== undefined && !allowAIUpdateAttributes) {
debugLog('[RPG Parser] Level found in response but allowAIUpdateAttributes is disabled - skipping update');
}
// Parse status
if (jsonData.status) {
if (jsonData.status.mood) {
extensionSettings.userStats.mood = jsonData.status.mood;
debugLog('[RPG Parser] Mood:', jsonData.status.mood);
}
if (jsonData.status.fields) {
// Filter to only include configured status fields
const configuredFields = extensionSettings.trackerConfig?.userStats?.statusSection?.customFields || [];
const filteredValues = [];
for (const [key, value] of Object.entries(jsonData.status.fields)) {
// Only include if this field is in the configured list (case-insensitive)
const isConfigured = configuredFields.some(f =>
f.toLowerCase() === key.toLowerCase()
);
if (isConfigured && value && value !== 'None' && value !== 'null') {
filteredValues.push(value);
}
}
extensionSettings.userStats.conditions = filteredValues.length > 0 ? filteredValues.join(', ') : 'None';
debugLog('[RPG Parser] Status fields (filtered):', filteredValues);
}
}
// Parse skills (string)
if (jsonData.skills && typeof jsonData.skills === 'string') {
extensionSettings.userStats.skills = jsonData.skills;
debugLog('[RPG Parser] Skills (string format) extracted from status:', jsonData.skills);
}
// Parse infoBox - normalize values and filter out null
if (jsonData.infoBox) {
const infoBox = {};
// Only copy non-null values
for (const [key, val] of Object.entries(jsonData.infoBox)) {
if (val !== null && val !== undefined && val !== 'null') {
infoBox[key] = val;
}
}
// Normalize recentEvents - LLM sometimes returns string instead of array
if (infoBox.recentEvents && typeof infoBox.recentEvents === 'string') {
infoBox.recentEvents = [infoBox.recentEvents];
} else if (!infoBox.recentEvents || !Array.isArray(infoBox.recentEvents)) {
infoBox.recentEvents = [];
}
// Filter out null/empty events from array
infoBox.recentEvents = infoBox.recentEvents.filter(e => e && e !== 'null');
extensionSettings.infoBoxData = infoBox;
debugLog('[RPG Parser] InfoBox:', Object.keys(infoBox));
// Generate text format for lastGeneratedData.infoBox (needed for other UI parts)
const textLines = [];
if (infoBox.date) textLines.push(`Date: ${infoBox.date}`);
if (infoBox.time) textLines.push(`Time: ${infoBox.time}`);
if (infoBox.weather) textLines.push(`Weather: ${infoBox.weather}`);
if (infoBox.temperature) textLines.push(`Temperature: ${infoBox.temperature}`);
if (infoBox.location) textLines.push(`Location: ${infoBox.location}`);
if (infoBox.recentEvents && infoBox.recentEvents.length > 0) {
textLines.push(`Recent Events: ${infoBox.recentEvents.join(', ')}`);
}
if (textLines.length > 0) {
lastGeneratedData.infoBox = textLines.join('\n');
debugLog('[RPG Parser] Generated text format for infoBox');
}
}
// Parse characters - store for UI rendering AND generate text format for thought bubbles
const parsedCharacters = Array.isArray(jsonData.characters) ? jsonData.characters : [];
extensionSettings.charactersData = parsedCharacters;
debugLog('[RPG Parser] Characters:', parsedCharacters.length);
// Generate text format for lastGeneratedData.characterThoughts (needed for thought bubbles)
const config = extensionSettings.trackerConfig?.presentCharacters;
const thoughtsFieldName = config?.thoughts?.name || 'Thoughts';
const lines = [];
for (const char of parsedCharacters) {
// Character name line
lines.push(`- ${char.name || 'Unknown'}`);
// Details line with emoji and fields
const details = [char.emoji || '😶'];
const charFields = char.fields || {};
for (const [key, value] of Object.entries(charFields)) {
if (value) details.push(`${key}: ${value}`);
}
lines.push(`Details: ${details.join(' | ')}`);
// Relationship line
if (char.relationship) {
lines.push(`Relationship: ${char.relationship}`);
}
// Stats line
const charStats = char.stats || {};
if (Object.keys(charStats).length > 0) {
const statsStr = Object.entries(charStats).map(([k, v]) => `${k}: ${v}%`).join(' | ');
lines.push(`Stats: ${statsStr}`);
}
// Thoughts line
if (char.thoughts) {
lines.push(`${thoughtsFieldName}: ${char.thoughts}`);
}
}
if (lines.length > 0) {
lastGeneratedData.characterThoughts = lines.join('\n');
committedTrackerData.characterThoughts = lines.join('\n');
debugLog('[RPG Parser] Generated text format for characterThoughts');
} else {
// No characters provided in the JSON response - clear any stale state/UI data
lastGeneratedData.characterThoughts = '';
committedTrackerData.characterThoughts = '';
debugLog('[RPG Parser] No characters present; cleared characterThoughts state');
}
// Parse inventory (structured format)
// Handle LLM variations: empty objects {} should become empty arrays []
if (jsonData.inventory) {
const normalizeArray = (val) => {
if (Array.isArray(val)) return val;
if (val && typeof val === 'object' && Object.keys(val).length === 0) return [];
return [];
};
// Get all item names from current inventory BEFORE updating
const getItemNamesFromInventory = (inv) => {
const names = new Set();
if (!inv) return names;
// From onPerson array
if (Array.isArray(inv.onPerson)) {
inv.onPerson.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
// From stored locations
if (inv.stored && typeof inv.stored === 'object') {
Object.values(inv.stored).forEach(items => {
if (Array.isArray(items)) {
items.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
});
}
// From assets
if (Array.isArray(inv.assets)) {
inv.assets.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) names.add(name.toLowerCase());
});
}
return names;
};
const previousItemNames = getItemNamesFromInventory(extensionSettings.inventoryV3);
// Get items - prefer 'items' for simplified mode, 'onPerson' for categorized
// Also handle case where LLM uses either field
const itemsArray = normalizeArray(jsonData.inventory.items);
const onPersonArray = normalizeArray(jsonData.inventory.onPerson);
// For simplified mode: prefer 'items', fallback to 'onPerson'
// For categorized mode: prefer 'onPerson', fallback to 'items'
const simplifiedItems = itemsArray.length > 0 ? itemsArray : onPersonArray;
const onPersonItems = onPersonArray.length > 0 ? onPersonArray : itemsArray;
extensionSettings.inventoryV3 = {
onPerson: onPersonItems,
stored: jsonData.inventory.stored && typeof jsonData.inventory.stored === 'object'
? jsonData.inventory.stored : {},
assets: normalizeArray(jsonData.inventory.assets),
// For simplified mode - use whichever array has items
simplified: extensionSettings.useSimplifiedInventory ? simplifiedItems : (extensionSettings.inventoryV3?.simplified || [])
};
debugLog('[RPG Parser] Inventory parsed - onPerson:', extensionSettings.inventoryV3.onPerson.length,
'simplified:', extensionSettings.inventoryV3.simplified?.length || 0);
// Log first item to verify descriptions are preserved
if (extensionSettings.inventoryV3.onPerson[0]) {
debugLog('[RPG Parser] First onPerson item:', JSON.stringify(extensionSettings.inventoryV3.onPerson[0]));
}
if (extensionSettings.inventoryV3.simplified?.[0]) {
debugLog('[RPG Parser] First simplified item:', JSON.stringify(extensionSettings.inventoryV3.simplified[0]));
}
// Detect removed items and handle skill links
const newItemNames = getItemNamesFromInventory(extensionSettings.inventoryV3);
previousItemNames.forEach(itemName => {
if (!newItemNames.has(itemName)) {
debugLog('[RPG Parser] Item removed by LLM:', itemName);
// Handle item removal - will unlink or delete skills based on settings
handleItemRemoved(itemName);
}
});
// Also update legacy inventory for backwards compatibility
const itemsToString = (items) => {
if (!items || items.length === 0) return 'None';
return items.map(i => typeof i === 'string' ? i : i.name).join(', ');
};
extensionSettings.userStats.inventory = {
version: 2,
onPerson: itemsToString(extensionSettings.inventoryV3.onPerson),
stored: Object.fromEntries(
Object.entries(extensionSettings.inventoryV3.stored).map(([k, v]) => [k, itemsToString(v)])
),
assets: itemsToString(extensionSettings.inventoryV3.assets),
// For simplified mode
items: extensionSettings.useSimplifiedInventory ? itemsToString(extensionSettings.inventoryV3.simplified) : undefined
};
}
// Parse skills (structured format) - handle array/object/string variations
if (jsonData.skills && typeof jsonData.skills === 'object') {
// Normalize skills - each category should be an array
const normalizedSkills = {};
for (const [category, abilities] of Object.entries(jsonData.skills)) {
if (Array.isArray(abilities)) {
normalizedSkills[category] = abilities;
} else if (typeof abilities === 'string') {
// LLM returned string instead of array - split by comma
normalizedSkills[category] = abilities.split(',').map(a => ({ name: a.trim(), description: '' })).filter(a => a.name);
} else {
normalizedSkills[category] = [];
}
}
// Validate grantedBy references - remove if item doesn't exist
// Build set of all valid item names from current inventory (including just-parsed items)
const validItemNames = new Set();
const inv = extensionSettings.inventoryV3;
if (inv) {
const addItems = (items) => {
if (!Array.isArray(items)) return;
items.forEach(item => {
const name = typeof item === 'string' ? item : item?.name;
if (name) validItemNames.add(name.toLowerCase());
});
};
addItems(inv.onPerson);
addItems(inv.assets);
addItems(inv.simplified);
if (inv.stored) {
Object.values(inv.stored).forEach(items => addItems(items));
}
}
// Check each skill's grantedBy and remove if invalid
for (const abilities of Object.values(normalizedSkills)) {
if (!Array.isArray(abilities)) continue;
for (const ability of abilities) {
if (ability && typeof ability === 'object' && ability.grantedBy) {
const grantedByLower = ability.grantedBy.toLowerCase();
if (!validItemNames.has(grantedByLower)) {
debugLog('[RPG Parser] Removing invalid grantedBy:', ability.grantedBy, 'from skill:', ability.name);
delete ability.grantedBy;
}
}
}
}
extensionSettings.skillsV2 = normalizedSkills;
debugLog('[RPG Parser] Skills parsed:', Object.keys(normalizedSkills));
// Update legacy skills data for backwards compatibility
for (const [category, abilities] of Object.entries(normalizedSkills)) {
if (!extensionSettings.skillsData) extensionSettings.skillsData = {};
const names = abilities.map(a => typeof a === 'string' ? a : (a?.name || '')).filter(n => n);
extensionSettings.skillsData[category] = names.join(', ') || 'None';
}
// Validate grantsSkill references on items - remove if skill doesn't exist
// Build set of all valid skill names from just-parsed skills
const validSkillNames = new Set();
for (const abilities of Object.values(normalizedSkills)) {
if (!Array.isArray(abilities)) continue;
abilities.forEach(ability => {
const name = typeof ability === 'string' ? ability : ability?.name;
if (name) validSkillNames.add(name.toLowerCase());
});
}
// Check each item's grantsSkill and remove if invalid
const validateItemsGrantsSkill = (items) => {
if (!Array.isArray(items)) return;
for (const item of items) {
if (item && typeof item === 'object' && item.grantsSkill) {
const grantsSkillLower = item.grantsSkill.toLowerCase();
if (!validSkillNames.has(grantsSkillLower)) {
debugLog('[RPG Parser] Removing invalid grantsSkill:', item.grantsSkill, 'from item:', item.name);
delete item.grantsSkill;
}
}
}
};
if (inv) {
validateItemsGrantsSkill(inv.onPerson);
validateItemsGrantsSkill(inv.assets);
validateItemsGrantsSkill(inv.simplified);
if (inv.stored) {
Object.values(inv.stored).forEach(items => validateItemsGrantsSkill(items));
}
}
}
// Parse quests - handle different formats
if (jsonData.quests) {
// Normalize main quest - could be string, object with name, or null
let mainName = 'None';
let mainDesc = '';
if (jsonData.quests.main) {
if (typeof jsonData.quests.main === 'string') {
mainName = jsonData.quests.main;
} else if (jsonData.quests.main.name) {
mainName = jsonData.quests.main.name;
mainDesc = jsonData.quests.main.description || '';
}
}
// Normalize optional quests - could be array of strings or objects
const optionalQuests = Array.isArray(jsonData.quests.optional) ? jsonData.quests.optional : [];
const optionalNames = optionalQuests.map(q => typeof q === 'string' ? q : (q?.name || '')).filter(n => n);
const optionalDescs = optionalQuests.map(q => typeof q === 'string' ? '' : (q?.description || ''));
extensionSettings.quests = {
main: mainName,
mainDescription: mainDesc,
optional: optionalNames,
optionalDescriptions: optionalDescs
};
// Store structured quests too
extensionSettings.questsV2 = jsonData.quests;
debugLog('[RPG Parser] Quests - main:', mainName);
}
saveSettings();
saveChatData();
debugLog('[RPG Parser] JSON parsing complete');
debugLog('[RPG Parser] =======================================================');
return true;
}
/**
* Main entry point for parsing responses - tries JSON first, falls back to text
* @param {string} responseText - The raw AI response
* @returns {boolean} Whether JSON parsing was successful
*/
export function tryParseJSONResponse(responseText) {
const jsonData = extractJSONFromCodeBlock(responseText);
if (jsonData) {
return parseJSONTrackerData(jsonData);
}
debugLog('[RPG Parser] No valid JSON found, falling back to text parsing');
return false;
}
/**
* Parses the model response to extract the different data sections.
* Extracts tracker data from markdown code blocks in the AI response.
* Handles both separate code blocks and combined code blocks gracefully.
*
* @param {string} responseText - The raw AI response text
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null, skills: string|null}} Parsed tracker data
* @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data
*/
export function parseResponse(responseText) {
const result = {
userStats: null,
infoBox: null,
characterThoughts: null,
skills: null
characterThoughts: null
};
// DEBUG: Log full response for troubleshooting
@@ -695,15 +210,7 @@ export function parseResponse(responseText) {
content.match(/User Stats\s*\n\s*---/i) ||
content.match(/Player Stats\s*\n\s*---/i) ||
// Fallback: look for stat keywords without strict header
(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i)) ||
// Fallback: inventory-only or quests-only blocks (no stats header)
(content.match(/^(On Person:|Inventory:|Main Quests?:|Optional Quests:)/im) &&
!content.match(/Info Box/i) && !content.match(/Present Characters/i) && !content.match(/Skills\s*\n\s*---/i));
// Match Skills section (separate from stats when showSkills is enabled)
const isSkills =
content.match(/Skills\s*\n\s*---/i) &&
!content.match(/Stats\s*\n\s*---/i); // Make sure it's not a combined block
(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i));
// Match Info Box section - flexible patterns
const isInfoBox =
@@ -724,9 +231,6 @@ export function parseResponse(responseText) {
if (isStats && !result.userStats) {
result.userStats = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Stats section');
} else if (isSkills && !result.skills) {
result.skills = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Skills section');
} else if (isInfoBox && !result.infoBox) {
result.infoBox = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Info Box section');
@@ -742,14 +246,12 @@ export function parseResponse(responseText) {
debugLog('[RPG Parser] - Has info keywords?', !!(content.match(/Date:/i) && content.match(/Location:/i)));
debugLog('[RPG Parser] - Has "Present Characters\\n---"?', !!content.match(/Present Characters\s*\n\s*---/i));
debugLog('[RPG Parser] - Has new format ("- Name" + "Details:")?', !!(content.match(/^-\s+\w+/m) && content.match(/Details:/i)));
debugLog('[RPG Parser] - Has "Skills\\n---"?', !!content.match(/Skills\s*\n\s*---/i));
}
}
}
debugLog('[RPG Parser] ==================== PARSE RESULTS ====================');
debugLog('[RPG Parser] Found Stats:', !!result.userStats);
debugLog('[RPG Parser] Found Skills:', !!result.skills);
debugLog('[RPG Parser] Found Info Box:', !!result.infoBox);
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
@@ -856,13 +358,24 @@ export function parseUserStats(statsText) {
}
}
// Extract inventory - extractInventory() handles v2 format and falls back to v1 if needed
const inventoryData = extractInventory(statsText);
if (inventoryData) {
extensionSettings.userStats.inventory = inventoryData;
debugLog('[RPG Parser] Inventory extracted:', inventoryData);
// Extract inventory - use v2 parser if feature flag enabled, otherwise fallback to v1
if (FEATURE_FLAGS.useNewInventory) {
const inventoryData = extractInventory(statsText);
if (inventoryData) {
extensionSettings.userStats.inventory = inventoryData;
debugLog('[RPG Parser] Inventory v2 extracted:', inventoryData);
} else {
debugLog('[RPG Parser] Inventory v2 extraction failed');
}
} else {
debugLog('[RPG Parser] Inventory extraction failed');
// Legacy v1 parsing for backward compatibility
const inventoryMatch = statsText.match(/Inventory:\s*(.+)/i);
if (inventoryMatch) {
extensionSettings.userStats.inventory = inventoryMatch[1].trim();
debugLog('[RPG Parser] Inventory v1 extracted:', inventoryMatch[1].trim());
} else {
debugLog('[RPG Parser] Inventory v1 not found');
}
}
// Extract quests
@@ -895,7 +408,7 @@ export function parseUserStats(statsText) {
arousal: extensionSettings.userStats.arousal,
mood: extensionSettings.userStats.mood,
conditions: extensionSettings.userStats.conditions,
inventory: extensionSettings.userStats.inventory
inventory: FEATURE_FLAGS.useNewInventory ? 'v2 object' : extensionSettings.userStats.inventory
});
saveSettings();
@@ -909,128 +422,6 @@ export function parseUserStats(statsText) {
}
}
/**
* Parses skills from the separate skills code block.
* Used when showSkills is enabled (skills appear in their own section).
* Format expected:
* Skills
* ---
* Combat: Sword Fighting, Shield Block, Parry
* Stealth: Lockpicking (via Lockpicks), Sneaking, Pickpocketing
* Magic: None
*
* Each skill category contains a comma-separated list of abilities.
* Abilities can be linked to items using "(via ItemName)" format.
*
* @param {string} skillsText - The raw skills text from AI response
*/
export function parseSkills(skillsText) {
debugLog('[RPG Parser] ==================== PARSING SKILLS ====================');
debugLog('[RPG Parser] Skills text length:', skillsText?.length + ' chars');
if (!skillsText || typeof skillsText !== 'string') {
debugLog('[RPG Parser] No skills text to parse');
return;
}
try {
// Initialize data structures if needed
if (!extensionSettings.skillsData) {
extensionSettings.skillsData = {};
}
if (!extensionSettings.skillAbilityLinks) {
extensionSettings.skillAbilityLinks = {};
}
// Migration function handles string array → object array conversion on load
const rawCategories = extensionSettings.trackerConfig?.userStats?.skillsSection?.customFields || [];
const configuredCategories = rawCategories
.filter(cat => cat.enabled !== false)
.map(cat => cat.name);
const lines = skillsText.split('\n');
const newSkillAbilityLinks = {};
for (const line of lines) {
// Skip header lines and notes
if (line.match(/^Skills\s*$/i) || line.match(/^---/) || !line.trim() || line.match(/^\(Note:/i)) {
continue;
}
// Parse skill category line: "CategoryName: ability1, ability2 (via Item), ability3"
const categoryMatch = line.match(/^(.+?):\s*(.*)$/);
if (categoryMatch) {
const categoryName = categoryMatch[1].trim();
const abilitiesText = categoryMatch[2].trim();
// Check if this is a configured category (case-insensitive match)
const matchedCategory = configuredCategories.find(c =>
c.toLowerCase() === categoryName.toLowerCase()
);
if (!matchedCategory) {
debugLog(`[RPG Parser] Skipping unknown skill category: ${categoryName}`);
continue;
}
// Parse abilities (comma-separated)
if (!abilitiesText || abilitiesText.toLowerCase() === 'none') {
extensionSettings.skillsData[matchedCategory] = 'None';
debugLog(`[RPG Parser] Skill category ${matchedCategory}: None`);
continue;
}
// Split by comma and parse each ability
const abilities = [];
const rawAbilities = abilitiesText.split(',').map(a => a.trim()).filter(a => a);
for (const rawAbility of rawAbilities) {
// Check for "(ItemName)" pattern - ability granted by item
// Supports both "AbilityName (ItemName)" and legacy "AbilityName (via ItemName)"
const itemMatch = rawAbility.match(/^(.+?)\s*\((?:via\s+)?(.+?)\)$/i);
if (itemMatch) {
const abilityName = itemMatch[1].trim();
const itemName = itemMatch[2].trim();
abilities.push(abilityName);
// Store the link
if (extensionSettings.enableItemSkillLinks) {
const linkKey = `${matchedCategory}::${abilityName}`;
newSkillAbilityLinks[linkKey] = itemName;
debugLog(`[RPG Parser] Linked: ${abilityName} <- ${itemName}`);
}
} else {
abilities.push(rawAbility);
}
}
// Store abilities for this category
const abilitiesString = abilities.length > 0 ? abilities.join(', ') : 'None';
extensionSettings.skillsData[matchedCategory] = abilitiesString;
debugLog(`[RPG Parser] Skill category ${matchedCategory}: ${abilitiesString}`);
}
}
// Update skill-ability links if item linking is enabled
if (extensionSettings.enableItemSkillLinks && Object.keys(newSkillAbilityLinks).length > 0) {
// Merge new links with existing ones
extensionSettings.skillAbilityLinks = {
...extensionSettings.skillAbilityLinks,
...newSkillAbilityLinks
};
debugLog('[RPG Parser] Skill-ability links updated:', Object.keys(newSkillAbilityLinks).length + ' new links');
}
saveSettings();
debugLog('[RPG Parser] Skills saved successfully');
debugLog('[RPG Parser] =======================================================');
} catch (error) {
console.error('[RPG Companion] Error parsing skills:', error);
debugLog('[RPG Parser] ERROR:', error.message);
}
}
/**
* Helper: Extract code blocks from text
* @param {string} text - Text containing markdown code blocks
File diff suppressed because it is too large Load Diff