Files
rpg-companion-sillytavern/src/core/persistence.js
T
Spicy_Marinara 4abceb48a2 Fix: Rewrite updateCharacterField for new multi-line format
- Completely rewrote updateCharacterField function to work with new multi-line Present Characters format
- Now parses character blocks by '- Name' lines instead of pipe-separated format
- Handles updating Details, Relationship, and Stats lines correctly
- Supports all field types: name, emoji, custom fields, relationship, character stats
- Creates new character blocks if character doesn't exist
- Fixes bug where edits would revert because old format logic couldn't parse new format
- Users can now successfully edit all Present Characters fields
2025-11-01 23:26:36 +01:00

491 lines
18 KiB
JavaScript

/**
* Core Persistence Module
* Handles saving/loading extension settings and chat data
*/
import { saveSettingsDebounced, chat_metadata, saveChatDebounced } from '../../../../../../script.js';
import { getContext } from '../../../../../extensions.js';
import {
extensionSettings,
lastGeneratedData,
committedTrackerData,
setExtensionSettings,
updateExtensionSettings,
setLastGeneratedData,
setCommittedTrackerData,
FEATURE_FLAGS
} from './state.js';
import { migrateInventory } from '../utils/migration.js';
import { validateStoredInventory, cleanItemString } from '../utils/security.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
/**
* Validates extension settings structure
* @param {Object} settings - Settings object to validate
* @returns {boolean} True if valid, false otherwise
*/
function validateSettings(settings) {
if (!settings || typeof settings !== 'object') {
return false;
}
// Check for required top-level properties
if (typeof settings.enabled !== 'boolean' ||
typeof settings.autoUpdate !== 'boolean' ||
!settings.userStats || typeof settings.userStats !== 'object') {
console.warn('[RPG Companion] Settings validation failed: missing required properties');
return false;
}
// Validate userStats structure
const stats = settings.userStats;
if (typeof stats.health !== 'number' ||
typeof stats.satiety !== 'number' ||
typeof stats.energy !== 'number') {
console.warn('[RPG Companion] Settings validation failed: invalid userStats structure');
return false;
}
return true;
}
/**
* Loads the extension settings from the global settings object.
* Automatically migrates v1 inventory to v2 format if needed.
*/
export function loadSettings() {
try {
const context = getContext();
const extension_settings = context.extension_settings || context.extensionSettings;
// Validate extension_settings structure
if (!extension_settings || typeof extension_settings !== 'object') {
console.warn('[RPG Companion] extension_settings is not available, using default settings');
return;
}
if (extension_settings[extensionName]) {
const savedSettings = extension_settings[extensionName];
// Validate loaded settings
if (!validateSettings(savedSettings)) {
console.warn('[RPG Companion] Loaded settings failed validation, using defaults');
console.warn('[RPG Companion] Invalid settings:', savedSettings);
// Save valid defaults to replace corrupt data
saveSettings();
return;
}
updateExtensionSettings(savedSettings);
// console.log('[RPG Companion] Settings loaded:', extensionSettings);
} else {
// console.log('[RPG Companion] No saved settings found, using defaults');
}
// Migrate inventory if feature flag enabled
if (FEATURE_FLAGS.useNewInventory) {
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
if (migrationResult.migrated) {
console.log(`[RPG Companion] Inventory migrated from ${migrationResult.source} to v2 format`);
extensionSettings.userStats.inventory = migrationResult.inventory;
saveSettings(); // Persist migrated inventory
}
}
// Migrate to trackerConfig if it doesn't exist
if (!extensionSettings.trackerConfig) {
console.log('[RPG Companion] Migrating to trackerConfig format');
migrateToTrackerConfig();
saveSettings(); // Persist migration
}
} catch (error) {
console.error('[RPG Companion] Error loading settings:', error);
console.error('[RPG Companion] Error details:', error.message, error.stack);
console.warn('[RPG Companion] Using default settings due to load error');
// Settings will remain at defaults from state.js
}
// Validate inventory structure (Bug #3 fix)
validateInventoryStructure(extensionSettings.userStats.inventory, 'settings');
}
/**
* Saves the extension settings to the global settings object.
*/
export function saveSettings() {
const context = getContext();
const extension_settings = context.extension_settings || context.extensionSettings;
if (!extension_settings) {
console.error('[RPG Companion] extension_settings is not available, cannot save');
return;
}
extension_settings[extensionName] = extensionSettings;
saveSettingsDebounced();
}
/**
* Saves RPG data to the current chat's metadata.
*/
export function saveChatData() {
if (!chat_metadata) {
return;
}
chat_metadata.rpg_companion = {
userStats: extensionSettings.userStats,
classicStats: extensionSettings.classicStats,
quests: extensionSettings.quests,
lastGeneratedData: lastGeneratedData,
committedTrackerData: committedTrackerData,
timestamp: Date.now()
};
saveChatDebounced();
}
/**
* Updates the last assistant message's swipe data with current tracker data.
* This ensures user edits are preserved across swipes and included in generation context.
*/
export function updateMessageSwipeData() {
const chat = getContext().chat;
if (!chat || chat.length === 0) {
return;
}
// Find the last assistant message
for (let i = chat.length - 1; i >= 0; i--) {
const message = chat[i];
if (!message.is_user) {
// Found last assistant message - update its swipe data
if (!message.extra) {
message.extra = {};
}
if (!message.extra.rpg_companion_swipes) {
message.extra.rpg_companion_swipes = {};
}
const swipeId = message.swipe_id || 0;
message.extra.rpg_companion_swipes[swipeId] = {
userStats: lastGeneratedData.userStats,
infoBox: lastGeneratedData.infoBox,
characterThoughts: lastGeneratedData.characterThoughts
};
// console.log('[RPG Companion] Updated message swipe data after user edit');
break;
}
}
}
/**
* Loads RPG data from the current chat's metadata.
* Automatically migrates v1 inventory to v2 format if needed.
*/
export function loadChatData() {
if (!chat_metadata || !chat_metadata.rpg_companion) {
// Reset to defaults if no data exists
updateExtensionSettings({
userStats: {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None',
// Use v2 inventory format for defaults
inventory: {
version: 2,
onPerson: "None",
stored: {},
assets: "None"
}
}
});
setLastGeneratedData({
userStats: null,
infoBox: null,
characterThoughts: null,
html: null
});
setCommittedTrackerData({
userStats: null,
infoBox: null,
characterThoughts: null
});
return;
}
const savedData = chat_metadata.rpg_companion;
// Restore stats
if (savedData.userStats) {
extensionSettings.userStats = { ...savedData.userStats };
}
// Restore classic stats
if (savedData.classicStats) {
extensionSettings.classicStats = { ...savedData.classicStats };
}
// Restore quests
if (savedData.quests) {
extensionSettings.quests = { ...savedData.quests };
} else {
// Initialize with defaults if not present
extensionSettings.quests = {
main: "None",
optional: []
};
}
// Restore last generated data
if (savedData.lastGeneratedData) {
setLastGeneratedData({ ...savedData.lastGeneratedData });
}
// Restore committed tracker data
if (savedData.committedTrackerData) {
setCommittedTrackerData({ ...savedData.committedTrackerData });
}
// Migrate inventory in chat data if feature flag enabled
if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) {
const migrationResult = migrateInventory(extensionSettings.userStats.inventory);
if (migrationResult.migrated) {
console.log(`[RPG Companion] Chat inventory migrated from ${migrationResult.source} to v2 format`);
extensionSettings.userStats.inventory = migrationResult.inventory;
saveChatData(); // Persist migrated inventory to chat metadata
}
}
// Validate inventory structure (Bug #3 fix)
validateInventoryStructure(extensionSettings.userStats.inventory, 'chat');
// console.log('[RPG Companion] Loaded chat data:', savedData);
}
/**
* Validates and repairs inventory structure to prevent corruption.
* Ensures all v2 fields exist and are the correct type.
* Fixes Bug #3: Location disappears when switching tabs
*
* @param {Object} inventory - Inventory object to validate
* @param {string} source - Source of load ('settings' or 'chat') for logging
* @private
*/
function validateInventoryStructure(inventory, source) {
if (!inventory || typeof inventory !== 'object') {
console.error(`[RPG Companion] Invalid inventory from ${source}, resetting to defaults`);
extensionSettings.userStats.inventory = {
version: 2,
onPerson: "None",
stored: {},
assets: "None"
};
saveSettings();
return;
}
let needsSave = false;
// Ensure v2 structure
if (inventory.version !== 2) {
console.warn(`[RPG Companion] Inventory from ${source} missing version, setting to 2`);
inventory.version = 2;
needsSave = true;
}
// Validate onPerson field
if (typeof inventory.onPerson !== 'string') {
console.warn(`[RPG Companion] Invalid onPerson from ${source}, resetting to "None"`);
inventory.onPerson = "None";
needsSave = true;
} else {
// Clean items in onPerson (removes corrupted/dangerous items)
const cleanedOnPerson = cleanItemString(inventory.onPerson);
if (cleanedOnPerson !== inventory.onPerson) {
console.warn(`[RPG Companion] Cleaned corrupted items from onPerson inventory (${source})`);
inventory.onPerson = cleanedOnPerson;
needsSave = true;
}
}
// Validate stored field (CRITICAL for Bug #3)
if (!inventory.stored || typeof inventory.stored !== 'object' || Array.isArray(inventory.stored)) {
console.error(`[RPG Companion] Corrupted stored inventory from ${source}, resetting to empty object`);
inventory.stored = {};
needsSave = true;
} else {
// Validate stored object keys/values
const cleanedStored = validateStoredInventory(inventory.stored);
if (JSON.stringify(cleanedStored) !== JSON.stringify(inventory.stored)) {
console.warn(`[RPG Companion] Cleaned dangerous/invalid stored locations from ${source}`);
inventory.stored = cleanedStored;
needsSave = true;
}
}
// Validate assets field
if (typeof inventory.assets !== 'string') {
console.warn(`[RPG Companion] Invalid assets from ${source}, resetting to "None"`);
inventory.assets = "None";
needsSave = true;
} else {
// Clean items in assets (removes corrupted/dangerous items)
const cleanedAssets = cleanItemString(inventory.assets);
if (cleanedAssets !== inventory.assets) {
console.warn(`[RPG Companion] Cleaned corrupted items from assets inventory (${source})`);
inventory.assets = cleanedAssets;
needsSave = true;
}
}
// Persist repairs if needed
if (needsSave) {
console.log(`[RPG Companion] Repaired inventory structure from ${source}, saving...`);
saveSettings();
if (source === 'chat') {
saveChatData();
}
}
}
/**
* Migrates old settings format to new trackerConfig format
* Converts statNames to customStats array and sets up default config
*/
function migrateToTrackerConfig() {
// Initialize trackerConfig if it doesn't exist
if (!extensionSettings.trackerConfig) {
extensionSettings.trackerConfig = {
userStats: {
customStats: [],
showRPGAttributes: true,
statusSection: {
enabled: true,
showMoodEmoji: true,
customFields: ['Conditions']
},
skillsSection: {
enabled: false,
label: 'Skills'
}
},
infoBox: {
widgets: {
date: { enabled: true, format: 'Weekday, Month, Year' },
weather: { enabled: true },
temperature: { enabled: true, unit: 'C' },
time: { enabled: true },
location: { enabled: true },
recentEvents: { enabled: true }
}
},
presentCharacters: {
showEmoji: true,
showName: true,
customFields: [
{ id: 'physicalState', label: 'Physical State', enabled: true, placeholder: 'Visible Physical State (up to three traits)' },
{ id: 'demeanor', label: 'Demeanor Cue', enabled: true, placeholder: 'Observable Demeanor Cue (one trait)' },
{ id: 'relationship', label: 'Relationship', enabled: true, type: 'relationship', placeholder: 'Enemy/Neutral/Friend/Lover' },
{ id: 'internalMonologue', label: 'Internal Monologue', enabled: true, placeholder: 'Internal Monologue (in first person POV, up to three sentences long)' }
],
characterStats: {
enabled: false,
stats: []
}
}
};
}
// Migrate old statNames to customStats if statNames exists
if (extensionSettings.statNames && extensionSettings.trackerConfig.userStats.customStats.length === 0) {
const statOrder = ['health', 'satiety', 'energy', 'hygiene', 'arousal'];
extensionSettings.trackerConfig.userStats.customStats = statOrder.map(id => ({
id: id,
name: extensionSettings.statNames[id] || id.charAt(0).toUpperCase() + id.slice(1),
enabled: true
}));
console.log('[RPG Companion] Migrated statNames to customStats array');
}
// Ensure all stats have corresponding values in userStats
if (extensionSettings.userStats) {
for (const stat of extensionSettings.trackerConfig.userStats.customStats) {
if (extensionSettings.userStats[stat.id] === undefined) {
extensionSettings.userStats[stat.id] = stat.id === 'arousal' ? 0 : 100;
}
}
}
// Migrate old presentCharacters structure to new format
if (extensionSettings.trackerConfig.presentCharacters) {
const pc = extensionSettings.trackerConfig.presentCharacters;
// Check if using old flat customFields structure (has 'label' or 'placeholder' keys)
if (pc.customFields && pc.customFields.length > 0) {
const hasOldFormat = pc.customFields.some(f => f.label || f.placeholder || f.type === 'relationship');
if (hasOldFormat) {
console.log('[RPG Companion] Migrating Present Characters to new structure');
// Extract relationship fields from old customFields
const relationshipFields = ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'];
// Extract non-relationship fields and convert to new format
const newCustomFields = pc.customFields
.filter(f => f.type !== 'relationship' && f.id !== 'internalMonologue')
.map(f => ({
id: f.id,
name: f.label || f.name || 'Field',
enabled: f.enabled !== false,
description: f.placeholder || f.description || ''
}));
// Extract thoughts config from old Internal Monologue field
const thoughtsField = pc.customFields.find(f => f.id === 'internalMonologue');
const thoughts = {
enabled: thoughtsField ? (thoughtsField.enabled !== false) : true,
name: 'Thoughts',
description: thoughtsField?.placeholder || 'Internal monologue (in first person POV, up to three sentences long)'
};
// Update to new structure
pc.relationshipFields = relationshipFields;
pc.customFields = newCustomFields;
pc.thoughts = thoughts;
console.log('[RPG Companion] Present Characters migration complete');
saveSettings(); // Persist the migration
}
}
// Ensure new structure exists even if migration wasn't needed
if (!pc.relationshipFields) {
pc.relationshipFields = ['Lover', 'Friend', 'Ally', 'Enemy', 'Neutral'];
}
if (!pc.relationshipEmojis) {
// Create default emoji mapping from relationshipFields
pc.relationshipEmojis = {
'Lover': '❤️',
'Friend': '⭐',
'Ally': '🤝',
'Enemy': '⚔️',
'Neutral': '⚖️'
};
}
if (!pc.thoughts) {
pc.thoughts = {
enabled: true,
name: 'Thoughts',
description: 'Internal monologue (in first person POV, up to three sentences long)'
};
}
}
}