Files
rpg-companion-sillytavern/src/systems/generation/parser.js
T
Spicy_Marinara 6fc35e50a1 Refactor inventory lock logic to use item names
Updated inventory lock management and rendering to match items by name instead of index, improving reliability and consistency. Also adjusted quest rendering and parsing to handle locked quest objects with a value property.
2026-01-23 09:17:40 +01:00

860 lines
40 KiB
JavaScript

/**
* Parser Module
* Handles parsing of AI responses to extract tracker data
* Supports both legacy text format and new v3 JSON format
*/
import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state.js';
import { saveSettings } from '../../core/persistence.js';
import { extractInventory } from './inventoryParser.js';
import { repairJSON } from '../../utils/jsonRepair.js';
/**
* Helper to separate emoji from text in a string
* Handles cases where there's no comma or space after emoji
* @param {string} str - String potentially containing emoji followed by text
* @returns {{emoji: string, text: string}} Separated emoji and text
*/
function separateEmojiFromText(str) {
if (!str) return { emoji: '', text: '' };
str = str.trim();
// Regex to match emoji at the start (handles most emoji including compound ones)
// This matches emoji sequences including skin tones, gender modifiers, etc.
const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u;
const emojiMatch = str.match(emojiRegex);
if (emojiMatch) {
const emoji = emojiMatch[0];
let text = str.substring(emoji.length).trim();
// Remove leading comma or space if present
text = text.replace(/^[,\s]+/, '');
return { emoji, text };
}
// No emoji found - check if there's a comma separator anyway
const commaParts = str.split(',');
if (commaParts.length >= 2) {
return {
emoji: commaParts[0].trim(),
text: commaParts.slice(1).join(',').trim()
};
}
// No clear separation - return original as text
return { emoji: '', text: str };
}
/**
* Helper to strip enclosing brackets from text and remove placeholder brackets
* Removes [], {}, and () from the entire text if it's wrapped, plus removes
* placeholder content like [Location], [Mood Emoji], etc.
* @param {string} text - Text that may contain brackets
* @returns {string} Text with brackets and placeholders removed
*/
function stripBrackets(text) {
if (!text) return text;
// Remove leading and trailing whitespace first
text = text.trim();
// Check if the entire text is wrapped in brackets and remove them
// This handles cases where models wrap entire sections in brackets
while (
(text.startsWith('[') && text.endsWith(']')) ||
(text.startsWith('{') && text.endsWith('}')) ||
(text.startsWith('(') && text.endsWith(')'))
) {
text = text.substring(1, text.length - 1).trim();
}
// Remove placeholder text patterns like [Location], [Mood Emoji], [Name], etc.
// Pattern matches: [anything with letters/spaces inside]
// This preserves actual content while removing template placeholders
const placeholderPattern = /\[([A-Za-z\s\/]+)\]/g;
// Check if a bracketed text looks like a placeholder vs real content
const isPlaceholder = (match, content) => {
// Common placeholder words to detect
const placeholderKeywords = [
'location', 'mood', 'emoji', 'name', 'description', 'placeholder',
'time', 'date', 'weather', 'temperature', 'action', 'appearance',
'skill', 'quest', 'item', 'character', 'field', 'value', 'details',
'relationship', 'thoughts', 'stat', 'status', 'lover', 'friend',
'enemy', 'neutral', 'weekday', 'month', 'year', 'forecast'
];
const lowerContent = content.toLowerCase().trim();
// If it contains common placeholder keywords, it's likely a placeholder
if (placeholderKeywords.some(keyword => lowerContent.includes(keyword))) {
return true;
}
// If it's a short generic phrase (1-3 words) with only letters/spaces, might be placeholder
const wordCount = content.trim().split(/\s+/).length;
if (wordCount <= 3 && /^[A-Za-z\s\/]+$/.test(content)) {
return true;
}
return false;
};
// Replace placeholders with empty string, keep real content
text = text.replace(placeholderPattern, (match, content) => {
if (isPlaceholder(match, content)) {
return ''; // Remove placeholder
}
return match; // Keep real bracketed content
});
// Clean up any resulting empty labels (e.g., "Status: " with nothing after)
text = text.replace(/^([A-Za-z\s]+):\s*$/gm, ''); // Remove lines that are just "Label: " with nothing
text = text.replace(/^([A-Za-z\s]+):\s*,/gm, '$1:'); // Fix "Label: ," patterns
text = text.replace(/:\s*\|/g, ':'); // Fix ": |" patterns
text = text.replace(/\|\s*\|/g, '|'); // Fix "| |" patterns (double pipes from removed content)
text = text.replace(/\|\s*$/gm, ''); // Remove trailing pipes at end of lines
// Clean up multiple spaces and empty lines
text = text.replace(/\s{2,}/g, ' '); // Multiple spaces to single space
text = text.replace(/^\s*\n/gm, ''); // Remove empty lines
return text.trim();
}
/**
* Helper to log to both console and debug logs array
*/
function debugLog(message, data = null) {
// console.log(message, data || '');
if (extensionSettings.debugMode) {
addDebugLog(message, data);
}
}
/**
* 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}} Parsed tracker data
*/
export function parseResponse(responseText) {
const result = {
userStats: null,
infoBox: null,
characterThoughts: null
};
// DEBUG: Log full response for troubleshooting
debugLog('[RPG Parser] ==================== PARSING AI RESPONSE ====================');
debugLog('[RPG Parser] Response length:', responseText.length + ' chars');
debugLog('[RPG Parser] First 500 chars:', responseText.substring(0, 500));
// Remove content inside thinking tags first (model's internal reasoning)
// This prevents parsing code blocks from the model's thinking process
let cleanedResponse = responseText.replace(/<think>[\s\S]*?<\/think>/gi, '');
cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars');
// Remove "FORMAT:" markers that the model might accidentally output
cleanedResponse = cleanedResponse.replace(/FORMAT:\s*/gi, '');
debugLog('[RPG Parser] Removed FORMAT: markers, new length:', cleanedResponse.length + ' chars');
// First, try to extract raw JSON objects (v3 format)
// Note: Prompts now instruct models to use ```json``` code blocks, but we extract
// from any JSON found using brace-matching for maximum compatibility
// Use brace-matching to find complete JSON objects
const extractedObjects = [];
let i = 0;
while (i < cleanedResponse.length) {
if (cleanedResponse[i] === '{') {
// Found opening brace, find matching closing brace
let depth = 1;
let j = i + 1;
let inString = false;
let escapeNext = false;
while (j < cleanedResponse.length && depth > 0) {
const char = cleanedResponse[j];
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === '{') depth++;
else if (char === '}') depth--;
}
j++;
}
if (depth === 0) {
// Found complete JSON object
const jsonContent = cleanedResponse.substring(i, j).trim();
if (jsonContent) {
extractedObjects.push(jsonContent);
}
i = j;
} else {
i++;
}
} else {
i++;
}
}
if (extractedObjects.length > 0) {
// console.log(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
debugLog(`[RPG Parser] ✓ Found ${extractedObjects.length} raw JSON objects (v3 format)`);
// First, try to parse as unified JSON structure (new v3.1 format)
if (extractedObjects.length === 1) {
const parsed = repairJSON(extractedObjects[0]);
if (parsed && (parsed.userStats || parsed.infoBox || parsed.characters)) {
// console.log('[RPG Parser] ✓ Detected unified JSON structure (v3.1 format)');
if (parsed.userStats) {
result.userStats = JSON.stringify(parsed.userStats);
// console.log('[RPG Parser] ✓ Extracted userStats from unified structure');
}
if (parsed.infoBox) {
result.infoBox = JSON.stringify(parsed.infoBox);
// console.log('[RPG Parser] ✓ Extracted infoBox from unified structure');
}
if (parsed.characters) {
result.characterThoughts = JSON.stringify(parsed.characters);
// console.log('[RPG Parser] ✓ Extracted characters from unified structure');
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning unified JSON parse results');
debugLog('[RPG Parser] Returning unified JSON parse results');
return result;
}
}
}
// Fall back to parsing multiple separate JSON objects (legacy v3.0 format)
for (let idx = 0; idx < extractedObjects.length; idx++) {
const jsonContent = extractedObjects[idx];
// console.log(`[RPG Parser] Parsing object ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
// console.log(`[RPG Parser] Full object ${idx + 1} length:`, jsonContent.length);
const parsed = repairJSON(jsonContent);
if (parsed) {
// console.log(`[RPG Parser] Object ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Check if object is wrapped (e.g., {"userStats": {...}})
// Unwrap single-key objects that match our tracker types
let unwrapped = parsed;
if (Object.keys(parsed).length === 1) {
const key = Object.keys(parsed)[0];
if (key === 'userStats' || key === 'infoBox' || key === 'characters') {
unwrapped = parsed[key];
// console.log(`[RPG Parser] ✓ Unwrapped ${key} object`);
}
}
// Detect tracker type by checking for top-level fields
if (unwrapped.stats || unwrapped.status || unwrapped.skills || unwrapped.inventory || unwrapped.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted raw JSON User Stats');
} else if (unwrapped.date || unwrapped.location || unwrapped.weather || unwrapped.temperature || unwrapped.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted raw JSON Info Box');
} else if (unwrapped.characters || Array.isArray(unwrapped)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted raw JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize object with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse raw JSON object', idx + 1);
}
}
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning raw JSON parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning raw JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', extractedObjects.length, 'objects');
}
}
// Check for JSON code blocks (legacy v3 format with ```json fences)
// Look for ```json code blocks which indicate JSON format
const jsonBlockRegex = /```json\s*\n([\s\S]*?)```/g;
const jsonMatches = [...cleanedResponse.matchAll(jsonBlockRegex)];
if (jsonMatches.length > 0) {
// console.log('[RPG Parser] ✓ Found', jsonMatches.length, 'JSON code blocks (v3 format with fences)');
debugLog('[RPG Parser] ✓ Found JSON code blocks (v3 format), parsing as JSON');
for (let idx = 0; idx < jsonMatches.length; idx++) {
const match = jsonMatches[idx];
const jsonContent = match[1].trim();
if (!jsonContent) continue;
// console.log(`[RPG Parser] Parsing JSON block ${idx + 1}:`, jsonContent.substring(0, 100) + '...');
const parsed = repairJSON(jsonContent);
if (parsed) {
// console.log(`[RPG Parser] JSON block ${idx + 1} parsed successfully, keys:`, Object.keys(parsed));
// Detect tracker type by checking for top-level fields
if (parsed.stats || parsed.status || parsed.skills || parsed.inventory || parsed.quests) {
result.userStats = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to User Stats');
debugLog('[RPG Parser] ✓ Extracted JSON User Stats');
} else if (parsed.date || parsed.location || parsed.weather || parsed.temperature || parsed.time) {
result.infoBox = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Info Box');
debugLog('[RPG Parser] ✓ Extracted JSON Info Box');
} else if (parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
// console.log('[RPG Parser] ✓ Assigned to Characters');
debugLog('[RPG Parser] ✓ Extracted JSON Characters');
} else {
console.warn('[RPG Parser] ⚠️ Could not categorize JSON block with keys:', Object.keys(parsed));
}
} else {
console.error('[RPG Parser] ✗ Failed to parse JSON code block', idx + 1);
debugLog('[RPG Parser] ✗ Failed to parse JSON block, will try text fallback');
}
}
// If we found at least one valid JSON block, return the result
// Mixed formats (some JSON, some text) will still work
if (result.userStats || result.infoBox || result.characterThoughts) {
// console.log('[RPG Parser] ✓ Returning JSON code block parse results:', {
// hasUserStats: !!result.userStats,
// hasInfoBox: !!result.infoBox,
// hasCharacters: !!result.characterThoughts
// });
debugLog('[RPG Parser] Returning JSON parse results');
return result;
} else {
console.warn('[RPG Parser] ⚠️ No tracker data extracted from', jsonMatches.length, 'JSON blocks');
}
}
// Check if response uses XML <trackers> tags (hybrid format)
const xmlMatch = cleanedResponse.match(/<trackers>([\s\S]*?)<\/trackers>/i);
if (xmlMatch) {
debugLog('[RPG Parser] ✓ Found XML <trackers> tags, using XML parser');
const trackersContent = xmlMatch[1].trim();
// Try to parse JSON blocks within XML first
const xmlJsonMatches = [...trackersContent.matchAll(jsonBlockRegex)];
if (xmlJsonMatches.length > 0) {
debugLog('[RPG Parser] Found JSON blocks within XML tags');
for (const match of xmlJsonMatches) {
const jsonContent = match[1].trim();
if (!jsonContent) continue;
const parsed = repairJSON(jsonContent);
if (parsed) {
if (parsed.type === 'userStats' || parsed.stats) {
result.userStats = jsonContent;
} else if (parsed.type === 'infoBox' || parsed.date || parsed.location) {
result.infoBox = jsonContent;
} else if (parsed.type === 'characters' || parsed.characters || Array.isArray(parsed)) {
result.characterThoughts = jsonContent;
}
}
}
} else {
// Fallback to text extraction from XML content (legacy v2 text format)
const statsMatch = trackersContent.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from XML (text format)');
}
const infoBoxMatch = trackersContent.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (infoBoxMatch) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from XML (text format)');
}
const charactersMatch = trackersContent.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from XML (text format)');
}
}
debugLog('[RPG Parser] Parsed from XML:', result);
return result;
}
// Fallback to markdown code block parsing (old text format or mixed format)
debugLog('[RPG Parser] No XML tags found, using code block parser');
// Extract code blocks
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...cleanedResponse.matchAll(codeBlockRegex)];
debugLog('[RPG Parser] Found', matches.length + ' code blocks');
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
const content = match[1].trim();
debugLog(`[RPG Parser] --- Code Block ${i + 1} ---`);
debugLog('[RPG Parser] First 300 chars:', content.substring(0, 300));
// Check if this is a combined code block with multiple sections
const hasMultipleSections = (
content.match(/Stats\s*\n\s*---/i) &&
(content.match(/Info Box\s*\n\s*---/i) || content.match(/Present Characters\s*\n\s*---/i))
);
if (hasMultipleSections) {
// Split the combined code block into individual sections
debugLog('[RPG Parser] ✓ Found combined code block with multiple sections');
// Extract User Stats section
const statsMatch = content.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i);
if (statsMatch && !result.userStats) {
result.userStats = stripBrackets(statsMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Stats from combined block');
}
// Extract Info Box section
const infoBoxMatch = content.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i);
if (infoBoxMatch && !result.infoBox) {
result.infoBox = stripBrackets(infoBoxMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Info Box from combined block');
}
// Extract Present Characters section
const charactersMatch = content.match(/Present Characters\s*\n\s*---[\s\S]*$/i);
if (charactersMatch && !result.characterThoughts) {
result.characterThoughts = stripBrackets(charactersMatch[0].trim());
debugLog('[RPG Parser] ✓ Extracted Present Characters from combined block');
}
} else {
// Handle separate code blocks with flexible pattern matching
// Match Stats section - flexible patterns
const isStats =
content.match(/Stats\s*\n\s*---/i) ||
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));
// Match Info Box section - flexible patterns
const isInfoBox =
content.match(/Info Box\s*\n\s*---/i) ||
content.match(/Scene Info\s*\n\s*---/i) ||
content.match(/Information\s*\n\s*---/i) ||
// Fallback: look for info box keywords
(content.match(/Date:/i) && content.match(/Location:/i) && content.match(/Time:/i));
// Match Present Characters section - flexible patterns
const isCharacters =
content.match(/Present Characters\s*\n\s*---/i) ||
content.match(/Characters\s*\n\s*---/i) ||
content.match(/Character Thoughts\s*\n\s*---/i) ||
// Fallback: look for new multi-line format patterns
(content.match(/^-\s+\w+/m) && content.match(/Details:/i));
if (isStats && !result.userStats) {
result.userStats = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Stats section');
} else if (isInfoBox && !result.infoBox) {
result.infoBox = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Info Box section');
} else if (isCharacters && !result.characterThoughts) {
result.characterThoughts = stripBrackets(content);
debugLog('[RPG Parser] ✓ Matched: Present Characters section');
debugLog('[RPG Parser] Full content:', content);
} else {
debugLog('[RPG Parser] ✗ No match - checking patterns:');
debugLog('[RPG Parser] - Has "Stats\\n---"?', !!content.match(/Stats\s*\n\s*---/i));
debugLog('[RPG Parser] - Has stat keywords?', !!(content.match(/Health:\s*\d+%/i) && content.match(/Energy:\s*\d+%/i)));
debugLog('[RPG Parser] - Has "Info Box\\n---"?', !!content.match(/Info Box\s*\n\s*---/i));
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] ==================== PARSE RESULTS ====================');
debugLog('[RPG Parser] Found Stats:', !!result.userStats);
debugLog('[RPG Parser] Found Info Box:', !!result.infoBox);
debugLog('[RPG Parser] Found Characters:', !!result.characterThoughts);
debugLog('[RPG Parser] =======================================================');
// Check if we found at least one section - if not, mark as parsing failure
if (!result.userStats && !result.infoBox && !result.characterThoughts) {
result.parsingFailed = true;
console.error('[RPG Parser] ❌ No tracker data found in response - parsing failed');
}
return result;
} // End parseResponse
/**
* Parses user stats from the text and updates the extensionSettings.
* Extracts percentages, mood, conditions, and inventory from the stats text.
*
* @param {string} statsText - The raw stats text from AI response
*/
export function parseUserStats(statsText) {
debugLog('[RPG Parser] ==================== PARSING USER STATS ====================');
debugLog('[RPG Parser] Stats text length:', statsText.length + ' chars');
debugLog('[RPG Parser] Stats text preview:', statsText.substring(0, 200));
try {
// Check if this is v3 JSON format - try to parse it first
let statsData = null;
const trimmed = statsText.trim();
if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
statsData = repairJSON(statsText);
if (statsData) {
debugLog('[RPG Parser] ✓ Parsed as v3 JSON format');
// Extract stats from v3 JSON structure
if (statsData.stats && Array.isArray(statsData.stats)) {
// console.log('[RPG Parser] ✓ Extracting stats array, count:', statsData.stats.length);
statsData.stats.forEach(stat => {
if (stat.id && typeof stat.value !== 'undefined') {
extensionSettings.userStats[stat.id] = stat.value;
// console.log(`[RPG Parser] ✓ Set ${stat.id} = ${stat.value}`);
}
});
}
// Extract status
if (statsData.status) {
// console.log('[RPG Parser] ✓ Extracting status:', statsData.status);
if (statsData.status.mood) {
extensionSettings.userStats.mood = statsData.status.mood;
// console.log('[RPG Parser] ✓ Set mood =', statsData.status.mood);
}
// Extract all custom status fields
const trackerConfig = extensionSettings.trackerConfig;
const customFields = trackerConfig?.userStats?.statusSection?.customFields || [];
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
if (statsData.status[fieldKey]) {
extensionSettings.userStats[fieldKey] = statsData.status[fieldKey];
// console.log(`[RPG Parser] ✓ Set ${fieldKey} =`, statsData.status[fieldKey]);
}
}
}
// Extract inventory (convert v3 array format to v2 string format)
if (statsData.inventory) {
const inv = statsData.inventory;
// Convert arrays of {name, quantity} objects to comma-separated strings
const convertItems = (items) => {
if (!items || !Array.isArray(items)) return '';
return items.map(item => {
if (typeof item === 'object' && item.name) {
// Include quantity if > 1
return item.quantity && item.quantity > 1
? `${item.quantity}x ${item.name}`
: item.name;
}
return String(item);
}).join(', ');
};
// Convert stored object {location: [items]} to {location: "item1, item2"}
const convertStoredInventory = (stored) => {
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {};
const result = {};
for (const [location, items] of Object.entries(stored)) {
if (Array.isArray(items)) {
result[location] = convertItems(items);
} else if (typeof items === 'string') {
result[location] = items;
} else {
result[location] = '';
}
}
return result;
};
extensionSettings.userStats.inventory = {
onPerson: convertItems(inv.onPerson),
clothing: convertItems(inv.clothing),
stored: convertStoredInventory(inv.stored),
assets: convertItems(inv.assets)
};
// console.log('[RPG Parser] ✓ Converted v3 inventory:', extensionSettings.userStats.inventory);
}
// Extract quests (convert v3 object format to v2 string format)
if (statsData.quests) {
// Convert quest objects to strings
const convertQuest = (quest) => {
if (!quest) return '';
if (typeof quest === 'string') return quest;
if (typeof quest === 'object') {
// Check for locked format: {value, locked}
if (quest.value !== undefined) return String(quest.value);
// v3 format: {title, description, status}
return quest.title || quest.description || JSON.stringify(quest);
}
return String(quest);
};
extensionSettings.quests = {
main: convertQuest(statsData.quests.main),
optional: Array.isArray(statsData.quests.optional)
? statsData.quests.optional.map(convertQuest)
: []
};
// console.log('[RPG Parser] ✓ Converted v3 quests:', extensionSettings.quests);
}
// Extract skills if present (store as object, not JSON string)
if (statsData.skills && Array.isArray(statsData.skills)) {
extensionSettings.userStats.skills = statsData.skills;
// console.log('[RPG Parser] ✓ Set skills:', extensionSettings.userStats.skills);
}
debugLog('[RPG Parser] ✓ Successfully extracted v3 JSON data');
saveSettings();
return; // Done processing v3 format
}
}
// Fall back to v2 text format parsing if JSON parsing failed
debugLog('[RPG Parser] Falling back to v2 text format parsing');
// Get custom stat configuration
const trackerConfig = extensionSettings.trackerConfig;
const customStats = trackerConfig?.userStats?.customStats || [];
const enabledStats = customStats.filter(s => s && s.enabled && s.name && s.id);
debugLog('[RPG Parser] Enabled custom stats:', enabledStats.map(s => s.name));
// Dynamically parse custom stats
for (const stat of enabledStats) {
const statRegex = new RegExp(`${stat.name}:\\s*(\\d+)%`, 'i');
const match = statsText.match(statRegex);
if (match) {
// Store using the stat ID (lowercase normalized name)
const statId = stat.id;
extensionSettings.userStats[statId] = parseInt(match[1]);
debugLog(`[RPG Parser] Parsed ${stat.name}:`, match[1]);
} else {
debugLog(`[RPG Parser] ${stat.name} NOT FOUND`);
}
}
// Parse RPG attributes if enabled
if (trackerConfig?.userStats?.showRPGAttributes) {
const strMatch = statsText.match(/STR:\s*(\d+)/i);
const dexMatch = statsText.match(/DEX:\s*(\d+)/i);
const conMatch = statsText.match(/CON:\s*(\d+)/i);
const intMatch = statsText.match(/INT:\s*(\d+)/i);
const wisMatch = statsText.match(/WIS:\s*(\d+)/i);
const chaMatch = statsText.match(/CHA:\s*(\d+)/i);
const lvlMatch = statsText.match(/LVL:\s*(\d+)/i);
if (strMatch) extensionSettings.classicStats.str = parseInt(strMatch[1]);
if (dexMatch) extensionSettings.classicStats.dex = parseInt(dexMatch[1]);
if (conMatch) extensionSettings.classicStats.con = parseInt(conMatch[1]);
if (intMatch) extensionSettings.classicStats.int = parseInt(intMatch[1]);
if (wisMatch) extensionSettings.classicStats.wis = parseInt(wisMatch[1]);
if (chaMatch) extensionSettings.classicStats.cha = parseInt(chaMatch[1]);
if (lvlMatch) extensionSettings.level = parseInt(lvlMatch[1]);
debugLog('[RPG Parser] RPG Attributes parsed');
}
// Match status section if enabled
const statusConfig = trackerConfig?.userStats?.statusSection;
if (statusConfig?.enabled) {
let moodMatch = null;
const customFields = statusConfig.customFields || [];
// Try Status: format
const statusMatch = statsText.match(/Status:\s*(.+)/i);
if (statusMatch) {
const statusContent = statusMatch[1].trim();
// Extract mood emoji if enabled
if (statusConfig.showMoodEmoji) {
const { emoji, text } = separateEmojiFromText(statusContent);
if (emoji) {
extensionSettings.userStats.mood = emoji;
// Remaining text contains custom status fields
if (text && customFields.length > 0) {
// For first custom field, use the remaining text
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = text;
}
moodMatch = true;
}
} else {
// No mood emoji, whole status goes to first custom field
if (customFields.length > 0) {
const firstFieldKey = customFields[0].toLowerCase();
extensionSettings.userStats[firstFieldKey] = statusContent;
}
moodMatch = true;
}
}
// Try to extract individual custom status fields by name
for (const fieldName of customFields) {
const fieldKey = fieldName.toLowerCase();
const fieldRegex = new RegExp(`${fieldName}:\\s*(.+?)(?:,|$)`, 'i');
const fieldMatch = statsText.match(fieldRegex);
if (fieldMatch) {
extensionSettings.userStats[fieldKey] = fieldMatch[1].trim();
moodMatch = true;
}
}
debugLog('[RPG Parser] Status match:', {
found: !!moodMatch,
mood: extensionSettings.userStats.mood,
customFields: customFields.map(f => ({
name: f,
value: extensionSettings.userStats[f.toLowerCase()]
}))
});
}
// Parse skills section if enabled
const skillsConfig = trackerConfig?.userStats?.skillsSection;
if (skillsConfig?.enabled) {
const skillsMatch = statsText.match(/Skills:\s*(.+)/i);
if (skillsMatch) {
extensionSettings.userStats.skills = skillsMatch[1].trim();
debugLog('[RPG Parser] Skills extracted:', skillsMatch[1].trim());
}
}
// 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 {
// 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
const mainQuestMatch = statsText.match(/Main Quests?:\s*(.+)/i);
if (mainQuestMatch) {
extensionSettings.quests.main = mainQuestMatch[1].trim();
debugLog('[RPG Parser] Main quests extracted:', mainQuestMatch[1].trim());
}
const optionalQuestsMatch = statsText.match(/Optional Quests:\s*(.+)/i);
if (optionalQuestsMatch) {
const questsText = optionalQuestsMatch[1].trim();
if (questsText && questsText !== 'None') {
// Split by comma and clean up
extensionSettings.quests.optional = questsText
.split(',')
.map(q => q.trim())
.filter(q => q && q !== 'None');
} else {
extensionSettings.quests.optional = [];
}
debugLog('[RPG Parser] Optional quests extracted:', extensionSettings.quests.optional);
}
debugLog('[RPG Parser] Final userStats after parsing:', {
health: extensionSettings.userStats.health,
satiety: extensionSettings.userStats.satiety,
energy: extensionSettings.userStats.energy,
hygiene: extensionSettings.userStats.hygiene,
arousal: extensionSettings.userStats.arousal,
mood: extensionSettings.userStats.mood,
conditions: extensionSettings.userStats.conditions,
inventory: FEATURE_FLAGS.useNewInventory ? 'v2 object' : extensionSettings.userStats.inventory
});
saveSettings();
debugLog('[RPG Parser] Settings saved successfully');
debugLog('[RPG Parser] =======================================================');
} catch (error) {
console.error('[RPG Companion] Error parsing user stats:', error);
console.error('[RPG Companion] Stack trace:', error.stack);
debugLog('[RPG Parser] ERROR:', error.message);
debugLog('[RPG Parser] Stack:', error.stack);
}
}
/**
* Helper: Extract code blocks from text
* @param {string} text - Text containing markdown code blocks
* @returns {Array<string>} Array of code block contents
*/
export function extractCodeBlocks(text) {
const codeBlockRegex = /```([^`]+)```/g;
const matches = [...text.matchAll(codeBlockRegex)];
return matches.map(match => match[1].trim());
}
/**
* Helper: Parse stats section from code block content
* @param {string} content - Code block content
* @returns {boolean} True if this is a stats section
*/
export function isStatsSection(content) {
return content.match(/Stats\s*\n\s*---/i) !== null;
}
/**
* Helper: Parse info box section from code block content
* @param {string} content - Code block content
* @returns {boolean} True if this is an info box section
*/
export function isInfoBoxSection(content) {
return content.match(/Info Box\s*\n\s*---/i) !== null;
}
/**
* Helper: Parse character thoughts section from code block content
* @param {string} content - Code block content
* @returns {boolean} True if this is a character thoughts section
*/
export function isCharacterThoughtsSection(content) {
return content.match(/Present Characters\s*\n\s*---/i) !== null || content.includes(" | ");
}