Fix tracker issues and add deprecation notice

This commit is contained in:
Spicy_Marinara
2026-05-04 13:08:52 +02:00
parent 70792f8a2a
commit 38fb3d8c51
11 changed files with 423 additions and 42 deletions
+279 -10
View File
@@ -3,7 +3,7 @@
* Handles saving/loading extension settings and chat data
*/
import { saveSettingsDebounced, chat_metadata, saveChatDebounced } from '../../../../../../script.js';
import { saveSettingsDebounced, chat_metadata, saveChatDebounced, getCurrentChatId } from '../../../../../../script.js';
import { getContext } from '../../../../../extensions.js';
import {
extensionSettings,
@@ -23,6 +23,245 @@ import { validateStoredInventory, cleanItemString } from '../utils/security.js';
import { migrateToV3JSON } from '../utils/jsonMigration.js';
const extensionName = 'third-party/rpg-companion-sillytavern';
const CURRENT_SETTINGS_VERSION = 5;
const DEFAULT_USER_STATS = {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None',
skills: [],
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
};
const DEFAULT_EXTENSION_SETTINGS = cloneSerializable(extensionSettings);
DEFAULT_EXTENSION_SETTINGS.settingsVersion = CURRENT_SETTINGS_VERSION;
let hasDeferredChatDataSave = false;
function cloneSerializable(value) {
if (value === undefined) {
return undefined;
}
try {
return structuredClone(value);
} catch {
return JSON.parse(JSON.stringify(value));
}
}
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function mergeWithDefaults(defaultValue, savedValue) {
if (savedValue === undefined) {
return cloneSerializable(defaultValue);
}
if (isPlainObject(defaultValue) && isPlainObject(savedValue)) {
const merged = cloneSerializable(defaultValue);
for (const [key, value] of Object.entries(savedValue)) {
merged[key] = mergeWithDefaults(defaultValue[key], value);
}
return merged;
}
return cloneSerializable(savedValue);
}
function parseMaybeJSON(value) {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function stringifyInventoryItems(items) {
if (typeof items === 'string') {
return items.trim() || 'None';
}
if (!Array.isArray(items)) {
return 'None';
}
const text = items
.map(item => {
if (isPlainObject(item) && item.name) {
const quantity = Number(item.quantity);
return quantity > 1 ? `${quantity}x ${item.name}` : item.name;
}
return String(item || '').trim();
})
.filter(Boolean)
.join(', ');
return text || 'None';
}
function normalizeStoredInventory(stored) {
if (!isPlainObject(stored)) {
return {};
}
const normalized = {};
for (const [location, items] of Object.entries(stored)) {
normalized[location] = stringifyInventoryItems(items);
}
return normalized;
}
function normalizeInventoryValue(inventory) {
const parsedInventory = parseMaybeJSON(inventory);
if (isPlainObject(parsedInventory) && (
Array.isArray(parsedInventory.onPerson)
|| Array.isArray(parsedInventory.clothing)
|| Array.isArray(parsedInventory.assets)
|| isPlainObject(parsedInventory.stored)
)) {
return {
version: 2,
onPerson: stringifyInventoryItems(parsedInventory.onPerson),
clothing: stringifyInventoryItems(parsedInventory.clothing),
stored: normalizeStoredInventory(parsedInventory.stored),
assets: stringifyInventoryItems(parsedInventory.assets)
};
}
const migrationResult = migrateInventory(parsedInventory);
return mergeWithDefaults(DEFAULT_USER_STATS.inventory, migrationResult.inventory);
}
function normalizeUserStatsValue(userStats) {
const parsedStats = parseMaybeJSON(userStats);
const normalized = cloneSerializable(DEFAULT_USER_STATS);
if (!isPlainObject(parsedStats)) {
return normalized;
}
if (Array.isArray(parsedStats.stats)) {
for (const stat of parsedStats.stats) {
if (!stat || typeof stat !== 'object') continue;
const id = stat.id || stat.name?.toLowerCase?.();
if (id && stat.value !== undefined) {
normalized[id] = stat.value;
}
}
} else {
for (const [key, value] of Object.entries(parsedStats)) {
if (!['stats', 'status', 'inventory', 'quests'].includes(key) && value !== undefined) {
normalized[key] = cloneSerializable(value);
}
}
}
if (isPlainObject(parsedStats.status)) {
for (const [key, value] of Object.entries(parsedStats.status)) {
if (value !== undefined) {
normalized[key] = cloneSerializable(value);
}
}
}
if (parsedStats.inventory !== undefined) {
normalized.inventory = normalizeInventoryValue(parsedStats.inventory);
}
for (const [key, defaultValue] of Object.entries(DEFAULT_USER_STATS)) {
if (typeof defaultValue !== 'number') continue;
const numericValue = Number(normalized[key]);
normalized[key] = Number.isFinite(numericValue) ? numericValue : defaultValue;
}
return mergeWithDefaults(DEFAULT_USER_STATS, normalized);
}
function normalizeQuestValue(quest) {
let value = quest;
while (isPlainObject(value) && value.value !== undefined) {
value = value.value;
}
if (typeof value === 'string') {
return value.trim() || 'None';
}
if (isPlainObject(value)) {
return value.title || value.description || JSON.stringify(value);
}
return value == null ? 'None' : String(value);
}
function normalizeQuestsValue(quests) {
if (!isPlainObject(quests)) {
return { main: 'None', optional: [] };
}
const optionalSource = Array.isArray(quests.optional)
? quests.optional
: (Array.isArray(quests.active) ? quests.active : []);
return {
main: normalizeQuestValue(quests.main),
optional: optionalSource
.map(normalizeQuestValue)
.filter(quest => quest && quest !== 'None')
};
}
function normalizeSettings(savedSettings) {
const sourceSettings = isPlainObject(savedSettings) ? savedSettings : {};
const normalized = mergeWithDefaults(DEFAULT_EXTENSION_SETTINGS, sourceSettings);
const savedVersion = Number(sourceSettings.settingsVersion);
normalized.settingsVersion = Number.isFinite(savedVersion) && savedVersion > 0 ? savedVersion : 1;
normalized.userStats = normalizeUserStatsValue(sourceSettings.userStats);
const parsedUserStats = parseMaybeJSON(sourceSettings.userStats);
if (sourceSettings.quests !== undefined) {
normalized.quests = normalizeQuestsValue(sourceSettings.quests);
} else if (isPlainObject(parsedUserStats) && parsedUserStats.quests !== undefined) {
normalized.quests = normalizeQuestsValue(parsedUserStats.quests);
}
return {
settings: normalized,
changed: JSON.stringify(normalized) !== JSON.stringify(savedSettings)
};
}
function isChatDataSaveReady() {
return !!(
chat_metadata
&& typeof chat_metadata === 'object'
&& chat_metadata.integrity
&& getCurrentChatId()
);
}
function hasTrackerPayload(payload) {
return !!(payload && typeof payload === 'object' && (
@@ -273,7 +512,8 @@ function validateSettings(settings) {
// Check for required top-level properties
if (typeof settings.enabled !== 'boolean' ||
typeof settings.autoUpdate !== 'boolean' ||
!settings.userStats || typeof settings.userStats !== 'object') {
!settings.userStats || typeof settings.userStats !== 'object' ||
Array.isArray(settings.userStats)) {
console.warn('[RPG Companion] Settings validation failed: missing required properties');
return false;
}
@@ -282,7 +522,8 @@ function validateSettings(settings) {
const stats = settings.userStats;
if (typeof stats.health !== 'number' ||
typeof stats.satiety !== 'number' ||
typeof stats.energy !== 'number') {
typeof stats.energy !== 'number' ||
!stats.inventory || typeof stats.inventory !== 'object') {
console.warn('[RPG Companion] Settings validation failed: invalid userStats structure');
return false;
}
@@ -307,21 +548,23 @@ export function loadSettings() {
if (extension_settings[extensionName]) {
const savedSettings = extension_settings[extensionName];
const normalizedResult = normalizeSettings(savedSettings);
const normalizedSettings = normalizedResult.settings;
// Validate loaded settings
if (!validateSettings(savedSettings)) {
// Validate loaded settings after schema repair/normalization
if (!validateSettings(normalizedSettings)) {
console.warn('[RPG Companion] Loaded settings failed validation, using defaults');
console.warn('[RPG Companion] Invalid settings:', savedSettings);
console.warn('[RPG Companion] Invalid settings:', normalizedSettings);
// Save valid defaults to replace corrupt data
saveSettings();
return;
}
updateExtensionSettings(savedSettings);
updateExtensionSettings(normalizedSettings);
// Perform settings migrations based on version
const currentVersion = extensionSettings.settingsVersion || 1;
let settingsChanged = false;
let settingsChanged = normalizedResult.changed;
// Migration to version 2: Enable dynamic weather for existing users
if (currentVersion < 2) {
@@ -455,7 +698,8 @@ export function saveSettings() {
* Saves RPG data to the current chat's metadata.
*/
export function saveChatData() {
if (!chat_metadata) {
if (!isChatDataSaveReady()) {
hasDeferredChatDataSave = true;
return;
}
@@ -480,6 +724,16 @@ export function saveChatData() {
saveChatDebounced();
}
export function flushDeferredChatDataSave() {
if (!hasDeferredChatDataSave || !isChatDataSaveReady()) {
return false;
}
hasDeferredChatDataSave = false;
saveChatData();
return true;
}
/**
* Mirrors a tracker data entry into message.swipe_info so it survives page reloads.
* ST only serializes swipe_info to disk; message.extra is in-memory only.
@@ -711,6 +965,7 @@ export function loadChatData() {
inventory: {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
@@ -830,6 +1085,7 @@ function validateInventoryStructure(inventory, source) {
extensionSettings.userStats.inventory = {
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
};
@@ -861,6 +1117,20 @@ function validateInventoryStructure(inventory, source) {
}
}
// Validate clothing field
if (typeof inventory.clothing !== 'string') {
console.warn(`[RPG Companion] Invalid clothing from ${source}, resetting to "None"`);
inventory.clothing = "None";
needsSave = true;
} else {
const cleanedClothing = cleanItemString(inventory.clothing);
if (cleanedClothing !== inventory.clothing) {
console.warn(`[RPG Companion] Cleaned corrupted items from clothing inventory (${source})`);
inventory.clothing = cleanedClothing;
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`);
@@ -1559,4 +1829,3 @@ export function importPresets(importData, overwrite = false) {
return importCount;
}
+16 -20
View File
@@ -10,7 +10,7 @@
* Extension settings - persisted to SillyTavern settings
*/
export let extensionSettings = {
settingsVersion: 4, // Version number for settings migrations
settingsVersion: 5, // Version number for settings migrations
enabled: true,
autoUpdate: false,
updateDepth: 4, // How many messages to include in the context
@@ -108,27 +108,23 @@ export let extensionSettings = {
stats: { enabled: true }, // All stats as compact numbers
attributes: { enabled: true } // Compact RPG attributes display
},
userStats: JSON.stringify({
stats: [
{ id: 'health', name: 'Health', value: 100 },
{ id: 'satiety', name: 'Satiety', value: 100 },
{ id: 'energy', name: 'Energy', value: 100 },
{ id: 'hygiene', name: 'Hygiene', value: 100 },
{ id: 'arousal', name: 'Arousal', value: 0 }
],
status: {
mood: '😐',
conditions: 'None'
},
userStats: {
health: 100,
satiety: 100,
energy: 100,
hygiene: 100,
arousal: 0,
mood: '😐',
conditions: 'None',
skills: [],
inventory: {
onPerson: [],
stored: []
},
quests: {
active: [],
completed: []
version: 2,
onPerson: "None",
clothing: "None",
stored: {},
assets: "None"
}
}, null, 2),
},
statNames: {
health: 'Health',
satiety: 'Satiety',
+4 -1
View File
@@ -33,7 +33,8 @@ import {
setMessageSwipeTrackerData,
getSwipeData,
commitTrackerDataFromPriorMessage,
inheritSwipeDataFromPriorMessage
inheritSwipeDataFromPriorMessage,
flushDeferredChatDataSave
} from '../../core/persistence.js';
import { i18n } from '../../core/i18n.js';
@@ -389,6 +390,7 @@ export function onChatLoaded() {
restoreOrRepairLatestTrackerState();
maybeRehydrateUserStatsFromDisplayData();
rerenderRpgState();
flushDeferredChatDataSave();
scheduleChatStateRehydration();
updateAllCheckpointIndicators();
}
@@ -658,6 +660,7 @@ export function onCharacterChanged() {
// Load chat-specific data when switching chats
loadChatData();
flushDeferredChatDataSave();
// chat_metadata may not reflect the actual chat tail for branches, so
// loadChatData() may have just restored stale data from the parent chat.
+1 -1
View File
@@ -219,7 +219,7 @@ export function renderOptionalQuestsView(optionalQuests) {
* Main render function for quests
*/
export function renderQuests() {
if (!extensionSettings.showInventory || !$questsContainer) {
if (!extensionSettings.showQuests || !$questsContainer) {
return;
}
+63
View File
@@ -612,6 +612,28 @@ export function showWelcomeModalIfNeeded() {
}
}
/**
* Shows the deprecation notice once for users updating to the deprecation release.
* @returns {boolean} True when the modal was displayed.
*/
export function showDeprecationModalIfNeeded() {
const DEPRECATION_NOTICE_VERSION = '3.7.4';
const STORAGE_KEY = 'rpg_companion_deprecation_notice_seen';
try {
const seenVersion = localStorage.getItem(STORAGE_KEY);
if (seenVersion !== DEPRECATION_NOTICE_VERSION) {
showDeprecationModal(DEPRECATION_NOTICE_VERSION, STORAGE_KEY);
return true;
}
} catch (error) {
console.error('[RPG Companion] Failed to check deprecation modal status:', error);
}
return false;
}
/**
* Shows the welcome modal
* @param {string} version - The version to mark as seen
@@ -663,3 +685,44 @@ function showWelcomeModal(version, storageKey) {
}
}, { once: true });
}
function showDeprecationModal(version, storageKey) {
const modal = document.getElementById('rpg-deprecation-modal');
if (!modal) {
console.error('[RPG Companion] Deprecation modal element not found');
return;
}
const theme = extensionSettings.theme || 'default';
modal.setAttribute('data-theme', theme);
modal.style.display = 'flex';
modal.classList.add('is-open');
const closeBtn = document.getElementById('rpg-deprecation-close');
const gotItBtn = document.getElementById('rpg-deprecation-got-it');
const closeModal = () => {
modal.classList.add('is-closing');
setTimeout(() => {
modal.style.display = 'none';
modal.classList.remove('is-open', 'is-closing');
}, 200);
try {
localStorage.setItem(storageKey, version);
} catch (error) {
console.error('[RPG Companion] Failed to save deprecation modal status:', error);
}
};
closeBtn?.addEventListener('click', closeModal, { once: true });
gotItBtn?.addEventListener('click', closeModal, { once: true });
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
}, { once: true });
}