Files
rpg-companion-sillytavern/src/systems/rendering/userStats.js
T
ARIA f8894a9f3c Fix: Scope event handlers to container in userStats.js
Fixes critical bug identified by Zephyre_Review: event handlers used
global jQuery selectors instead of container-scoped selectors, causing
potential event handler leaks and scope mismatches.
2026-07-12 13:14:19 +02:00

575 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* User Stats Rendering Module
* Handles rendering of the user stats panel with progress bars and classic RPG stats
*/
import { getContext } from '../../../../../../extensions.js';
import { user_avatar } from '../../../../../../../script.js';
import { extensionSettings, lastGeneratedData, committedTrackerData, $userStatsContainer, FALLBACK_AVATAR_DATA_URI } from '../../core/state.js';
import { i18n } from '../../core/i18n.js';
import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { buildInventorySummary } from '../generation/promptBuilder.js';
import { isItemLocked, setItemLock } from '../generation/lockManager.js';
import { updateFabWidgets } from '../ui/mobile.js';
import { getStatBarColors } from '../ui/theme.js';
import { getEquipmentBonuses } from '../interaction/equipmentActions.js';
import { updateIfChanged } from './renderUtils.js';
/**
* Extracts the base name (before parentheses) and converts to snake_case for use as JSON key.
* Example: "Conditions (up to 5 traits)" -> "conditions"
* @param {string} name - Field name, possibly with parenthetical description
* @returns {string} snake_case key from the base name only
*/
function toFieldKey(name) {
const baseName = name.replace(/\s*\(.*\)\s*$/, '').trim();
return baseName
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '_')
.replace(/^_+|_+$/g, '');
}
/**
* Builds the user stats text string using custom stat names
* @returns {string} Formatted stats text for tracker
*/
export function buildUserStatsText() {
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'satiety', name: 'Satiety', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true },
{ id: 'hygiene', name: 'Hygiene', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
],
statusSection: { enabled: true, showMoodEmoji: true, customFields: ['Conditions'] },
skillsSection: { enabled: false, label: 'Skills' }
};
let text = '';
// Add enabled custom stats
const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id);
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
text += `${stat.name}: ${value}%\n`;
}
// Add status section if enabled
if (config.statusSection.enabled) {
if (config.statusSection.showMoodEmoji) {
text += `${stats.mood}: `;
}
text += `${stats.conditions || 'None'}\n`;
}
// Add inventory summary
const inventorySummary = buildInventorySummary(stats.inventory);
text += inventorySummary;
// Add skills if enabled
if (config.skillsSection.enabled && stats.skills) {
text += `\n${config.skillsSection.label}: ${stats.skills}`;
}
return text.trim();
}
/**
* Updates lastGeneratedData.userStats and committedTrackerData.userStats
* Maintains JSON format if current data is JSON, otherwise uses text format.
* @private
*/
function updateUserStatsData() {
// Check if current data is in JSON format
const currentData = lastGeneratedData.userStats || committedTrackerData.userStats;
if (currentData) {
const trimmed = currentData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// Maintain JSON format
try {
const jsonData = JSON.parse(currentData);
if (jsonData && typeof jsonData === 'object') {
const stats = extensionSettings.userStats;
const config = extensionSettings.trackerConfig?.userStats || {};
const enabledStats = config.customStats?.filter(stat => stat && stat.enabled && stat.name && stat.id) || [];
// Build stats array - include all stats from extensionSettings, not just enabled ones
// This preserves custom stats that AI might have added or that user has disabled
const statsArray = [];
const processedIds = new Set();
// First, add all enabled stats from config (maintains order)
enabledStats.forEach(stat => {
statsArray.push({
id: stat.id,
name: stat.name,
value: stats[stat.id] !== undefined ? stats[stat.id] : 100
});
processedIds.add(stat.id);
});
// Then, add any other numeric stats from extensionSettings that aren't in config
// (these could be custom stats the AI added or disabled stats)
const customFields = config.statusSection?.customFields || [];
const excludeFields = new Set(['mood', ...customFields.map(f => toFieldKey(f)), 'inventory', 'skills', 'level']);
Object.entries(stats).forEach(([key, value]) => {
if (!processedIds.has(key) && !excludeFields.has(key) && typeof value === 'number') {
statsArray.push({
id: key,
name: key.charAt(0).toUpperCase() + key.slice(1),
value: value
});
}
});
jsonData.stats = statsArray;
// Update status - include all custom status fields
jsonData.status = {
mood: stats.mood || '😐'
};
// Add all custom status fields
for (const fieldName of customFields) {
const fieldKey = toFieldKey(fieldName);
jsonData.status[fieldKey] = stats[fieldKey] || 'None';
}
// Update inventory (convert to v3 format)
const convertToV3Items = (itemString) => {
if (!itemString) return [];
const items = itemString.split(',').map(s => s.trim()).filter(s => s);
return items.map(item => {
const qtyMatch = item.match(/^(\\d+)x\\s+(.+)$/);
if (qtyMatch) {
return { name: qtyMatch[2].trim(), quantity: parseInt(qtyMatch[1]) };
}
return { name: item, quantity: 1 };
});
};
jsonData.inventory = {
onPerson: convertToV3Items(stats.inventory?.onPerson),
clothing: convertToV3Items(stats.inventory?.clothing),
stored: stats.inventory?.stored || {},
assets: convertToV3Items(stats.inventory?.assets)
};
// Update quests
jsonData.quests = extensionSettings.quests || { main: '', optional: [] };
// Update skills if present
if (stats.skills) {
jsonData.skills = Array.isArray(stats.skills) ? stats.skills :
stats.skills.split(',').map(s => s.trim()).filter(s => s);
}
const updatedJSON = JSON.stringify(jsonData, null, 2);
lastGeneratedData.userStats = updatedJSON;
committedTrackerData.userStats = updatedJSON;
return;
}
} catch (e) {
console.warn('[RPG Companion] Failed to parse JSON, falling back to text format:', e);
}
}
}
// Fall back to text format
const statsText = buildUserStatsText();
lastGeneratedData.userStats = statsText;
committedTrackerData.userStats = statsText;
}
/**
* Renders the user stats panel with health bars, mood, inventory, and classic stats.
* Includes event listeners for editable fields.
```
*/
export function renderUserStats() {
if (!extensionSettings.showUserStats || !$userStatsContainer) {
return;
}
// Don't render if no data exists (e.g., after cache clear)
// Check both lastGeneratedData and committedTrackerData
// console.log('[RPG UserStats Render] Checking data:', {
// hasLastGenerated: !!lastGeneratedData.userStats,
// hasCommitted: !!committedTrackerData.userStats,
// lastGeneratedPreview: lastGeneratedData.userStats ? lastGeneratedData.userStats.substring(0, 100) : 'null',
// committedPreview: committedTrackerData.userStats ? committedTrackerData.userStats.substring(0, 100) : 'null'
// });
if (!lastGeneratedData.userStats && !committedTrackerData.userStats) {
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html('<div class="rpg-inventory-empty">' + (i18n.getTranslation('userStats.empty') || 'No statuses generated yet') + '</div>');
return;
}
// Use lastGeneratedData if available, otherwise fall back to committed data
if (!lastGeneratedData.userStats && committedTrackerData.userStats) {
lastGeneratedData.userStats = committedTrackerData.userStats;
}
const stats = extensionSettings.userStats;
// console.log('[RPG UserStats Render] Current extensionSettings.userStats:', {
// health: stats.health,
// satiety: stats.satiety,
// energy: stats.energy,
// hygiene: stats.hygiene,
// arousal: stats.arousal,
// mood: stats.mood,
// conditions: stats.conditions
// });
const config = extensionSettings.trackerConfig?.userStats || {
customStats: [
{ id: 'health', name: 'Health', enabled: true },
{ id: 'satiety', name: 'Satiety', enabled: true },
{ id: 'energy', name: 'Energy', enabled: true },
{ id: 'hygiene', name: 'Hygiene', enabled: true },
{ id: 'arousal', name: 'Arousal', enabled: true }
],
rpgAttributes: [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
],
statusSection: { enabled: true, showMoodEmoji: true, customFields: ['Conditions'] },
skillsSection: { enabled: false, label: 'Skills' }
};
const userName = getContext().name1;
// Initialize lastGeneratedData.userStats if it doesn't exist
if (!lastGeneratedData.userStats) {
lastGeneratedData.userStats = buildUserStatsText();
}
// Get user portrait
let userPortrait = FALLBACK_AVATAR_DATA_URI;
if (user_avatar) {
const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar);
if (thumbnailUrl) {
userPortrait = thumbnailUrl;
}
}
// Create gradient from low to high color with opacity
const colors = getStatBarColors();
const gradient = `linear-gradient(to right, ${colors.low}, ${colors.high})`;
// Check if stats bars section is locked
const isStatsLocked = isItemLocked('userStats', 'stats');
const lockIcon = isStatsLocked ? '🔒' : '🔓';
const lockTitle = isStatsLocked ? (i18n.getTranslation('userStats.statsLocked') || 'Stats locked') : (i18n.getTranslation('userStats.statsUnlocked') || 'Stats unlocked');
const lockedClass = isStatsLocked ? ' locked' : '';
let html = '<div class="rpg-stats-content">';
html += '<div class="rpg-stats-left">';
// User info row
const showLevel = extensionSettings.trackerConfig?.userStats?.showLevel !== false;
html += `
<div class="rpg-user-info-row">
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
<span class="rpg-user-name">${userName}</span>
${showLevel ? `<span style="opacity: 0.5;">|</span>
<span class="rpg-level-label">${i18n.getTranslation('userStats.level') || 'Level'}</span>
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="${i18n.getTranslation('userStats.clickToEditLevel') || 'Click to edit level'}">${extensionSettings.level}</span>` : ''}
</div>
`;
// Dynamic stats grid - only show enabled stats
const showLockIcons = extensionSettings.showLockIcons ?? true;
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${lockedClass}" data-tracker="userStats" data-path="stats" title="${lockTitle}">${lockIcon}</span>`;
}
html += '<div class="rpg-stats-grid">';
const enabledStats = config.customStats.filter(stat => stat && stat.enabled && stat.name && stat.id);
const displayMode = config.statsDisplayMode || 'percentage';
for (const stat of enabledStats) {
const value = stats[stat.id] !== undefined ? stats[stat.id] : 100;
const maxValue = stat.maxValue || 100;
// Calculate percentage for bar fill
let percentage;
let displayValue;
if (displayMode === 'number') {
// In number mode, value is already the number (0 to maxValue)
percentage = maxValue > 0 ? (value / maxValue) * 100 : 100;
displayValue = `${value}/${maxValue}`;
} else {
// In percentage mode, value is 0-100
percentage = value;
displayValue = `${value}%`;
}
html += `
<div class="rpg-stat-row">
<span class="rpg-stat-label rpg-editable-stat-name" contenteditable="true" data-field="${stat.id}" title="${i18n.getTranslation('userStats.clickToEditStatName') || 'Click to edit stat name'}">${stat.name}:</span>
<div class="rpg-stat-bar" style="background: ${gradient}">
<div class="rpg-stat-fill" style="width: ${100 - percentage}%"></div>
</div>
<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${stat.id}" data-max="${maxValue}" data-mode="${displayMode}" title="${i18n.getTranslation('userStats.clickToEditStatValue') || 'Click to edit stat value'}">${displayValue}</span>
</div>
`;
}
html += '</div>';
// Status section (conditionally rendered)
if (config.statusSection.enabled) {
const isMoodLocked = isItemLocked('userStats', 'status');
const moodLockIcon = isMoodLocked ? '🔒' : '🔓';
const moodLockTitle = isMoodLocked ? (i18n.getTranslation('userStats.moodLocked') || 'Mood locked') : (i18n.getTranslation('userStats.moodUnlocked') || 'Mood unlocked');
const moodLockedClass = isMoodLocked ? ' locked' : '';
html += '<div class="rpg-mood">';
if (showLockIcons) {
html += `<span class="rpg-section-lock-icon${moodLockedClass}" data-tracker="userStats" data-path="status" title="${moodLockTitle}">${moodLockIcon}</span>`;
}
if (config.statusSection.showMoodEmoji) {
html += `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="${i18n.getTranslation('userStats.clickToEditEmoji') || 'Click to edit emoji'}">${stats.mood}</div>`;
}
// Render custom status fields
if (config.statusSection.customFields && config.statusSection.customFields.length > 0) {
for (const fieldName of config.statusSection.customFields) {
const fieldKey = toFieldKey(fieldName);
let fieldValue = stats[fieldKey] || 'None';
// Handle array format (from JSON)
if (Array.isArray(fieldValue)) {
fieldValue = fieldValue.join(', ') || 'None';
} else if (typeof fieldValue === 'string') {
// Strip brackets if present (from JSON array format)
fieldValue = fieldValue.replace(/^\[|\]$/g, '').trim();
}
html += `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="${fieldKey}" title="${i18n.getTranslation('userStats.clickToEdit') || 'Click to edit'} ${fieldName}">${fieldValue}</div>`;
}
}
html += '</div>';
}
// Skills section (conditionally rendered)
if (config.skillsSection.enabled) {
const isSkillsLocked = isItemLocked('userStats', 'skills');
const skillsLockIcon = isSkillsLocked ? '🔒' : '🔓';
const skillsLockTitle = isSkillsLocked ? (i18n.getTranslation('userStats.skillsLocked') || 'Skills locked') : (i18n.getTranslation('userStats.skillsUnlocked') || 'Skills unlocked');
const skillsLockedClass = isSkillsLocked ? ' locked' : '';
let skillsValue = 'None';
// Handle JSON array format: [{name: "Art"}, {name: "Coding"}]
if (Array.isArray(stats.skills)) {
skillsValue = stats.skills.map(s => s.name || s).join(', ') || 'None';
} else if (stats.skills) {
skillsValue = stats.skills;
}
html += `
<div class="rpg-skills-section">`;
if (showLockIcons) {
html += `
<span class="rpg-section-lock-icon${skillsLockedClass}" data-tracker="userStats" data-path="skills" title="${skillsLockTitle}">${skillsLockIcon}</span>`;
}
html += `
<span class="rpg-skills-label">${config.skillsSection.label}:</span>
<div class="rpg-skills-value rpg-editable" contenteditable="true" data-field="skills" title="${i18n.getTranslation('userStats.clickToEditSkills') || 'Click to edit skills'}">${skillsValue}</div>
</div>
`;
}
html += '</div>'; // Close rpg-stats-left
// RPG Attributes section (dynamically generated from config)
// Check if RPG Attributes section is enabled
const showRPGAttributes = config.showRPGAttributes !== undefined ? config.showRPGAttributes : true;
if (showRPGAttributes) {
// Use attributes from config, with fallback to defaults if not configured
const rpgAttributes = (config.rpgAttributes && config.rpgAttributes.length > 0) ? config.rpgAttributes : [
{ id: 'str', name: 'STR', enabled: true },
{ id: 'dex', name: 'DEX', enabled: true },
{ id: 'con', name: 'CON', enabled: true },
{ id: 'int', name: 'INT', enabled: true },
{ id: 'wis', name: 'WIS', enabled: true },
{ id: 'cha', name: 'CHA', enabled: true }
];
const enabledAttributes = rpgAttributes.filter(attr => attr && attr.enabled && attr.name && attr.id);
if (enabledAttributes.length > 0) {
const equipmentBonuses = getEquipmentBonuses();
html += `
<div class="rpg-stats-right">
<div class="rpg-classic-stats">
<div class="rpg-classic-stats-grid">
`;
enabledAttributes.forEach(attr => {
const value = extensionSettings.classicStats[attr.id] !== undefined ? extensionSettings.classicStats[attr.id] : 10;
const bonus = equipmentBonuses[attr.id] || 0;
const bonusHtml = bonus > 0 ? `<span class="rpg-classic-stat-bonus" title="Equipment bonus: +${bonus}"> +${bonus}</span>` : '';
html += `
<div class="rpg-classic-stat" data-stat="${attr.id}">
<span class="rpg-classic-stat-label">${attr.name}</span>
<div class="rpg-classic-stat-buttons">
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="${attr.id}"></button>
<span class="rpg-classic-stat-value">${value}${bonusHtml}</span>
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="${attr.id}">+</button>
</div>
</div>
`;
});
html += `
</div>
</div>
</div>
`;
}
}
html += '</div>'; // Close rpg-stats-content
// console.log('[RPG UserStats Render] Generated HTML length:', html.length);
// console.log('[RPG UserStats Render] HTML preview:', html.substring(0, 300));
// console.log('[RPG UserStats Render] Container exists:', !!$userStatsContainer, '$userStatsContainer length:', $userStatsContainer?.length);
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
// Use change detection to skip unnecessary DOM updates
const domUpdated = updateIfChanged($userStatsContainer, html, 'rpg-user-stats');
// console.log('[RPG UserStats Render] ✓ HTML rendered to #rpg-user-stats container, updated:', domUpdated);
// Only re-bind event listeners if DOM was actually updated
if (domUpdated) {
$userStatsContainer.find('.rpg-editable-stat').on('blur', function () {
const field = $(this).data('field');
const mode = $(this).data('mode');
const maxValue = parseInt($(this).data('max')) || 100;
const textValue = $(this).text().trim();
let value;
if (mode === 'number') {
const parts = textValue.split('/');
value = parseInt(parts[0]);
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(maxValue, value));
} else {
value = parseInt(textValue.replace('%', ''));
if (isNaN(value)) {
value = 0;
}
value = Math.max(0, Math.min(100, value));
}
extensionSettings.userStats[field] = value;
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
renderUserStats();
updateFabWidgets();
});
// Add event listeners for mood/conditions editing
$userStatsContainer.find('.rpg-mood-emoji.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.mood = value || '😐';
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
$userStatsContainer.find('.rpg-mood-conditions.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
const fieldKey = $(this).data('field');
extensionSettings.userStats[fieldKey] = value || 'None';
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
// Add event listener for skills editing
$userStatsContainer.find('.rpg-skills-value.rpg-editable').on('blur', function () {
const value = $(this).text().trim();
extensionSettings.userStats.skills = value || 'None';
updateUserStatsData();
saveSettings();
saveChatData();
updateMessageSwipeData();
});
// Add event listeners for stat name editing
$userStatsContainer.find('.rpg-editable-stat-name').on('blur', function () {
const field = $(this).data('field');
const value = $(this).text().trim().replace(':', '');
if (!extensionSettings.statNames) {
extensionSettings.statNames = {
health: 'Health',
satiety: 'Satiety',
energy: 'Energy',
hygiene: 'Hygiene',
arousal: 'Arousal'
};
}
extensionSettings.statNames[field] = value || extensionSettings.statNames[field];
saveSettings();
saveChatData();
renderUserStats();
});
// Add event listener for level editing
$userStatsContainer.find('.rpg-level-value.rpg-editable').on('blur', function () {
let value = parseInt($(this).text().trim());
if (isNaN(value) || value < 1) {
value = 1;
}
value = Math.min(100, value);
extensionSettings.level = value;
saveSettings();
saveChatData();
updateMessageSwipeData();
renderUserStats();
});
// Prevent line breaks in level field
$userStatsContainer.find('.rpg-level-value.rpg-editable').on('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
$(this).blur();
}
});
// Add event listener for section lock icon clicks (support both click and touch)
$userStatsContainer.find('.rpg-section-lock-icon').on('click touchend', function (e) {
e.preventDefault();
e.stopPropagation();
const $icon = $(this);
const trackerType = $icon.data('tracker');
const itemPath = $icon.data('path');
const currentlyLocked = isItemLocked(trackerType, itemPath);
setItemLock(trackerType, itemPath, !currentlyLocked);
const newIcon = !currentlyLocked ? '🔒' : '🔓';
const newTitle = !currentlyLocked ? (i18n.getTranslation('infoBox.locked') || 'Locked') : (i18n.getTranslation('infoBox.unlocked') || 'Unlocked');
$icon.text(newIcon);
$icon.attr('title', newTitle);
$icon.toggleClass('locked', !currentlyLocked);
saveSettings();
});
} // end if (domUpdated)
}